The operator tested v0.1.0 in Cowork on 2026-11-11. README now says what was measured there and what was not. - miljo: no longer claims that kontroll compares "with tolerance, not bit for bit" when numpy or scipy differ from the note's versions. kontroll always compares byte for byte first, and numpy 2.5.3 gave bit for bit. - kontroll --alt: show every differing line. The cut-off line now names the option instead of only counting the lines left out. - skill simuler: the question before a run must be answerable with yes or no. In the test it became a choice between variants and the run stalled. - skill forbedre: offer the working copy as a zip when the user cannot open the folder it was made in, as in Cowork. 263 tests pass; full kontroll: bit for bit with the note. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
650 lines
31 KiB
Python
650 lines
31 KiB
Python
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
|
|
"""Gjenskapingskontroll: sjekk at modellen i denne mappa regner som notatet.
|
|
|
|
Bruk:
|
|
python3 kjor.py kontroll [--rask] [--alt]
|
|
python3 kontroll.py [--rask] [--alt] (med en Python som har numpy og scipy)
|
|
|
|
--alt viser alle linjene som er ulike. Uten den vises de 40 første per sammenligning, og
|
|
resten telles.
|
|
|
|
Kontrollene, hver med én linje dom og detaljer bare ved avvik:
|
|
1. Kjernen og fasit: sha256 for filene i kjerne/ og fasit/ mot notatets verdier, som står som
|
|
konstanter i denne fila (NOTATETS_KJERNE og NOTATETS_FASIT). Ankeret er notatet, ikke
|
|
filene i samme mappe: kjerne/KILDE.json er bare en opplysning.
|
|
2. diagnose.py på begge eksempelfilene mot fasit/ (tekst, eksakt: diagnose.py bruker bare
|
|
standardbiblioteket, så ingen toleranse).
|
|
3. forlop.py på begge eksempelfilene mot fasit/ (tekst; trenger numpy og scipy).
|
|
4. Full kjøring av sim.py i en midlertidig mappe: datafilene mot fasit/data/ byte for byte,
|
|
med største absolutte avvik per kolonne når en fil er ulik, og utskriften mot
|
|
fasit/sim_utskrift.txt. Om lag 1-2 min; --rask hopper over den.
|
|
5. grensetilfelle() i hver utvidelse i utvidelser/; hvert avvik skal være eksakt 0.
|
|
6. Scenariokjøreren: scenariofilene i eksempler/scenarioer/notatet/ som gjenskaper notatets
|
|
datafiler byte for byte, kjørt gjennom scenario.py i denne modell-mappa, mot fasit/data/.
|
|
De 13 raske er med i --rask; mpc11 (MPC, treg) bare i full kjøring.
|
|
Er en fasit-fil endret i forhold til notatet, sier en sammenligning mot den ingenting om
|
|
notatet; den regnes da som endret.
|
|
|
|
Nivået til slutt:
|
|
- «bit for bit som notatet»: alt er kjørt og likt byte for byte (med --rask: alt unntatt den
|
|
fulle kjøringen, og det står i linja).
|
|
- «innenfor toleranse»: teksten er lik, og hvert tall avviker med høyst én enhet i siste
|
|
viste siffer (det som kan skje med andre versjoner av numpy og scipy, eller en annen maskin).
|
|
- Mangler numpy eller scipy, sies bare at det som ble kjørt, var uten avvik, og hva som ikke
|
|
er kontrollert.
|
|
- «endret»: alt annet. Detaljene sier hvilke av notatets resultater som har flyttet seg, og
|
|
hvor mye. I en arbeidskopi (mappa over modell/ har LES-MEG.md, som arbeidskopi.py lager) er
|
|
det en opplysning, ikke en feil; ellers avhenger det av om modellen er endret med vilje.
|
|
En utvidelse som ikke gjenskaper kjernen, er alltid en feil i utvidelsen.
|
|
|
|
Exit-kode: 0 for bit for bit og innenfor toleranse, 1 for endret, 3 når numpy eller scipy
|
|
mangler for noe av kontrollen (det som kunne kjøres, er kjørt), 2 for feil bruk.
|
|
|
|
Når kjernen synkroniseres fra notatets repo (ny commit), må disse oppdateres sammen:
|
|
kjerne/*.py, kjerne/KILDE.json, fasit/ (lages på nytt med notatets eget miljø), og
|
|
konstantene NOTATETS_COMMIT, NOTATETS_KJERNE og NOTATETS_FASIT her. Sha256 for fasit/data/
|
|
skal være lik notatets paper/data/ ved samme commit.
|
|
|
|
Grensesnitt: main(argv: list[str]) -> int, som kjor.py kaller.
|
|
Utvidelser: utvidelser/<navn>.py (ikke filer som begynner med _) med
|
|
grensetilfelle() -> list av (beskrivelse, største absolutte avvik mot kjernen). Hver
|
|
kjøres i en egen prosess med modell/kjerne og modell/ først i sys.path. Scenariokjøreren
|
|
kjøres på samme måte: scenario.main([fil, "--ut", mappe]) skal skrive <navn>.dat i mappa.
|
|
Bare standardbiblioteket her; kjernen kjøres som egne prosesser med samme Python.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import hashlib
|
|
import importlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
MODELL = Path(__file__).resolve().parent
|
|
KJERNE = MODELL / "kjerne"
|
|
ROT = MODELL.parent
|
|
FASIT = ROT / "fasit"
|
|
EKSEMPLER = ROT / "eksempler"
|
|
SCENARIOER = EKSEMPLER / "scenarioer" / "notatet"
|
|
EKSEMPELFILER = ("eksempel_domstol", "norge_2025")
|
|
ARBEIDSKOPI_MERKE = "LES-MEG.md"
|
|
|
|
# Notatet: https://git.fromaitochitta.com/open/den-dynamiske-rettsstaten @ 2badc25 (se synkroniseringen over).
|
|
NOTATETS_REPO = "https://git.fromaitochitta.com/open/den-dynamiske-rettsstaten"
|
|
NOTATETS_COMMIT = "2badc25e5134ee26309bd8c3bd008ac64afe55a4"
|
|
NOTATETS_KJERNE = {
|
|
"sim.py": "96857a92492f5768bc5d1d98bec30f4292b603c1273913e69901b6a1b220a01e",
|
|
"diagnose.py": "e5a20c68615836dd854c1414346c7932037eefaf1adb29a18458dbd590a3367a",
|
|
"forlop.py": "5b369e30f98783b712f76e5dc90bcea4f711603944690c4f19da01efd29c6e21",
|
|
}
|
|
NOTATETS_FASIT = {
|
|
"data/endo_des.dat": "cc200766c80dd486611b4f0a0e20e229c6f1842763b205e86760bf17458f594f",
|
|
"data/endo_desp.dat": "66794618783bd098a16ddbb427c20d46795f6805e5a14f14122f86e3f8825847",
|
|
"data/endo_e5.dat": "de137561fe952336f94008378f1bed4b72bbec91ca9613a054c47860086c5671",
|
|
"data/endo_ko.dat": "ebbf9a096d04a5a52977303b221ee6d8144fc771354c86b7d0ba85262e543378",
|
|
"data/kjede_s1.dat": "5f7597aea8a39909cfa3784f841a4e4f11cff8822721fa42a5ee90e3ffcd9ce2",
|
|
"data/kjede_s2.dat": "9dedcc6821a286eca9f05d1a908e377f11c1e542baf7a30c3658b1128f7aacf9",
|
|
"data/kjede_s3.dat": "a88c964f3a98c2eb962fa2066b3c1d1f4d092b0abfadc2b93c8cd6d747b9a68d",
|
|
"data/kjede_s4.dat": "6b7dccdcade9eead1e483d046e745a1291b2238c2027f05ee38ee248787cd407",
|
|
"data/kjede_s5.dat": "cdf936372eed8f3e1117508172f63e8c72f89294cb06caf994262652d044fe59",
|
|
"data/mpc11.dat": "2609b5bb73efb12e31adf3d4b40c6798e93d40e6bd68afb2583a565d60cb3be0",
|
|
"data/p11.dat": "526a839a393c2020fff0280a39c263e0ea73d7131ff86d3d9b734be919e884da",
|
|
"data/p11d.dat": "dfa920ebeafef640735ce27eb98e50f6977e20b643a9c140a1672df86d8e8bf3",
|
|
"data/p3.dat": "448def8fa662a3b68152d184a33888f7d5a0cfcdfe619064a51a47ff07d6037c",
|
|
"data/p6.dat": "fcb78c473b4927aaf1a8360a6e2652d0363eaa6b2ac2a80ef551eb577ca4390d",
|
|
"data/prio_s2.dat": "38389ac7a3d4cc2b5ad4cf54248f0a98d89bb21eb5c8f4f1eba799c6b4b5c7fc",
|
|
"data/prio_s3.dat": "b675f87eefbcd4097732a258b6e81d06b5b5d96d4bcfbc8599270cc2079baf7e",
|
|
"data/prio_s5.dat": "dd8744bd75ee27eceb9f312c4740058d13973cdcabf499fef895d2fe32c41813",
|
|
"data/sat_B.dat": "6a24ce8f69964a055deaaa9260375b8e4cb1c6c09f1a113fd20f6f96a4251847",
|
|
"data/sat_C.dat": "851d218a3a7772da65e3b19301cdf2006582358df873bab9fcaf7c3da0236fa7",
|
|
"data/stab.dat": "9a09e14290634beafa1560a31097f76d26c592e7be46b7590e1480a01b60974e",
|
|
"data/thumb.dat": "49c07f7ae6c23c9f728be27c422e76e8afa895b9bdd46c43ea2f5d5ebe7c767d",
|
|
"data/vippe.dat": "7185a3edff28dede529a230504d63d85e4821096730acd293855cd74bd005ed1",
|
|
"diagnose_eksempel_domstol.txt": "226212b0177b54a5402c2fd83d2df334184c7533b295bb3a72a277a4b99f82f0",
|
|
"diagnose_norge_2025.txt": "3764c9236ec5afde9a47ebbeacec3e0d42cde98123a7d5a6e11c3fbec56a696e",
|
|
"forlop_eksempel_domstol.txt": "b56cf2a7408d9611088eebaba787ab5d5a1dcdd3ee6116ce73dc226e1cea88c4",
|
|
"forlop_norge_2025.txt": "ec75dae235031743816ad4b2d63830fcee6050e9e1477acb68269e7e9ded456a",
|
|
"sim_utskrift.txt": "6225f9427845d915914a0bf78acf4359f6935d437f48cb94e1da48220a99eafd",
|
|
}
|
|
# Scenariofilene som gjenskaper notatets datafil med samme navn byte for byte.
|
|
SCENARIO_RASKE = ("p3", "p6", "p11", "p11d", "kjede_s1", "kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5",
|
|
"endo_e5", "endo_des", "endo_desp", "endo_ko")
|
|
SCENARIO_TREGE = ("mpc11",)
|
|
|
|
LIK, TOLERANSE, ENDRET, IKKE_KJORT = "lik", "toleranse", "endret", "ikke kjørt"
|
|
RANG = {LIK: 0, TOLERANSE: 1, IKKE_KJORT: 2, ENDRET: 3}
|
|
MAKS_LINJER = 40 # linjer med forskjeller som vises per sammenligning (alle med --alt)
|
|
VISNING = {"alt": False} # settes av --alt i main()
|
|
SLINGRING = 1e-9 # relativ slingring når avviket sammenlignes med én enhet i siste siffer
|
|
UTVIDELSER = "Utvidelser" # merkelappen for kontroll 5
|
|
|
|
TALL = re.compile(r"[-+]?\d+(?:[.,]\d+)?(?:[eE][-+]?\d+)?")
|
|
|
|
MILJO = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1")
|
|
|
|
UTVIDELSE_KJORER = """\
|
|
import importlib.util, json, sys
|
|
modell, fil = sys.argv[1], sys.argv[2]
|
|
sys.path[:0] = [modell + "/kjerne", modell]
|
|
spec = importlib.util.spec_from_file_location("utvidelse", fil)
|
|
modul = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(modul)
|
|
print()
|
|
print(json.dumps([[str(b), float(a)] for b, a in modul.grensetilfelle()]))
|
|
"""
|
|
|
|
SCENARIO_KJORER = """\
|
|
import contextlib, importlib.util, io, json, os, sys, traceback
|
|
modell, ut, filer = sys.argv[1], sys.argv[2], sys.argv[3:]
|
|
sys.path[:0] = [os.path.join(modell, "kjerne"), modell]
|
|
spec = importlib.util.spec_from_file_location("scenario", os.path.join(modell, "scenario.py"))
|
|
modul = importlib.util.module_from_spec(spec)
|
|
sys.modules["scenario"] = modul
|
|
spec.loader.exec_module(modul)
|
|
svar = []
|
|
for fil in filer:
|
|
navn = os.path.splitext(os.path.basename(fil))[0]
|
|
feil = io.StringIO()
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(feil):
|
|
try:
|
|
kode = modul.main([fil, "--ut", os.path.join(ut, navn)])
|
|
except SystemExit as stopp:
|
|
kode = stopp.code if isinstance(stopp.code, int) else 2
|
|
feil.write(str(stopp.code or ""))
|
|
except Exception:
|
|
kode = 2
|
|
feil.write(traceback.format_exc())
|
|
svar.append([navn, kode, feil.getvalue()[-3000:]])
|
|
print()
|
|
print(json.dumps(svar))
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class Dom:
|
|
"""Resultatet av én kontroll."""
|
|
|
|
status: str
|
|
linje: str
|
|
detaljer: list[str] = field(default_factory=list)
|
|
avvik: float = 0.0
|
|
ikke_kjort: str = "" # hva som ikke ble kontrollert, når status er IKKE_KJORT
|
|
|
|
|
|
@dataclass
|
|
class Sammenligning:
|
|
"""Resultatet av å sammenligne en fil eller en utskrift med fasit."""
|
|
|
|
status: str
|
|
avvik: float = 0.0
|
|
detaljer: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ---------------- sammenligning ----------------
|
|
|
|
def enhet(tall: str) -> float:
|
|
"""Én enhet i siste viste siffer: «1,28» -> 0,01, «7.21» -> 0,01, «1.11e-15» -> 1e-17."""
|
|
mantisse, _, eksponent = tall.lower().partition("e")
|
|
desimaler = len(mantisse.replace(",", ".").partition(".")[2])
|
|
return float(10.0 ** (int(eksponent or "0") - desimaler))
|
|
|
|
|
|
def verdi(tall: str) -> float:
|
|
"""Tallet i en tekst, med punktum eller komma som desimaltegn."""
|
|
return float(tall.replace(",", "."))
|
|
|
|
|
|
def innenfor(fasit: str, naa: str) -> tuple[bool, float]:
|
|
"""Er to tall like innenfor én enhet i siste viste siffer? Returnerer også avviket."""
|
|
try:
|
|
a, b = verdi(fasit), verdi(naa)
|
|
except ValueError:
|
|
return fasit == naa, 0.0 if fasit == naa else math.inf
|
|
avvik = abs(a - b) if math.isfinite(a) and math.isfinite(b) else (0.0 if a == b else math.inf)
|
|
return avvik <= max(enhet(fasit), enhet(naa)) * (1 + SLINGRING), avvik
|
|
|
|
|
|
def vis_tall(x: float) -> str:
|
|
"""Et avvik med norsk desimaltegn."""
|
|
if x == 0:
|
|
return "0"
|
|
if math.isinf(x):
|
|
return "uendelig"
|
|
return f"{x:.1e}".replace(".", ",")
|
|
|
|
|
|
def forskjeller(fasit: list[str], naa: list[str]) -> list[str]:
|
|
"""Linjene som er ulike, som en kort diff (notatet / nå)."""
|
|
linjer = list(difflib.unified_diff(fasit, naa, "notatet", "nå", lineterm="", n=0))
|
|
if len(linjer) > MAKS_LINJER and not VISNING["alt"]:
|
|
linjer = [*linjer[:MAKS_LINJER],
|
|
f"... ({len(linjer) - MAKS_LINJER} linjer til; «kontroll --alt» viser alle)"]
|
|
return linjer
|
|
|
|
|
|
def sammenlign_tekst(fasit: str, naa: str) -> Sammenligning:
|
|
"""Sammenlign en utskrift med fasit: lik, innenfor toleranse eller endret."""
|
|
if fasit == naa:
|
|
return Sammenligning(LIK)
|
|
a, b = fasit.splitlines(), naa.splitlines()
|
|
detaljer = forskjeller(a, b)
|
|
if len(a) != len(b):
|
|
return Sammenligning(ENDRET, math.inf, detaljer)
|
|
storst = 0.0
|
|
for linje_a, linje_b in zip(a, b):
|
|
if linje_a == linje_b:
|
|
continue
|
|
if TALL.sub("#", linje_a) != TALL.sub("#", linje_b):
|
|
return Sammenligning(ENDRET, math.inf, detaljer)
|
|
for x, y in zip(TALL.findall(linje_a), TALL.findall(linje_b)):
|
|
ok, avvik = innenfor(x, y)
|
|
if not ok:
|
|
return Sammenligning(ENDRET, avvik, detaljer)
|
|
storst = max(storst, avvik)
|
|
return Sammenligning(TOLERANSE, storst, detaljer)
|
|
|
|
|
|
def les_dat(tekst: str) -> tuple[list[str], list[list[str]]]:
|
|
"""Kolonnenavn og rader i en datafil fra sim.py (første linje er kolonnenavnene)."""
|
|
linjer = [linje.split() for linje in tekst.splitlines() if linje.strip()]
|
|
if not linjer:
|
|
return [], []
|
|
return linjer[0], linjer[1:]
|
|
|
|
|
|
def sammenlign_dat(fasit: str, naa: str) -> Sammenligning:
|
|
"""Sammenlign en datafil med fasit; ved avvik største absolutte avvik per kolonne."""
|
|
if fasit == naa:
|
|
return Sammenligning(LIK)
|
|
kol_a, rader_a = les_dat(fasit)
|
|
kol_b, rader_b = les_dat(naa)
|
|
if kol_a != kol_b:
|
|
return Sammenligning(ENDRET, math.inf,
|
|
[f"kolonnene er ulike: notatet {' '.join(kol_a)}, nå {' '.join(kol_b)}"])
|
|
if len(rader_a) != len(rader_b) or any(len(x) != len(y) for x, y in zip(rader_a, rader_b)):
|
|
return Sammenligning(ENDRET, math.inf,
|
|
[f"antall rader er ulikt: notatet {len(rader_a)}, nå {len(rader_b)}"])
|
|
per_kolonne = [0.0] * len(kol_a)
|
|
alle_innenfor = True
|
|
for rad_a, rad_b in zip(rader_a, rader_b):
|
|
for j, (x, y) in enumerate(zip(rad_a, rad_b)):
|
|
if x == y:
|
|
continue
|
|
ok, avvik = innenfor(x, y)
|
|
alle_innenfor = alle_innenfor and ok
|
|
per_kolonne[j] = max(per_kolonne[j], avvik)
|
|
deler = [f"{navn} {vis_tall(avvik)}" for navn, avvik in zip(kol_a, per_kolonne) if avvik > 0]
|
|
detaljer = ["største absolutte avvik per kolonne: " + (", ".join(deler) or "ingen (bare formatering)")]
|
|
return Sammenligning(TOLERANSE if alle_innenfor else ENDRET, max(per_kolonne), detaljer)
|
|
|
|
|
|
def verst(statuser: list[str]) -> str:
|
|
"""Den dårligste statusen i en liste."""
|
|
return max(statuser, key=RANG.__getitem__, default=LIK)
|
|
|
|
|
|
# ---------------- miljø og prosesser ----------------
|
|
|
|
def har_numpy_scipy() -> bool:
|
|
"""Sjekk om denne Python har numpy 2.0 eller nyere (numpy.trapezoid) og scipy."""
|
|
try:
|
|
numpy = importlib.import_module("numpy")
|
|
importlib.import_module("scipy.optimize")
|
|
except ImportError:
|
|
return False
|
|
return hasattr(numpy, "trapezoid")
|
|
|
|
|
|
def kjor_python(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
"""Kjør denne Python med args i mappa cwd og fang utskriften."""
|
|
return subprocess.run([sys.executable, *args], cwd=str(cwd), env=MILJO, capture_output=True, text=True,
|
|
encoding="utf-8", check=False)
|
|
|
|
|
|
def feilmelding(prosess: subprocess.CompletedProcess[str]) -> list[str]:
|
|
"""De siste linjene på stderr, til detaljene."""
|
|
linjer = prosess.stderr.strip().splitlines()
|
|
return [f"stoppet med exit-kode {prosess.returncode}:", *linjer[-MAKS_LINJER // 2:]]
|
|
|
|
|
|
def sha256(fil: Path) -> str:
|
|
"""sha256 av en fil."""
|
|
return hashlib.sha256(fil.read_bytes()).hexdigest()
|
|
|
|
|
|
|
|
def lest_fasit(rel: str, endret_fasit: set[str]) -> str | None:
|
|
"""Innholdet i fasit/<rel>, eller None når fila mangler eller er endret i forhold til notatet."""
|
|
fil = FASIT / rel
|
|
if rel in endret_fasit or not fil.is_file():
|
|
return None
|
|
return fil.read_text(encoding="utf-8")
|
|
|
|
|
|
def ugyldig_fasit(rel: str) -> str:
|
|
"""Detaljen når en fasit-fil ikke kan brukes som notatets verdi."""
|
|
if not (FASIT / rel).is_file():
|
|
return f"fant ikke fasit/{rel}"
|
|
return f"fasit/{rel} er endret i forhold til notatet, så sammenligningen sier ingenting om notatet"
|
|
|
|
|
|
# ---------------- kontrollene ----------------
|
|
|
|
def kontroll_kjerne() -> Dom:
|
|
"""1. Kjernen mot notatets sha256 (konstantene over, ikke KILDE.json)."""
|
|
endret = []
|
|
for navn, verdi in NOTATETS_KJERNE.items():
|
|
fil = KJERNE / navn
|
|
if not fil.is_file():
|
|
endret.append(f"{navn} mangler")
|
|
elif sha256(fil) != verdi:
|
|
endret.append(f"{navn} er endret")
|
|
versjon = f"{NOTATETS_REPO} @ {NOTATETS_COMMIT[:7]}"
|
|
if not endret:
|
|
return Dom(LIK, f"Kjernen: uendret fra notatet ({len(NOTATETS_KJERNE)} filer, {versjon}).")
|
|
return Dom(ENDRET, f"Kjernen: endret fra notatet ({versjon}): {', '.join(endret)}.")
|
|
|
|
|
|
def endrede_fasitfiler() -> set[str]:
|
|
"""Filene i fasit/ som mangler eller ikke har notatets sha256."""
|
|
return {rel for rel, verdi in NOTATETS_FASIT.items()
|
|
if not (FASIT / rel).is_file() or sha256(FASIT / rel) != verdi}
|
|
|
|
|
|
def kontroll_fasit(endret_fasit: set[str]) -> Dom:
|
|
"""1. Fasit mot notatets sha256."""
|
|
if not endret_fasit:
|
|
return Dom(LIK, f"Fasit: uendret fra notatet ({len(NOTATETS_FASIT)} filer).")
|
|
detaljer = [f"fasit/{rel}: " + ("mangler" if not (FASIT / rel).is_file() else "har ikke notatets sha256")
|
|
for rel in sorted(endret_fasit)]
|
|
return Dom(ENDRET, f"Fasit: fasit er endret i forhold til notatet ({len(endret_fasit)} av "
|
|
f"{len(NOTATETS_FASIT)} filer).", detaljer, math.inf)
|
|
|
|
|
|
def kontroll_utskrift(skript: str, mappe: Path, endret_fasit: set[str], *, toleranse: bool) -> Dom:
|
|
"""2 og 3. Utskriften fra diagnose.py eller forlop.py på begge eksempelfilene mot fasit.
|
|
|
|
toleranse=False: bare byte-lik tekst godtas (diagnose.py avhenger ikke av numpy og scipy).
|
|
"""
|
|
statuser, detaljer, storst = [], [], 0.0
|
|
for eksempel in EKSEMPELFILER:
|
|
rel = f"{skript}_{eksempel}.txt"
|
|
fasit = lest_fasit(rel, endret_fasit)
|
|
inndata = EKSEMPLER / f"{eksempel}.json"
|
|
if fasit is None or not inndata.is_file():
|
|
statuser.append(ENDRET)
|
|
detaljer.append(f"{eksempel}: " + (ugyldig_fasit(rel) if fasit is None else f"fant ikke {inndata}"))
|
|
continue
|
|
prosess = kjor_python([str(KJERNE / f"{skript}.py"), str(inndata)], mappe)
|
|
if prosess.returncode != 0:
|
|
statuser.append(ENDRET)
|
|
detaljer += [f"{eksempel}:", *(" " + linje for linje in feilmelding(prosess))]
|
|
continue
|
|
resultat = sammenlign_tekst(fasit, prosess.stdout)
|
|
if resultat.status == TOLERANSE and not toleranse:
|
|
resultat.status = ENDRET
|
|
statuser.append(resultat.status)
|
|
storst = max(storst, resultat.avvik)
|
|
if resultat.status != LIK:
|
|
detaljer += [f"{eksempel}:", *(" " + linje for linje in resultat.detaljer)]
|
|
status = verst(statuser)
|
|
begge = " og ".join(EKSEMPELFILER)
|
|
linje = {
|
|
LIK: f"{skript}: lik notatet for {begge}.",
|
|
TOLERANSE: f"{skript}: innenfor toleranse for {begge} (største avvik {vis_tall(storst)}).",
|
|
ENDRET: f"{skript}: endret (se under).",
|
|
}[status]
|
|
return Dom(status, linje, detaljer, storst)
|
|
|
|
|
|
def kontroll_full(mappe: Path, endret_fasit: set[str]) -> Dom:
|
|
"""4. Full kjøring av sim.py i en midlertidig mappe mot fasit/data og fasit/sim_utskrift.txt."""
|
|
print(" (kjører sim.py, om lag 1-2 minutter ...)", flush=True)
|
|
prosess = kjor_python([str(KJERNE / "sim.py")], mappe)
|
|
if prosess.returncode != 0:
|
|
return Dom(ENDRET, "Full kjøring: sim.py stoppet med en feil.", feilmelding(prosess), math.inf)
|
|
navn_fasit = sorted(rel[len("data/"):] for rel in NOTATETS_FASIT if rel.startswith("data/"))
|
|
navn_naa = sorted(f.name for f in (mappe / "data").glob("*.dat"))
|
|
statuser, detaljer, storst, like = [], [], 0.0, 0
|
|
for navn in sorted(set(navn_fasit) | set(navn_naa)):
|
|
if navn not in navn_naa or navn not in navn_fasit:
|
|
statuser.append(ENDRET)
|
|
detaljer.append(f"{navn}: " + ("mangler i kjøringen" if navn not in navn_naa
|
|
else "er ikke en av notatets datafiler"))
|
|
continue
|
|
fasit = lest_fasit(f"data/{navn}", endret_fasit)
|
|
if fasit is None:
|
|
statuser.append(ENDRET)
|
|
detaljer.append(f"{navn}: {ugyldig_fasit(f'data/{navn}')}")
|
|
continue
|
|
resultat = sammenlign_dat(fasit, (mappe / "data" / navn).read_text(encoding="utf-8"))
|
|
statuser.append(resultat.status)
|
|
storst = max(storst, resultat.avvik)
|
|
if resultat.status == LIK:
|
|
like += 1
|
|
else:
|
|
dom = "innenfor toleranse" if resultat.status == TOLERANSE else "endret"
|
|
detaljer.append(f"{navn} ({dom}): {'; '.join(resultat.detaljer)}")
|
|
fasit_utskrift = lest_fasit("sim_utskrift.txt", endret_fasit)
|
|
if fasit_utskrift is None:
|
|
utskrift = Sammenligning(ENDRET, math.inf, [ugyldig_fasit("sim_utskrift.txt")])
|
|
else:
|
|
utskrift = sammenlign_tekst(fasit_utskrift, prosess.stdout)
|
|
statuser.append(utskrift.status)
|
|
storst = max(storst, utskrift.avvik)
|
|
if utskrift.status != LIK:
|
|
detaljer += ["utskriften fra sim.py:", *(" " + linje for linje in utskrift.detaljer)]
|
|
status = verst(statuser)
|
|
tekst_utskrift = {LIK: "lik", TOLERANSE: "innenfor toleranse", ENDRET: "endret"}[utskrift.status]
|
|
linje = (f"Full kjøring: {like} av {len(navn_fasit)} datafiler like byte for byte med notatet; "
|
|
f"utskriften {tekst_utskrift}.")
|
|
return Dom(status, linje, detaljer, storst)
|
|
|
|
|
|
def grensetilfeller(fil: Path, mappe: Path) -> tuple[list[str], list[str], float]:
|
|
"""Kjør grensetilfelle() i én utvidelse. Returnerer statuser, detaljer og største avvik."""
|
|
prosess = kjor_python(["-c", UTVIDELSE_KJORER, str(MODELL), str(fil)], mappe)
|
|
try:
|
|
tilfeller = json.loads(prosess.stdout.strip().splitlines()[-1]) if prosess.returncode == 0 else None
|
|
except (ValueError, IndexError):
|
|
tilfeller = None
|
|
if tilfeller is None:
|
|
return [ENDRET], [f"{fil.stem}: grensetilfelle() kunne ikke kjøres",
|
|
*(" " + linje for linje in feilmelding(prosess))], math.inf
|
|
if not tilfeller:
|
|
return [ENDRET], [f"{fil.stem}: grensetilfelle() ga ingen tilfeller; en utvidelse skal ha minst ett."], 0.0
|
|
statuser, detaljer, storst = [], [], 0.0
|
|
for beskrivelse, avvik in tilfeller:
|
|
storst = max(storst, abs(avvik))
|
|
if avvik == 0.0:
|
|
statuser.append(LIK)
|
|
continue
|
|
statuser.append(ENDRET)
|
|
hvem = beskrivelse if str(beskrivelse).startswith(fil.stem) else f"{fil.stem}: {beskrivelse}"
|
|
detaljer.append(f"{hvem}: avvik {vis_tall(abs(avvik))}, skal være eksakt 0")
|
|
return statuser, detaljer, storst
|
|
|
|
|
|
def kontroll_utvidelser(mappe: Path, *, regnemiljo: bool) -> Dom:
|
|
"""5. Grensetilfellet i hver utvidelse skal gjenskape kjernen eksakt (avvik 0)."""
|
|
utvidelser = sorted(f for f in (MODELL / "utvidelser").glob("*.py") if not f.name.startswith("_"))
|
|
if not utvidelser:
|
|
return Dom(LIK, f"{UTVIDELSER}: ingen å kontrollere.")
|
|
if not regnemiljo:
|
|
return Dom(IKKE_KJORT, f"{UTVIDELSER}: ikke kjørt ({len(utvidelser)} stk.), numpy og scipy mangler.",
|
|
ikke_kjort="utvidelsenes grensetilfeller")
|
|
statuser, detaljer, storst, feilet = [], [], 0.0, []
|
|
for fil in utvidelser:
|
|
egne, egne_detaljer, avvik = grensetilfeller(fil, mappe)
|
|
statuser += egne
|
|
detaljer += egne_detaljer
|
|
storst = max(storst, avvik)
|
|
if ENDRET in egne:
|
|
feilet.append(fil.stem)
|
|
navn = ", ".join(f.stem for f in utvidelser)
|
|
if not feilet:
|
|
return Dom(LIK, f"{UTVIDELSER}: grensetilfellene gjenskaper kjernen eksakt ({navn}).", [], storst)
|
|
linje = (f"{UTVIDELSER}: {', '.join(feilet)} gjenskaper ikke kjernen i grensetilfellet. Det er en feil i "
|
|
"utvidelsen, og resultatene den gir, kan ikke brukes (se under).")
|
|
return Dom(ENDRET, linje, detaljer, storst)
|
|
|
|
|
|
def kjor_scenarioer(navn: tuple[str, ...], mappe: Path) -> tuple[dict[str, tuple[int, str]], list[str]]:
|
|
"""Kjør scenariofilene gjennom scenario.py i en egen prosess.
|
|
|
|
Returnerer {navn: (exit-kode, stderr)} og feillinjer når prosessen selv stoppet.
|
|
"""
|
|
ut = mappe / "scenarioer"
|
|
filer = [str(SCENARIOER / f"{n}.json") for n in navn]
|
|
prosess = kjor_python(["-c", SCENARIO_KJORER, str(MODELL), str(ut), *filer], mappe)
|
|
try:
|
|
svar = json.loads(prosess.stdout.strip().splitlines()[-1]) if prosess.returncode == 0 else None
|
|
except (ValueError, IndexError):
|
|
svar = None
|
|
if svar is None:
|
|
return {}, ["scenario.py kunne ikke lastes:", *(" " + linje for linje in feilmelding(prosess))]
|
|
return {str(n): (int(kode), str(feil)) for n, kode, feil in svar}, []
|
|
|
|
|
|
def kontroll_scenario(mappe: Path, endret_fasit: set[str], *, rask: bool, regnemiljo: bool) -> Dom:
|
|
"""6. Scenariokjøreren gjenskaper notatets datafiler fra scenariofilene i eksempler/scenarioer/notatet/."""
|
|
navn = SCENARIO_RASKE if rask else SCENARIO_RASKE + SCENARIO_TREGE
|
|
if not (MODELL / "scenario.py").is_file():
|
|
return Dom(ENDRET, f"Scenariokjøreren: fant ikke {MODELL / 'scenario.py'}, så den er ikke kontrollert.",
|
|
[], math.inf)
|
|
mangler = [n for n in navn if not (SCENARIOER / f"{n}.json").is_file()]
|
|
if mangler:
|
|
return Dom(ENDRET, f"Scenariokjøreren: fant ikke {len(mangler)} av {len(navn)} scenariofiler i "
|
|
f"{SCENARIOER}, så den er ikke kontrollert.", [f"mangler: {', '.join(mangler)}"], math.inf)
|
|
if not regnemiljo:
|
|
return Dom(IKKE_KJORT, "Scenariokjøreren: ikke kjørt, numpy og scipy mangler.",
|
|
ikke_kjort="scenariokjøreren")
|
|
resultater, feil = kjor_scenarioer(navn, mappe)
|
|
if feil:
|
|
return Dom(ENDRET, "Scenariokjøreren: scenario.py stoppet før noen fil var kjørt.", feil, math.inf)
|
|
statuser, detaljer, storst, like = [], [], 0.0, 0
|
|
for n in navn:
|
|
kode, melding = resultater.get(n, (2, "ble ikke kjørt"))
|
|
fasit = lest_fasit(f"data/{n}.dat", endret_fasit)
|
|
laget = mappe / "scenarioer" / n / f"{n}.dat"
|
|
if kode != 0 or not laget.is_file():
|
|
statuser.append(ENDRET)
|
|
detaljer += [f"{n}.json: stoppet med exit-kode {kode}" if kode != 0 else f"{n}.json: laget ikke {n}.dat",
|
|
*(" " + linje for linje in melding.strip().splitlines()[-MAKS_LINJER // 2:])]
|
|
continue
|
|
if fasit is None:
|
|
statuser.append(ENDRET)
|
|
detaljer.append(f"{n}.dat: {ugyldig_fasit(f'data/{n}.dat')}")
|
|
continue
|
|
resultat = sammenlign_dat(fasit, laget.read_text(encoding="utf-8"))
|
|
statuser.append(resultat.status)
|
|
storst = max(storst, resultat.avvik)
|
|
if resultat.status == LIK:
|
|
like += 1
|
|
else:
|
|
dom = "innenfor toleranse" if resultat.status == TOLERANSE else "endret"
|
|
detaljer.append(f"{n}.dat ({dom}): {'; '.join(resultat.detaljer)}")
|
|
status = verst(statuser)
|
|
linje = (f"Scenariokjøreren: {like} av {len(navn)} av notatets datafiler gjenskapt byte for byte fra "
|
|
"eksempler/scenarioer/notatet/" + (" (mpc11 bare i full kjøring)." if rask else "."))
|
|
return Dom(status, linje, detaljer, storst)
|
|
|
|
|
|
# ---------------- rapporten ----------------
|
|
|
|
def niva(dommer: list[Dom], *, rask: bool, arbeidskopi: bool) -> list[str]:
|
|
"""Linjene om nivået som er oppnådd."""
|
|
kjort = [d for d in dommer if d.status != IKKE_KJORT]
|
|
hoppet_over = [d.ikke_kjort for d in dommer if d.status == IKKE_KJORT]
|
|
status = verst([d.status for d in kjort])
|
|
hva = " (uten full kjøring, --rask)" if rask else ""
|
|
if status == ENDRET:
|
|
endret = [d.linje.split(":")[0] for d in kjort if d.status == ENDRET]
|
|
linjer = [f"Nivå{hva}: endret: {', '.join(endret)}."]
|
|
if UTVIDELSER in endret:
|
|
linjer.append("En utvidelse som ikke gjenskaper kjernen i grensetilfellet, har en feil. Resultatene den "
|
|
"gir, kan ikke brukes før den er rettet.")
|
|
if [e for e in endret if e != UTVIDELSER]:
|
|
if arbeidskopi:
|
|
linjer.append("Dette er en arbeidskopi, så det er en opplysning, ikke en feil: detaljene over viser "
|
|
"hvilke av notatets resultater som har flyttet seg, og hvor mye.")
|
|
else:
|
|
linjer.append("Er dette pluginen slik den er installert, er det en feil: pluginens modell skal regne "
|
|
"som notatet. Er modellen endret med vilje (for eksempel i en klone av repoet), er det "
|
|
"en opplysning: detaljene over viser hvilke av notatets resultater som har flyttet "
|
|
"seg, og hvor mye.")
|
|
elif hoppet_over:
|
|
kjorte = [d.linje.split(":")[0] for d in kjort]
|
|
linjer = [(f"Nivå{hva}: ingen avvik i det som ble kjørt ({', '.join(kjorte)}). Ikke kontrollert: "
|
|
f"{', '.join(hoppet_over)}.")]
|
|
elif status == TOLERANSE:
|
|
storst = max(d.avvik for d in kjort)
|
|
linjer = [(f"Nivå{hva}: innenfor toleranse (største avvik {vis_tall(storst)}): hvert tall avviker med "
|
|
"høyst én enhet i siste viste siffer.")]
|
|
else:
|
|
linjer = [f"Nivå{hva}: bit for bit som notatet."]
|
|
if hoppet_over:
|
|
linjer.append(f"Ikke kjørt fordi numpy eller scipy mangler i {sys.executable}. "
|
|
"«kjor.py miljo --opprett» installerer dem i en egen mappe.")
|
|
return linjer
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
"""Kjør kontrollene og skriv dommen. Returnerer exit-koden."""
|
|
if len(set(argv)) != len(argv) or not set(argv) <= {"--rask", "--alt"}:
|
|
print("Bruk: kjor.py kontroll [--rask] [--alt]", file=sys.stderr)
|
|
return 2
|
|
rask = "--rask" in argv
|
|
VISNING["alt"] = "--alt" in argv
|
|
arbeidskopi = (ROT / ARBEIDSKOPI_MERKE).is_file()
|
|
regnemiljo = har_numpy_scipy()
|
|
print(f"Kontroll av modellen i {MODELL}" + (" (arbeidskopi)" if arbeidskopi else "") + "\n", flush=True)
|
|
dommer: list[Dom] = []
|
|
|
|
def vis(nummer: str, dom: Dom) -> None:
|
|
dommer.append(dom)
|
|
print(f"{nummer} {dom.linje}")
|
|
for linje in dom.detaljer:
|
|
print(f" {linje}")
|
|
sys.stdout.flush()
|
|
|
|
endret_fasit = endrede_fasitfiler()
|
|
with tempfile.TemporaryDirectory(prefix="dynamisk-rettsstat-") as tmp:
|
|
mappe = Path(tmp)
|
|
vis("1.", kontroll_kjerne())
|
|
vis("1.", kontroll_fasit(endret_fasit))
|
|
vis("2.", kontroll_utskrift("diagnose", mappe, endret_fasit, toleranse=False))
|
|
if regnemiljo:
|
|
vis("3.", kontroll_utskrift("forlop", mappe, endret_fasit, toleranse=True))
|
|
else:
|
|
vis("3.", Dom(IKKE_KJORT, "forlop: ikke kjørt, numpy og scipy mangler.", ikke_kjort="forlop"))
|
|
if rask:
|
|
print("4. Full kjøring: hoppet over (--rask).")
|
|
elif regnemiljo:
|
|
vis("4.", kontroll_full(mappe, endret_fasit))
|
|
else:
|
|
vis("4.", Dom(IKKE_KJORT, "Full kjøring: ikke kjørt, numpy og scipy mangler.",
|
|
ikke_kjort="full kjøring av sim.py"))
|
|
vis("5.", kontroll_utvidelser(mappe, regnemiljo=regnemiljo))
|
|
vis("6.", kontroll_scenario(mappe, endret_fasit, rask=rask, regnemiljo=regnemiljo))
|
|
print()
|
|
print("\n".join(niva(dommer, rask=rask, arbeidskopi=arbeidskopi)))
|
|
statuser = [d.status for d in dommer]
|
|
if ENDRET in statuser:
|
|
return 1
|
|
if IKKE_KJORT in statuser:
|
|
return 3
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.dont_write_bytecode = True
|
|
sys.exit(main(sys.argv[1:]))
|