dynamisk-rettsstat/modell/kontroll.py
Kjell Tore Guttormsen 776325c680
fix: findings from the independent review
Scenario runner: stops when overridden base parameters leave the start out
of balance (new field startlikevekt: krev, kalibrer, ubalanse); stops on
non-finite results; says that models with a court queue drift slowly even
without a shock and prints the drift before the shock; queue in months of
inflow; never deletes files it did not write; stricter limits.
Check: anchored on the note's own checksums for core and reference files,
runs the scenario files too, exact comparison for the diagnosis, honest
wording when parts were skipped or an extension is broken.
Launcher: unknown field names in input are rejected with a suggestion;
unwritable folders give a message, not a traceback.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:27:14 +01:00

644 lines
30 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]
python3 kontroll.py [--rask] (med en Python som har numpy og scipy)
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
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:
linjer = [*linjer[:MAKS_LINJER], f"... ({len(linjer) - MAKS_LINJER} linjer til)"]
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 argv not in ([], ["--rask"]):
print("Bruk: kjor.py kontroll [--rask]", file=sys.stderr)
return 2
rask = argv == ["--rask"]
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:]))