dynamisk-rettsstat/modell/arbeidskopi.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

228 lines
9.4 KiB
Python

# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Arbeidskopi: kopier modellen til en mappe brukeren eier og kan endre.
Bruk:
python3 modell/kjor.py arbeidskopi <mappe>
python3 modell/arbeidskopi.py <mappe>
Kopierer modell/, eksempler/, fasit/, tests/ og docs/ (det som finnes), og
pyproject.toml og LICENSE når de finnes, og skriver LES-MEG.md i mappa. Nekter
hvis mappa finnes og ikke er tom. Kopierer ikke .venv, __pycache__, mellomlagre
eller resultater/. Bare standardbiblioteket.
"""
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
MODELLMAPPE = Path(__file__).resolve().parent
ROT = MODELLMAPPE.parent
MAPPER = ("modell", "eksempler", "fasit", "tests", "docs")
FILER = ("pyproject.toml", "LICENSE")
HOPP_OVER = (".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "resultater",
"rettsstat-modell", ".DS_Store", "*.pyc", "*.pyo")
NODVENDIG = ("kjor.py", "scenario.py", "kontroll.py", "arbeidskopi.py", "requirements.txt",
"kjerne/sim.py", "kjerne/diagnose.py", "kjerne/forlop.py", "kjerne/KILDE.json",
"utvidelser/__init__.py")
DOKUMENTER = (
("scenarioformat.md", "scenariofila: alle felt, sveip og utdata"),
("utvidelser.md", "hvordan du legger til en mekanisme som utvidelse, med mal"),
("modellkart.md", "fra ligning i notatet til funksjon og parameter i koden"),
("inndata.md", "inndatafila til diagnose og forlop"),
("forbehold.md", "hva modellen kan og ikke kan brukes til"),
)
def paa_windows(*, windows: bool | None = None) -> bool:
"""Sann på Windows (eller når testene ber om det)."""
return os.name == "nt" if windows is None else windows
def python_navn(*, windows: bool | None = None) -> str:
"""Tolken heter python på Windows og python3 ellers."""
return "python" if paa_windows(windows=windows) else "python3"
def sitert(sti: Path | str, *, windows: bool | None = None) -> str:
"""En sti slik den må skrives på kommandolinja: cmd/PowerShell på Windows, POSIX-skall ellers."""
return subprocess.list2cmdline([str(sti)]) if paa_windows(windows=windows) else shlex.quote(str(sti))
BRUK = f"Bruk: {python_navn()} modell/kjor.py arbeidskopi <mappe>"
class KopiFeil(Exception):
"""Feil i bruken eller i kildemappa; gir exit-kode 2."""
def sjekk_kilde() -> None:
"""Stopp hvis modell/ ikke er komplett."""
mangler = [n for n in NODVENDIG if not (MODELLMAPPE / n).is_file()]
if mangler:
melding = (f"Modellen i {MODELLMAPPE} er ikke komplett, så arbeidskopien ville ikke virket. Mangler: "
+ ", ".join(f"modell/{n}" for n in mangler) + ".")
raise KopiFeil(melding)
def sjekk_mal(mal: Path) -> None:
"""Mappa må være ny eller tom, og ikke ligge inne i det som kopieres."""
if mal.exists() and not mal.is_dir():
melding = f"{mal} finnes og er en fil, ikke en mappe."
raise KopiFeil(melding)
if mal.is_dir() and any(mal.iterdir()):
melding = (f"Mappa {mal} finnes og er ikke tom. Velg en ny mappe; arbeidskopi skriver aldri over noe "
"som finnes.")
raise KopiFeil(melding)
rot = ROT.resolve()
if mal == rot or rot in mal.parents:
melding = (f"{mal} ligger inne i {rot}, modellen som skal kopieres. Velg en mappe utenfor, for eksempel "
"i arbeidsmappa di.")
raise KopiFeil(melding)
def kildeversjon() -> str:
"""Commit i notatets repo som kjernen er kopiert fra (fra KILDE.json)."""
try:
data = json.loads((MODELLMAPPE / "kjerne" / "KILDE.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return "ukjent"
if not isinstance(data, dict):
return "ukjent"
commit = str(data.get("commit", "ukjent"))
repo = str(data.get("repo", data.get("url", "")))
return f"{repo} @ {commit[:7]}" if repo else commit[:7]
def les_meg(mal: Path, docs: list[str], *, windows: bool | None = None) -> str:
"""Innholdet i LES-MEG.md."""
py = python_navn(windows=windows)
kjor = f"{py} {sitert(mal / 'modell' / 'kjor.py', windows=windows)}"
eks = mal / "eksempler"
def q(sti: Path) -> str:
return sitert(sti, windows=windows)
linjer = [
"# Arbeidskopi av modellen bak «Den dynamiske rettsstaten»",
"",
(f"Denne mappa er din egen kopi av modellen i pluginen dynamisk-rettsstat. Kjernen i `modell/kjerne/` er "
f"en uendret kopi av notatets kode ({kildeversjon()}); sjekksummene står i `modell/kjerne/KILDE.json`."),
"",
"## Hva du kan endre",
"",
"Alt i mappa kan endres, også kjernen. Tre veier, fra minst til mest inngripende:",
"",
"1. Andre forutsetninger: skriv en scenariofil (se `docs/scenarioformat.md`). Ingen kode endres.",
("2. En ny mekanisme ved siden av kjernen: legg den i `modell/utvidelser/` (se `docs/utvidelser.md`). "
"Regelen er den samme som i notatet: utvidelsen må ha et grensetilfelle som gjenskaper kjernen eksakt."),
("3. Endre kjernen selv (`modell/kjerne/sim.py`). Kontrollen under sier da hvilke av notatets resultater "
"som flytter seg, og hvor mye."),
"",
"## Slik kjører du kopien",
"",
"Kommandoene bruker koden i denne mappa:",
"",
"```",
f"{kjor} scenario {q(eks / 'scenarioer' / 'avviklingstid_koordinert.json')}",
f"{kjor} diagnose {q(eks / 'eksempel_domstol.json')}",
f"{kjor} forlop {q(eks / 'eksempel_domstol.json')}",
"```",
"",
"Fra pluginen kan du peke på kopien med `--modell`:",
f"`{py} <plugin>/modell/kjor.py --modell " + q(mal) + " scenario <fil.json>`. Ferdighetene i "
"pluginen bruker kopien selv når mappa du jobber i, har en `rettsstat-modell/`.",
"",
("Scenario, forløp og kontroll trenger numpy og scipy. Mangler de, sier kommandoen fra; "
f"`{kjor} miljo --opprett` installerer dem i en egen mappe."),
"",
"## Slik kontrollerer du en endring",
"",
"```",
f"{kjor} kontroll # alt, om lag 1-2 minutter",
f"{kjor} kontroll --rask # uten den fulle kjøringen av sim.py",
"```",
"",
"Kontrollen sammenligner med notatets resultater i `fasit/` og ender med ett av tre nivåer:",
"",
"- «bit for bit som notatet»: endringen flytter ingen av notatets resultater.",
("- «innenfor toleranse»: tallene avviker med høyst én enhet i siste viste siffer, som med andre versjoner "
"av numpy og scipy."),
("- «endret: ...»: noe har flyttet seg. I en arbeidskopi er det en opplysning, ikke en feil. Detaljene "
"sier hvilke datafiler og utskrifter som er endret, og største avvik per kolonne."),
"",
"Kontrollen kjører også `grensetilfelle()` i hver utvidelse; hvert avvik skal være eksakt 0.",
f"Testene kjøres med `{py} -m pytest` i mappa (krever pytest; de trege med `-m treg`).",
"",
"## Dokumentasjon",
"",
]
if docs:
linjer.extend(f"- `docs/{navn}`: {hva}" for navn, hva in DOKUMENTER if navn in docs)
linjer.extend(f"- `docs/{navn}`" for navn in docs if navn not in {n for n, _ in DOKUMENTER})
else:
linjer.append("- (docs/ fantes ikke da kopien ble laget)")
linjer += [
"",
"## Sende en forbedring tilbake",
"",
("Se `BIDRA.md` i pluginen. Kort: lag en patch av endringene (for eksempel med `git diff` hvis du har lagt "
"mappa i git) og send den på e-post med en forklaring og utskriften fra kontrollen."),
"",
"Alt i pluginen er under MIT-lisensen (se `LICENSE`).",
"",
]
return "\n".join(linjer)
def kopier(mal: Path) -> int:
"""Lag arbeidskopien. Returnerer antall filer."""
sjekk_kilde()
sjekk_mal(mal)
mal.mkdir(parents=True, exist_ok=True)
ignorer = shutil.ignore_patterns(*HOPP_OVER)
for navn in MAPPER:
kilde = ROT / navn
if kilde.is_dir():
shutil.copytree(kilde, mal / navn, ignore=ignorer)
for navn in FILER:
if (ROT / navn).is_file():
shutil.copy2(ROT / navn, mal / navn)
docs = sorted(p.name for p in (mal / "docs").glob("*.md")) if (mal / "docs").is_dir() else []
(mal / "LES-MEG.md").write_text(les_meg(mal, docs), encoding="utf-8")
return sum(1 for p in mal.rglob("*") if p.is_file())
def main(argv: list[str]) -> int:
"""Kommandoen arbeidskopi. Exit-kode 0 ok, 2 feil i bruken."""
if argv and argv[0] in {"-h", "--hjelp", "--help"}:
sys.stdout.write(BRUK + "\n")
return 0
if len(argv) != 1 or not argv[0].strip():
sys.stderr.write(BRUK + "\n")
return 2
mal = Path(argv[0]).expanduser().resolve()
try:
antall = kopier(mal)
except KopiFeil as e:
sys.stderr.write(f"{e}\n")
return 2
except OSError as e:
sys.stderr.write(f"Kunne ikke lage arbeidskopien i {mal}: {e}\n")
return 2
kjor = f"{python_navn()} {sitert(mal / 'modell' / 'kjor.py')}"
sys.stdout.write(f"Arbeidskopien er laget i {mal} ({antall} filer).\n"
f"Les {mal / 'LES-MEG.md'} først. Kjør den med:\n"
f" {kjor} scenario <fil.json>\n"
f"og kontroller endringer med:\n"
f" {kjor} kontroll\n")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))