dynamisk-rettsstat/modell/kontroll.py
Kjell Tore Guttormsen 464ad38b49
feat: model core, launcher, reproduction check and the first two skills
The core in modell/kjerne/ is an unmodified copy of sim.py, diagnose.py and
forlop.py from den-dynamiske-rettsstaten @2badc25, pinned by sha256.
kjor.py is the single entry point; kontroll.py reproduces the note's 22 data
files and printed results. Skills: diagnose and forlop (Norwegian).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 13:53:32 +01:00

432 lines
18 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)
Fire kontroller, hver med én linje dom og detaljer bare ved avvik:
1. Kjernen: sha256 for filene i kjerne/ mot kjerne/KILDE.json.
2. diagnose.py på begge eksempelfilene mot fasit/ (tekst).
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.
I tillegg kjøres grensetilfelle() i hver utvidelse i utvidelser/; hvert avvik skal være eksakt 0.
Nivået til slutt:
- «bit for bit som notatet»: alt er likt byte for byte.
- «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).
- «endret»: alt annet. I en arbeidskopi (mappa over modell/ har LES-MEG.md, som
arbeidskopi.py lager) er det en opplysning, ikke en feil: detaljene sier hvilke av
notatets resultater som har flyttet seg, og hvor mye.
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.
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.
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"
EKSEMPELFILER = ("eksempel_domstol", "norge_2025")
ARBEIDSKOPI_MERKE = "LES-MEG.md"
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
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()]))
"""
@dataclass
class Dom:
"""Resultatet av én kontroll."""
status: str
linje: str
detaljer: list[str] = field(default_factory=list)
avvik: float = 0.0
@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()
# ---------------- kontrollene ----------------
def kontroll_kjerne() -> Dom:
"""1. Kjernen mot sha256 i KILDE.json."""
kildefil = KJERNE / "KILDE.json"
if not kildefil.is_file():
return Dom(ENDRET, f"Kjernen: fant ikke {kildefil}, så kjernen kan ikke sammenlignes med notatet.")
kilde = json.loads(kildefil.read_text(encoding="utf-8"))
endret = []
for navn, info in kilde["filer"].items():
fil = KJERNE / navn
if not fil.is_file():
endret.append(f"{navn} mangler")
elif sha256(fil) != info["sha256"]:
endret.append(f"{navn} er endret")
versjon = f"{kilde['repo']} @ {kilde['commit'][:7]}"
if not endret:
return Dom(LIK, f"Kjernen: uendret fra notatet ({len(kilde['filer'])} filer, {versjon}).")
return Dom(ENDRET, f"Kjernen: endret fra notatet ({versjon}): {', '.join(endret)}.")
def kontroll_utskrift(skript: str, mappe: Path) -> Dom:
"""2 og 3. Utskriften fra diagnose.py eller forlop.py på begge eksempelfilene mot fasit."""
statuser, detaljer, storst = [], [], 0.0
for eksempel in EKSEMPELFILER:
fasitfil = FASIT / f"{skript}_{eksempel}.txt"
inndata = EKSEMPLER / f"{eksempel}.json"
mangler = [str(f) for f in (fasitfil, inndata) if not f.is_file()]
if mangler:
statuser.append(ENDRET)
detaljer.append(f"{eksempel}: fant ikke {', '.join(mangler)}")
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(fasitfil.read_text(encoding="utf-8"), prosess.stdout)
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) -> 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)
fasit_data = FASIT / "data"
navn_fasit = sorted(f.name for f in fasit_data.glob("*.dat"))
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 "finnes ikke i fasit"))
continue
resultat = sammenlign_dat((fasit_data / navn).read_text(encoding="utf-8"),
(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)}")
utskriftfil = FASIT / "sim_utskrift.txt"
if utskriftfil.is_file():
utskrift = sammenlign_tekst(utskriftfil.read_text(encoding="utf-8"), prosess.stdout)
else:
utskrift = Sammenligning(ENDRET, math.inf, [f"fant ikke {utskriftfil}"])
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 kontroll_utvidelser(mappe: Path, *, regnemiljo: bool) -> Dom:
"""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, "Utvidelser: ingen å kontrollere.")
if not regnemiljo:
return Dom(IKKE_KJORT, f"Utvidelser: ikke kjørt ({len(utvidelser)} stk.), numpy og scipy mangler.")
statuser, detaljer, storst = [], [], 0.0
for fil in utvidelser:
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:
statuser.append(ENDRET)
detaljer += [f"{fil.stem}: grensetilfelle() kunne ikke kjøres", *(" " + linje for linje in
feilmelding(prosess))]
continue
if not tilfeller:
statuser.append(ENDRET)
detaljer.append(f"{fil.stem}: grensetilfelle() ga ingen tilfeller; en utvidelse skal ha minst ett.")
continue
for beskrivelse, avvik in tilfeller:
storst = max(storst, abs(avvik))
if avvik != 0.0:
statuser.append(ENDRET)
detaljer.append(f"{fil.stem}: {beskrivelse}: avvik {vis_tall(abs(avvik))}, skal være eksakt 0")
else:
statuser.append(LIK)
status = verst(statuser)
navn = ", ".join(f.stem for f in utvidelser)
linje = (f"Utvidelser: grensetilfellene gjenskaper kjernen eksakt ({navn})." if status == LIK
else f"Utvidelser: grensetilfellet gjenskaper ikke kjernen eksakt ({navn}; se under).")
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]
status = verst([d.status for d in kjort])
deler = (["uten full kjøring, --rask"] if rask else []) + (
["bare det som er kjørt"] if any(d.status == IKKE_KJORT for d in dommer) else [])
hva = f" ({'; '.join(deler)})" if deler else ""
if status == LIK:
linjer = [f"Nivå{hva}: bit for bit som notatet."]
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:
endret = [d.linje.split(":")[0] for d in kjort if d.status == ENDRET]
linjer = [f"Nivå{hva}: endret: {', '.join(endret)}."]
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("Pluginens modell skal regne som notatet, så dette er en feil.")
if any(d.status == IKKE_KJORT for d in dommer):
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()
with tempfile.TemporaryDirectory(prefix="dynamisk-rettsstat-") as tmp:
mappe = Path(tmp)
vis("1.", kontroll_kjerne())
vis("2.", kontroll_utskrift("diagnose", mappe))
if regnemiljo:
vis("3.", kontroll_utskrift("forlop", mappe))
else:
vis("3.", Dom(IKKE_KJORT, "forlop: ikke kjørt, numpy og scipy mangler."))
if rask:
print("4. Full kjøring: hoppet over (--rask).")
elif regnemiljo:
vis("4.", kontroll_full(mappe))
else:
vis("4.", Dom(IKKE_KJORT, "Full kjøring: ikke kjørt, numpy og scipy mangler."))
vis("5.", kontroll_utvidelser(mappe, 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:]))