dynamisk-rettsstat/docs/utvidelser.md
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

242 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Utvidelser: å legge til en mekanisme
En utvidelse er en mekanisme modellen ikke har, lagt ved siden av kjernen i stedet for inn i den. Den ligger i
`modell/utvidelser/<navn>.py`, kjøres med en scenariofil (`"modell": "utvidelse:<navn>"`) og kontrolleres av
`kontroll`. Kjernen (`modell/kjerne/`) forblir notatets kode, så det er alltid klart hva som er notatets modell og hva
som er lagt til.
## Regelen
Samme regel som i notatets kode (`sim.py`, Del 3: «Alle utvidelser har grensetilfeller som gjenskaper Del 1 og 2
eksakt»): **en utvidelse skal ha et grensetilfelle som gjenskaper kjernen eksakt, og en test som viser det.** Grensetilfellet er en verdi av utvidelsens parametere der mekanismen forsvinner,
for eksempel en faktor lik 1, et ledd lik 0 eller en kapasitet lik uendelig. Med den verdien skal utvidelsen gi
nøyaktig de samme tallene som simulatoren i kjernen den bygger på: største absolutte avvik over alle tidsserier skal
være `0.0`, ikke bare lite.
Hvorfor eksakt: da vet både du og leseren at alt utvidelsen viser utover kjernen, kommer fra mekanismen, ikke fra en
annen tallbehandling, en annen tidsstegning eller en feil i kopien av løkka. Avvik på 10⁻¹⁵ betyr at regnestegene er
gjort i en annen rekkefølge, og da er det ikke lenger kjernens modell.
## Grensesnittet
| Navn | Type | Krav |
|---|---|---|
| `NAVN` | tekst | lik filnavnet uten `.py` |
| `BESKRIVELSE` | tekst | én setning; står i sammendraget etter modellnavnet |
| `PARAMETERE` | dict | utvidelsens parametere med standardverdier. Standardverdiene bør være grensetilfellet |
| `simuler(scenario) -> dict` | funksjon | kjører modellen; se under |
| `grensetilfelle() -> list[tuple[str, float]]` | funksjon | minst ett tilfelle: (beskrivelse, største absolutte avvik mot kjernen). Hvert avvik skal være `0.0` |
| `BRUKER` | tuple, valgfri | hvilke av kjernens dicts (`"P"`, `"CH0"`, `"CH2"`, `"ENDO"`, `"SOC"`) som kan overstyres under `parametere`. Uten den: alle fem |
| `sjekk(scenario) -> None` | funksjon, valgfri | kalles før kjøringen; en `ValueError("felt: hva som er galt")` gir exit-kode 2 med meldingen din |
| `GJENSKAPER_NOTATET` | tuple, valgfri | navn på scenariofiler i `eksempler/scenarioer/notatet/` (uten `.json`) som utvidelsen med standardverdiene skal gjenskape byte for byte når `"modell"` byttes til `"utvidelse:<navn>"`. Testene kjører dem gjennom kjøreren og sammenligner med `fasit/data` |
### Hva `simuler` får
Scenariokjøreren validerer scenariofila og gir `simuler` én kjøring om gangen (ett sveip gir flere kall), som en dict:
```python
{
"navn": "T_clear=24", # kjøringens navn ("<navn>" uten sveip)
"styring": {"regulator": "P", "forsinkelse": 3.0, "reaktivitet": 3.0},
"slutt": 120.0, # horisont i måneder
"tiltak": {...}, # tiltakene slik de står i fila (oppløst, med standardverdier)
"parametere": { # kjernens dicts, fullt utfylt med overstyringene
"P": {...}, # som sim.P, med det fila endrer
"CH0": {...}, # som sim.CH0, med Zfun, xifun og dbr satt fra tiltak
... # bare dictene i BRUKER
},
"utvidelse": {...}, # PARAMETERE med verdiene fra fila
}
```
Dictene kan sendes rett til kjernens funksjoner (`sim.deriv_chain(..., p, ch, ...)`). Typene i `"utvidelse"` er
sjekket mot standardverdiene: tall (også `"inf"`), heltall, true/false, tekst eller liste. Innholdet i en liste
sjekker du selv i `sjekk`. Feltet `sjokk` i scenariofila er ikke tillatt for utvidelser: pådraget er utvidelsens sak.
Før `simuler` kalles, sjekker kjøreren at startpunktet er en likevekt for dictene i `BRUKER`, på samme måte som for
kjernens modeller (`startlikevekt` i [scenarioformat.md](scenarioformat.md)): `P` for grunnmodellen, og `CH0` eller
`CH2` for domstolen når kapasiteten er endelig. Med `"startlikevekt": "kalibrer"` får `simuler` de kalibrerte
verdiene i `parametere`.
### Hva `simuler` returnerer
En dict med tidsserien `"t"` (numpy-array) og andre tidsserier med samme lengde, med kjernens navn der det gir mening
(`K`, `U`, `L`, `T`, `Q`, `tauD`, `JD`, `RD`, `Z`, `Z_rel`, `xi`, …). Kjøreren behandler dem som en kjøring av
kjernen: nøkkeltallene velges etter hvilke tidsserier som finnes, alle kan skrives til CSV, og `utdata.kolonner` kan
velge blant dem. Tall (ikke arrays) blir «skalarer» i `resultat.json`; en liste med tekst under `"log"` blir
vedtakslinjer i sammendraget.
## Hvordan utvidelser kontrolleres
- `python3 modell/kjor.py kontroll --rask` kjører `grensetilfelle()` i hver utvidelse (hver fil i
`modell/utvidelser/` som ikke begynner med `_`) og sier «Utvidelser: grensetilfellene gjenskaper kjernen eksakt
(...)», eller hvilket tilfelle som ikke gjør det.
- `tests/test_utvidelser.py` går gjennom alle utvidelsene: grensesnittet finnes, og hvert avvik er `0.0`. En ny
utvidelse er med uten at testen endres.
- Avviket `grensetilfelle()` rapporterer, er utvidelsens egen beregning; en utvidelse som bare returnerer `0.0`,
ville gått gjennom. Derfor bør utvidelsen oppgi `GJENSKAPER_NOTATET`: da kjører testen de scenariofilene gjennom
scenariokjøreren med utvidelsen og sammenligner datafilene byte for byte med `fasit/data`, uavhengig av
`grensetilfelle()`. Mangler den, sier testen fra (hoppet over, med grunn).
- `resultat.json` har sha256 for utvidelsens fil, så det er sporbart hvilken versjon en kjøring brukte.
## Gjennomført eksempel: `paadrag_tidsserie`
I kjernen er det strukturelle pådraget Ω₀ før måned 12 og Ω₀ · 1,3 etterpå (`Omega` i `sim.py`): ett varig sjokk.
Utvidelsen lar pådraget følge Ω₀ · f(t), der f er en tidsserie du oppgir. Da kan en forbigående bølge, et sjokk i
flere trinn eller en gradvis endring kjøres.
| Parameter | Standard | Hva |
|---|---|---|
| `form` | `"trapp"` | `"trapp"`: f holder verdien fra hvert punkt til neste; `"lineaer"`: rett linje mellom punktene |
| `punkter` | `[[12.0, 1.3]]` | `[måned, faktor]`, stigende måneder. Før første punkt er f = 1, etter siste er f lik siste verdi |
- Styring: P-regelen (`styring.regulator` må være `"P"`).
- Med kjeden (`parametere.CH0.Z` endelig, standard): regnestegene i `simulate_chain`, med kjernens `deriv_chain` og
pådraget gitt som `Om0`. Tiltakene virker som i `simulate_chain` (`andel_utenom`, `kapasitet`,
`trommel_buffer_tau`).
- Uten kjeden (`"CH0": {"Z": "inf"}`): regnestegene i `simulate`. Utvidelsen har en egen løkke for dette i stedet
for å la `simulate_chain` med uendelig kapasitet gjøre jobben, fordi den bare er eksakt lik `simulate` for regime A
(for regime B–D er avviket opptil 3,8·10⁻¹⁵, se [modellkart.md](modellkart.md), del (e)).
- `P.shock` og `P.t_shock` brukes ikke (pådraget følger `punkter`); endres de, er det en feil.
- Tidsserien `Omega_faktor` (f(t)) skrives med i CSV-fila.
Grensetilfellene, som `grensetilfelle()` returnerer:
| Tilfelle | Kjernen | Avvik |
|---|---|---|
| trapp med ett punkt `[12, 1.3]`, notatets `CH0` | `simulate_chain("P", 3.0, CH0)` | 0.0 (t, K, U, L, T, Q, tauD, JD, RD og tau_ref) |
| samme trapp, `CH0.Z` = inf | `simulate("P", 3.0)` | 0.0 (t, K, U, L, T) |
Utvidelsen oppgir `GJENSKAPER_NOTATET = ("kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5")`: kjørt gjennom
scenariokjøreren med standardverdiene (og tiltakene fra scenario 3–5) gir den notatets datafiler byte for byte. Testene
viser i tillegg at den med `CH0.Z` = inf gir `p3.dat`.
Eksempel: `eksempler/scenarioer/utvidelse_bolge.json` kjører en bølge (40 % høyere pådrag fra måned 18 til 36, med
lineær stigning og fall) med og uten flaskehals i domstolen.
## Mal
Malen under er en hel, kjørbar utvidelse: politiets effektivitet (`gamma` i fjerningsleddet) endres med en faktor
fra en gitt måned. Med faktor 1 forsvinner mekanismen, og utvidelsen gjenskaper `simulate("P", ...)` eksakt. Kopier
den til `modell/utvidelser/<ditt_navn>.py` og bytt ut mekanismen.
<!-- mal:start -->
```python
"""Utvidelse: politiets effektivitet endres med en faktor fra en gitt måned.
Bygger på simulate i sim.py (grunnmodellen uten kjede). Grensetilfelle: faktor = 1
gjenskaper simulate("P", tau) eksakt.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from typing import Any
import numpy as np
NAVN = "mal_effektivitet" # samme som filnavnet uten .py
BESKRIVELSE = "politiets effektivitet ganges med en faktor fra en gitt måned (mal)"
PARAMETERE: dict[str, object] = { # standardverdiene er grensetilfellet
"faktor": 1.0, # effektiviteten ganges med dette ...
"fra": 24.0, # ... fra denne måneden
}
BRUKER = ("P",) # kjernens dicts som kan overstyres under "parametere"
KJERNE = Path(__file__).resolve().parents[1] / "kjerne"
def _sim() -> Any:
"""Kjernen fra samme modellmappe som utvidelsen."""
if str(KJERNE) not in sys.path:
sys.path.insert(0, str(KJERNE))
return importlib.import_module("sim")
def sjekk(scenario: dict[str, Any]) -> None:
"""Valgfri: stopp med en norsk melding før kjøringen."""
if scenario["styring"]["regulator"] != "P":
raise ValueError('styring.regulator: denne utvidelsen bruker bare "P".')
if scenario["utvidelse"]["faktor"] < 0:
raise ValueError("utvidelse.faktor: må være minst 0.")
def simuler(scenario: dict[str, Any]) -> dict[str, Any]:
"""Løkka fra simulate i sim.py, med mekanismen lagt inn i fjerningsleddet."""
sim = _sim()
p = scenario["parametere"]["P"]
tau = scenario["styring"]["forsinkelse"]
kp = scenario["styring"]["reaktivitet"]
u = scenario["utvidelse"]
dt = sim.DT
n = int(scenario["slutt"] / dt) + 1
t = np.arange(n) * dt
K = np.ones(n); U = np.full(n, p["U0"]); L = np.full(n, p["Lam0"]); T = np.full(n, p["T0"])
lag = int(round(tau / dt))
for k in range(n - 1):
Kobs = K[k - lag] if k - lag >= 0 else 1.0
T[k] = np.clip(p["T0"] + kp * (Kobs - p["Kstar"]), 0.0, p["Tmax"])
# Mekanismen: med faktor 1.0 er F * 1.0 nøyaktig F, så grensetilfellet blir eksakt.
f = u["faktor"] if t[k] >= u["fra"] else 1.0
F = sim.removal(K[k], L[k], T[k], p) * f
# Resten er deriv() i sim.py, med F i stedet for removal(...). Samme rekkefølge på regnestegene.
dK = sim.Omega(t[k], p) - F - p["delta"] * K[k]
Ueq = np.clip(p["U0"] - p["psiU"] * (K[k] - 1.0), 0.0, 1.0)
dU = p["kU"] * (Ueq - U[k])
over = max(0.0, T[k] - p["Tprop"] * K[k])
dL = p["kL"] * (p["Lam0"] + p["mu1"] * (U[k] - p["U0"]) - L[k]) - p["mu2"] * L[k] * over ** 2
K[k + 1] = K[k] + dt * dK
U[k + 1] = U[k] + dt * dU
L[k + 1] = np.clip(L[k] + dt * dL, 1e-4, 1 - 1e-4)
T[-1] = T[-2]
return {"t": t, "K": K, "U": U, "L": L, "T": T}
def grensetilfelle() -> list[tuple[str, float]]:
"""Faktor 1 gjenskaper simulate i kjernen; avvikene skal være eksakt 0.0."""
sim = _sim()
ut = []
for tau in (3.0, 11.0):
scenario = {"styring": {"regulator": "P", "forsinkelse": tau, "reaktivitet": 3.0},
"slutt": float(sim.TEND), "parametere": {"P": dict(sim.P)},
"utvidelse": {"faktor": 1.0, "fra": 24.0}}
egen = simuler(scenario)
kjerne = dict(zip(("t", "K", "U", "L", "T"), sim.simulate("P", tau)))
avvik = max(float(np.abs(egen[n] - kjerne[n]).max()) for n in kjerne)
ut.append((f'mal_effektivitet: faktor 1 = simulate("P", {tau:g})', avvik))
return ut
```
<!-- mal:slutt -->
`tests/test_utvidelser.py` legger malen inn i en kopi av modellen og kontrollerer at grensetilfellene er `0.0` og at
den kan kjøres med en scenariofil, så malen virker slik den står her.
## Trinn for trinn
1. **Lag en arbeidskopi** du kan endre: `python3 modell/kjor.py arbeidskopi rettsstat-modell`. Resten gjøres i
kopien.
2. **Finn stedet i modellen** mekanismen hører til: hvilken ligning, hvilken funksjon i `sim.py`, hvilke parametere
([modellkart.md](modellkart.md)). Velg simulatoren du bygger på: den enkleste som har det mekanismen trenger.
3. **Kopier malen** til `modell/utvidelser/<navn>.py`. Sett `NAVN` lik filnavnet, skriv `BESKRIVELSE`, og legg
utvidelsens parametere i `PARAMETERE` med grensetilfellet som standardverdier.
4. **Kopier løkka** fra simulatoren du bygger på inn i `simuler`, uendret, og legg mekanismen inn slik at den
forsvinner i grensetilfellet. Det som gjør avviket eksakt 0: gang med `1.0`, legg til `0.0`, eller velg en gren
(`if`) som gjør nøyaktig det kjernen gjør. Bruk kjernens funksjoner (`removal`, `deriv_chain`, `court`,
`chain_at`, …) der du kan, i stedet for å skrive dem av. Endre ikke rekkefølgen på regnestegene: `a - b - c` og
`a - (b + c)` kan gi forskjellig siste siffer.
5. **Skriv `grensetilfelle()`**: kjør `simuler` med grenseverdiene og kjernen med de samme forutsetningene, og
returner største absolutte avvik over alle tidsseriene. Ta gjerne med flere tilfeller (andre forsinkelser, med og
uten kjede).
6. **Kontroller**: `python3 modell/kjor.py kontroll --rask` skal si at grensetilfellene gjenskaper kjernen eksakt.
Kjør testene (`python3 -m pytest tests/test_utvidelser.py`); utvidelsen er med automatisk. Oppgi
`GJENSKAPER_NOTATET` hvis standardverdiene gjenskaper en av notatets scenariofiler, så sjekkes grensetilfellet
også uavhengig av din egen `grensetilfelle()`. Skriv gjerne en egen test for det mekanismen skal gjøre utenfor
grensetilfellet.
7. **Bruk den**: skriv en scenariofil med `"modell": "utvidelse:<navn>"` og parametrene under `"utvidelse"`, og kjør
`python3 modell/kjor.py scenario <fil.json>`. Sveip virker som ellers, også over `utvidelse.<parameter>`.
8. **Send den tilbake** hvis du vil: se `BIDRA.md` i pluginen.
Vil du heller endre kjernen selv, kan du det i arbeidskopien. Da sier `kontroll` hvilke av notatets resultater som
flytter seg, og hvor mye. En utvidelse er likevel å foretrekke når mekanismen kan stå ved siden av: notatets
resultater ligger da fast, og grensetilfellet viser at de gjør det.