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>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-11 15:27:14 +01:00
commit 776325c680
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 1524 additions and 199 deletions

View file

@ -302,3 +302,271 @@ def test_alle_eksempelscenarioer_er_gyldige() -> None:
assert sc.kjoringer, fil.name
if fil.parent == NYE:
assert sc.beskrivelse, f"{fil.name} mangler beskrivelse"
# ---------------- startlikevekten (gjennomgang E, funn 1) ----------------
def test_endret_grunnparameter_stopper_med_to_veier_ut(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "parametere": {"P": {"gamma": 0.15}}, "sjokk": {"faktor": 1.0}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
for t in ("startlikevekt: startpunktet er ikke en likevekt", "pådraget Omega0 (0,12)",
"ville drevet bort fra 1,00 også uten sjokk", "passer ikke lenger til strømmen",
'"startlikevekt": "kalibrer"', "(P.Omega0, CH0.Z, CH0.m)", '"startlikevekt": "ubalanse"'):
assert t in err, err
assert not (tmp_path / "ut").exists()
@pytest.mark.parametrize(("p", "modell"), [({"Lam0": 0.6}, "simulate"), ({"delta": 0.03}, "simulate"),
({"T0": 1.2}, "simulate"), ({"eta": 2.0}, "simulate"),
({"Omega0": 0.13}, "simulate"), ({"Kstar": 1.1}, "simulate"),
({"Tprop": 0.9}, "simulate"), ({"Tmax": 0.9}, "simulate"),
({"gamma": 0.1}, "simulate_soc"), ({"Lam0": 0.7}, "simulate_chain2")])
def test_hver_grunnparameter_i_likevekten_sjekkes(p: dict[str, float], modell: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": modell, "parametere": {"P": p}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "startpunktet er ikke en likevekt" in err, err
@pytest.mark.parametrize(("modell", "par"), [("simulate", {"P": {"ksat": 1.5}}),
("simulate_soc", {"SOC": {"eps": 0.5}}),
("simulate_chain", {"CH0": {"Z": 0.12}}),
("simulate_chain", {"CH0": {"varsigma": 0.02}})])
def test_parametere_kjernen_selv_rekalibrerer_stopper_ikke(
modell: str, par: dict[str, object], tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": modell, "parametere": par})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
def test_kalibrer_gir_ro_for_sjokket(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"navn": "k", "modell": "simulate", "startlikevekt": "kalibrer",
"parametere": {"P": {"Lam0": 0.6, "gamma": 0.2}}, "sjokk": {"faktor": 1.0}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
assert "Referansetilstanden er kalibrert på nytt" in ut
assert "P.Omega0 0,14 (var 0,12)" in ut
assert "tilliten er 0,60 der" in ut
rader = (tmp_path / "ut" / "k.csv").read_text(encoding="utf-8").splitlines()[1:]
assert all(abs(float(r.split(",")[1]) - 1.0) < 1e-9 for r in rader) # K står i ro
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
kalibrert = data["kjoringer"][0]["startlikevekt"]["kalibrert"]
assert kalibrert["P.Omega0"]["for"] == 0.12
assert data["kjoringer"][0]["brukt"]["parametere"]["P"]["Omega0"] == kalibrert["P.Omega0"]["etter"]
def test_kalibrer_kjeden_regner_om_z_og_m(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "kalibrer",
"parametere": {"P": {"gamma": 0.15}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
brukt = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))["kjoringer"][0]["brukt"]
j0 = 0.15 * 0.8 * 1.0
assert brukt["parametere"]["CH0"]["Z"] == j0 / 0.95 # sim.py:151
assert brukt["parametere"]["CH0"]["m"] == 3.0 - j0 / (j0 / 0.95 * (1 / 3.0 + 0.01)) # sim.py:155
assert brukt["parametere"]["P"]["Omega0"] == pytest.approx(j0 + 0.02, abs=1e-15)
@pytest.mark.parametrize("p", [{"Kstar": 1.1}, {"Omega0": 0.13}, {"Tprop": 0.9}])
def test_kalibrer_retter_ikke_det_som_er_satt_eller_ligger_fast(
p: dict[str, float], tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "startlikevekt": "kalibrer", "parametere": {"P": p}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert '"kalibrer" kan ikke gjøre starten til en likevekt' in err
def test_ubalanse_kjorer_og_viser_driften(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "startlikevekt": "ubalanse", "parametere": {"P": {"Lam0": 0.6}},
"sjokk": {"faktor": 1.0}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
assert "1,00 er nivået i referanselikevekten" not in ut
assert 'startpunktet er ikke en likevekt ("startlikevekt": "ubalanse"): pådraget Omega0' in ut
assert "Fram til måned 12 har K gått fra 1,000 til 1,0" in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
assert data["kjoringer"][0]["startlikevekt"]["ubalanse"]
def test_ubalanse_med_sjokk_viser_driften_til_sjokkmaaneden(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "ubalanse",
"parametere": {"P": {"gamma": 0.15}}, "sjokk": {"fra": 18}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
assert "Fram til måned 18 har K gått fra 1,000 til" in ut
assert "saksbehandlingstiden (mnd) fra 15,7 til" in ut
@pytest.mark.parametrize("valg", ["krev", "kalibrer", "ubalanse"])
def test_simulate_endo_med_annen_strom_ved_start_stopper_alltid(
valg: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_endo", "regel": "koord", "startlikevekt": valg,
"parametere": {"P": {"gamma": 0.11}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "sim.py:588" in err
assert "endring i kjernen" in err
def test_eta_i_kjeden_kan_ikke_kalibreres(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "kalibrer", "parametere": {"P": {"eta": 2.0}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "uten eta" in err
# ---------------- funn 2, 7, 8, 12, 13, 14 og kapasitet i simulate_chain2 ----------------
def test_kjoring_som_eksploderer_stopper_for_noe_skrives(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "utvidelse:paadrag_tidsserie",
"utvidelse": {"punkter": [[12, 1e308], [13, 1e308]]}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert ut == ""
assert "ikke er endelige tall" in err
assert "Ingenting er skrevet" in err
assert not (tmp_path / "ut").exists()
def test_absurd_k_gir_merknad(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "sjokk": {"faktor": 1e6}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
assert "## Merknader (kjøringen er ikke stoppet)" in ut
assert "utenfor området kjernen selv regner på" in ut
def test_notatets_vippekjoring_gir_ingen_merknad(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert kjor_fil(NOTATET / "vippe.json", tmp_path / "ut", capsys)[1].count("Merknader") == 0
def test_mappe_som_ikke_kan_skrives(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate"})
laast = tmp_path / "laast"
laast.mkdir()
laast.chmod(0o500)
try:
kode, _, err = kjor_fil(fil, laast / "ut", capsys)
finally:
laast.chmod(0o700)
assert kode == 2
assert err.startswith(f"Kan ikke skrive i {laast / 'ut'}: ")
assert err.rstrip().endswith("Velg en annen mappe med --ut.")
assert "Traceback" not in err
def test_koen_har_enhet(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / "kjede_s2.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Kø i domstolen ved slutt (mnd innstrømning før sjokket)" in ut
assert "Køen er målt i måneder av innstrømningen til domstolen før sjokket" in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
assert data["kjoringer"][0]["nokkeltall"]["Q_slutt_mnd"] == pytest.approx(56.4, abs=0.05) # Qend 5,64 / 0,1
@pytest.mark.parametrize(("periode", "tekst"), [(0.07, "minst 1"), (1.5, "helt antall måneder")])
def test_budsjettsyklus_er_hele_maaneder(periode: float, tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_endo", "regel": "koord", "parametere": {"ENDO": {"period": periode}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "parametere.ENDO.period" in err
assert tekst in err
@pytest.mark.parametrize(("data", "tekst"), [
({"modell": "simulate", "slutt": 0.04, "styring": {"forsinkelse": 0}}, "slutt: må være et tall minst 1"),
({"modell": "simulate", "slutt": 2}, "slutt: horisonten (2 mnd) er kortere enn forsinkelsen"),
({"modell": "simulate", "sveip": {"parameter": "slutt", "verdier": [2]}}, "slutt: horisonten (2 mnd)"),
])
def test_for_kort_horisont_navngir_slutt(data: dict[str, object], tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
kode, _, err = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 2
assert tekst in err, err
@pytest.mark.parametrize(("data", "tekst"), [
({"modell": "simulate_chain", "slutt": 6}, "Sjokket kommer i måned 12, ikke før slutt (6)"),
({"modell": "simulate_soc", "slutt": 30}, "Sjokket varer til måned 36, etter slutt (30)"),
])
def test_sjokk_utenfor_horisonten_gir_merknad(data: dict[str, object], tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 0
assert tekst in ut
@pytest.mark.parametrize("innhold", [{"kjoringer": [{"fil": "viktig.docx"}]},
{"scenario": "x", "scenariofil_sha256": "", "kjerne_sha256": {},
"kjoringer": [{"fil": "viktig.docx"}, {"fil": "../utenfor.csv"}]}])
def test_sletter_aldri_andres_filer(innhold: dict[str, object], tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
ut = tmp_path / "ut"
ut.mkdir()
(ut / "resultat.json").write_text(json.dumps(innhold), encoding="utf-8")
(ut / "viktig.docx").write_text("x", encoding="utf-8")
(tmp_path / "utenfor.csv").write_text("x", encoding="utf-8")
kjor_fil(skriv(tmp_path, {"navn": "n", "modell": "simulate"}), ut, capsys)
assert (ut / "viktig.docx").is_file()
assert (tmp_path / "utenfor.csv").is_file()
def test_kapasitet_i_simulate_chain2_vises(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain2", "tiltak": {"kapasitet": {"faktor": 1.34},
"andel_utenom": {"andel": 0.2}}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
tall = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))["kjoringer"][0]["nokkeltall"]
assert tall["Z_slutt"] == pytest.approx(1.34)
assert tall["utenom_slutt_pst"] == pytest.approx(20.0)
assert "Kapasitet, ved slutt (1,00 = ved start)" in ut
def test_bruk_nevner_riktig_python() -> None:
assert scenario.BRUK.startswith(f"Bruk: {scenario.PYTHON} modell/kjor.py scenario")
# ---------------- modeller med kø driver sakte før sjokket ----------------
def test_modell_med_ko_viser_driften_for_sjokket(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / "kjede_s2.json", tmp_path / "ut", capsys)
assert kode == 0
assert "1,00 er nivået ved start for kriminaliteten K og politiets innsats T (notatets referansetilstand)" in ut
assert "nesten, men ikke helt, en likevekt" in ut
assert "referanselikevekten" not in ut
assert "i ro" not in ut
assert "Fram til måned 12 har K gått fra 1,000 til 1,007, innsatsen T fra 1,00 til 1,02" in ut
def test_modell_med_ko_uten_sjokk_sier_at_den_kryper(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NYE / "uten_sjokk_kjede.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Fram til måned" not in ut
assert 'kryper modellen med kø sakte oppover av seg selv; en kjøring uten sjokk ("sjokk": {"faktor": 1})' in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
tall = data["kjoringer"][0]["nokkeltall"]
assert round(tall["K_slutt"], 3) == 1.018
assert round(tall["tauD_slutt"], 1) == 4.6
@pytest.mark.parametrize("modell", ["simulate_chain2", "simulate_endo", "utvidelse:paadrag_tidsserie"])
def test_alle_modeller_med_ko_far_driftlinjen(modell: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
data: dict[str, object] = {"modell": modell}
if modell == "simulate_endo":
data["regel"] = "koord"
kode, ut, _ = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 0
assert "nesten, men ikke helt, en likevekt" in ut
assert "Fram til måned 12 har K gått fra 1,000 til" in ut
@pytest.mark.parametrize("fil", ["p3", "vippe"])
def test_modell_uten_ko_har_ingen_driftlinje(fil: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / f"{fil}.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Slik leses tallene: 1,00 er nivået i referanselikevekten før sjokket" in ut
assert "Fram til måned" not in ut
assert "nesten, men ikke helt" not in ut