# 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/.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:]))