dynamisk-rettsstat/modell/arbeidskopi.py
Kjell Tore Guttormsen 03151cfb3b
feat(scenario): scenario runner, extensions and working copy
scenario.py runs all five simulators of the core from a JSON scenario file,
with sweeps, and reproduces 20 of the note's data files (14 byte for byte,
6 column-wise). Extensions live outside the core and must reproduce it
exactly in a limit case; paadrag_tidsserie is the worked example.
arbeidskopi.py gives the user a copy of the model they can change.

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

208 lines
8.7 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 shlex
import shutil
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"),
)
BRUK = "Bruk: python3 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)
for navn in MAPPER:
kilde = (ROT / navn).resolve()
if mal == kilde or kilde in mal.parents:
melding = f"{mal} ligger inne i {kilde}, som skal kopieres. Velg en mappe utenfor."
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]) -> str:
"""Innholdet i LES-MEG.md."""
kjor = f"python3 {shlex.quote(str(mal / 'modell' / 'kjor.py'))}"
eks = mal / "eksempler"
def q(sti: Path) -> str:
return shlex.quote(str(sti))
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`:",
"`python3 <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.",
"Testene kjøres med `python3 -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."),
"",
"Tekst og kode er under lisensene i pluginen (MIT for kode og data, 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 = shlex.quote(str(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" python3 {kjor} scenario <fil.json>\n"
f"og kontroller endringer med:\n"
f" python3 {kjor} kontroll\n")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))