den-dynamiske-rettsstaten/paper/diagnose.py
Kjell Tore Guttormsen e6e9d3c74d
fix: correct findings from the pre-publication quality check
Seventeen independent read-only reviews (figures and sources, the diagnosis
tool, the mathematics, simulation numbers, appendix C, references, claims,
language, layout, summary and slides, README and repo, cross-document
consistency), each followed by an independent verifier. Every fix below was
checked against the source or recomputed before it was applied.

Norwegian diagnosis (paper/norge_2025.json, README):
- The prison outflow mixed units: it multiplied "cases handled" (16 000) by
  the share of enforced cases. It now uses enforced cases (KDI annual report
  2025, p. 70: 5 180 in the community = 58 %, so about 3 750 in prison), and
  the queue excludes sentences waiting for electronic monitoring.
- Result changes: prison and police/prosecution tie at utilisation 1,09; the
  tool points to prison on backlog growth. README says the difference is
  smaller than the uncertainty and lists the sensitivity (1,06 to 1,10).
- Police backlog taken directly from POD report 1/2026 fig. 3.2.1 (31 908
  vs 30 295); page references to the courts report corrected (15 and 18).

diagnose.py:
- Norwegian error messages instead of tracebacks for bad input; same
  expansion threshold as sim.py; tie message; honest wording when no
  forecast is given, when the constraint is the first link, and when a
  backlog grows with no link above full utilisation.

Note, summary and slides (PDFs rebuilt: 58, 2 and 16 pages):
- b_sat in (13') lacked the factor 1/T-bar; lowest trust in appendix C is
  0,33, not 0,47; nine numbers rounded or quoted wrongly; one sensitivity
  sentence was wrong for mu_4; the abstract claimed more police effort does
  not reduce crime, which the model contradicts.
- The decommissioning rule is described as the code implements it.
- Three references corrected, one added (Srivastav et al. 2019).
- Core sentence quoted verbatim in README and section 9.
- Layout: duplicate hyperref targets, the overfull box in table 1, three
  figures with colliding labels, one stranded heading.
- Summary and slides no longer say "not tested on Norwegian data"; slide 16
  gives the repository address.

sim.py and paper/data/ are untouched.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:46:23 +01:00

222 lines
12 KiB
Python

"""Flaskehalsdiagnose for straffesakskjeden fra egne tall (første versjon).
Bruk (fra paper/):
python3 diagnose.py eksempel_domstol.json
Inndata er en JSON-fil med ett objekt per ledd i kjeden, i rekkefølge. Alle
strømmer i saker per måned, beholdninger i saker, tider i måneder:
{"ledd": [
{"navn": "Domstol",
"innstromning": 134, # saker inn per måned nå
"kapasitet": 105, # saker leddet kan avgjøre per måned, slik det virker nå
"restanse": 669, # saker i kø nå
"maal_tid": 3, # mål for saksbehandlingstid (mnd)
"prognose_innstromning": 134, # valgfritt: strømmen når balansen er gjenopprettet
"maks_andel_utenom": 0.2, # valgfritt: andel som kan ledes til forenklede reaksjoner
"restanse_for_12_mnd": 400, # valgfritt: gir observert vekst i restansen
"restanse_maal": 300, # valgfritt: akseptabel restanse (saker) i stedet for maal_tid;
# oppgis begge, brukes restanse_maal
"skjult_avgang": 0} # valgfritt: saker per måned som avsluttes uten behandling og
# som er regnet med i kapasitet (f.eks. henlagt pga. kapasitet);
# trekkes fra kapasiteten når utnyttelse og behov regnes ut
]}
Verktøyet følger trinn (c)-(e) i metoden (notatet, avsnitt 7.7 og 9.4-9.5):
(c) finn skranken som binder: høyest utnyttelse; er utnyttelsen lik på to desimaler,
avgjør restanseveksten i forhold til kapasiteten;
(d) velg tiltakstype etter Theory of Constraints: utnytt, underordne, utvid;
(e) dimensjoner kapasiteten etter prognosen for strømmen, ikke etter køen, med
den koordinerte regelen fra sim.py (simulate_endo, policy="koord"):
Z_ny = (xi_min * F_prognose + Q_overskudd / T_avvikling) / ups_star.
Forløpet over tid (simulering med egne tall) er ikke med i denne versjonen.
Verktøyet regner ikke med kapasitet som er bevilget, men ennå ikke virker: oppgi
kapasiteten som virker nå, og trekk det som allerede er bevilget, fra behovet.
Kolonnen «Saksbeh.tid» er restanse delt på kapasitet, ikke målt saksbehandlingstid.
Restansen over målet regnes som restanse - maal_tid * kapasitet.
sim.py trekker i tillegg fra minste behandlingstid m_D; eksempelfila er satt
slik at overskuddet blir det samme som i sim.py i måned 24.
Bruker ingenting fra sim.py og endrer ikke paper/data/.
"""
import json
import sys
UPS_STAR = 0.95 # målutnyttelse ved dimensjonering (ENDO["ups_star"] i sim.py)
UPS_HI = 1.0 # bestill når utnyttelsen er over denne (ENDO["ups_hi"])
T_CLEAR = 24.0 # restansen avvikles over denne tiden, mnd (ENDO["T_clear"])
L1, L2 = 6.0, 24.0 # bestilt kapasitet virker lineært fra L1 til L2 mnd etter vedtak
TALLFELT = ("innstromning", "kapasitet", "restanse", "maal_tid", "prognose_innstromning",
"maks_andel_utenom", "restanse_for_12_mnd", "restanse_maal", "skjult_avgang")
def tall(x, d=2):
return f"{x:,.{d}f}".replace(",", " ").replace(".", ",")
def sjekk(data):
"""Stopp med en forståelig melding før regningen hvis inndata ikke kan brukes."""
if not isinstance(data, dict) or not isinstance(data.get("ledd"), list) or not data["ledd"]:
sys.exit('Feil i inndata: fila må ha en liste "ledd" med minst ett ledd.')
for nr, ledd in enumerate(data["ledd"], 1):
if not isinstance(ledd, dict):
sys.exit(f"Feil i inndata, ledd {nr}: hvert ledd må være et objekt med felt og verdier.")
hvor = f'ledd {nr} ({ledd.get("navn", "uten navn")})'
for felt in ("navn", "innstromning", "kapasitet", "restanse"):
if felt not in ledd:
sys.exit(f'Feil i inndata, {hvor}: feltet "{felt}" mangler.')
if not isinstance(ledd["navn"], str):
sys.exit(f'Feil i inndata, {hvor}: "navn" må være tekst i anførselstegn.')
if "maal_tid" not in ledd and "restanse_maal" not in ledd:
sys.exit(f'Feil i inndata, {hvor}: oppgi "maal_tid" eller "restanse_maal".')
for felt in TALLFELT:
if felt not in ledd:
continue
verdi = ledd[felt]
vist = json.dumps(verdi, ensure_ascii=False)
try:
x = float(verdi)
except (TypeError, ValueError):
sys.exit(f'Feil i inndata, {hvor}: "{felt}" må være et tall '
f"(med punktum som desimaltegn), ikke {vist}.")
if not 0 <= x < float("inf"): # fanger også NaN
sys.exit(f'Feil i inndata, {hvor}: "{felt}" må være 0 eller et positivt, endelig tall, ikke {vist}.')
if float(ledd["kapasitet"]) - float(ledd.get("skjult_avgang", 0.0)) <= 0:
sys.exit(f"Feil i inndata, {hvor}: kapasiteten må være større enn 0, "
"også etter at skjult_avgang er trukket fra.")
if float(ledd.get("maks_andel_utenom", 0.0)) >= 1:
sys.exit(f'Feil i inndata, {hvor}: "maks_andel_utenom" er en andel og må være mindre enn 1 '
"(0.2 betyr 20 %).")
def analyser(ledd):
F = float(ledd["innstromning"])
skjult = float(ledd.get("skjult_avgang", 0.0))
Z_tot = float(ledd["kapasitet"]) # all avgang, også den skjulte
Z = Z_tot - skjult # avgang med behandling
Q = float(ledd["restanse"])
har_restansemaal = "restanse_maal" in ledd
maal_tid = float(ledd["maal_tid"]) if "maal_tid" in ledd and not har_restansemaal else None
Q_ref = float(ledd["restanse_maal"]) if har_restansemaal else maal_tid * Z_tot
ups = F / Z
# beholdning / avgangsrate; bare meningsfull når restansen er hele køen
tid = None if har_restansemaal else Q / Z_tot
if "restanse_for_12_mnd" in ledd:
vekst = (Q - float(ledd["restanse_for_12_mnd"])) / 12.0
vekst_kilde = "observert siste 12 mnd"
else:
vekst = max(F - Z_tot, 0.0) # det som ikke avsluttes, legger seg i kø
vekst_kilde = "innstrømning minus kapasitet"
return dict(navn=ledd["navn"], F=F, Z=Z, Q=Q, Q_ref=Q_ref, maal_tid=maal_tid, ups=ups, tid=tid,
vekst=vekst, vekst_kilde=vekst_kilde, skjult=skjult,
Fp=float(ledd.get("prognose_innstromning", F)),
har_prognose="prognose_innstromning" in ledd,
andel=float(ledd.get("maks_andel_utenom", 0.0)))
def tiltak(a):
"""Trinn (d) og (e) for det bindende leddet."""
ut = []
xi_min = 1.0 - a["andel"]
Q_exc = max(a["Q"] - a["Q_ref"], 0.0) # restanse over målet
ut.append("1. Utnytt leddet: fjern tapt tid i skranken (ikke regnet her).")
if a["andel"] > 0:
ups_u = xi_min * a["Fp"] / a["Z"]
ut.append(f"2. Led saker utenom: med {tall(a['andel'] * 100, 0)} % til forenklede reaksjoner "
f"blir utnyttelsen {tall(ups_u)} (fra {tall(a['Fp'] / a['Z'])} "
+ ("med prognosen)." if a["har_prognose"] else "med innstrømningen nå)."))
if ups_u <= UPS_HI and Q_exc == 0:
ut.append(" Det rekker: kapasiteten trenger ikke utvides.")
return ut
else:
ut.append("2. Led saker utenom: ikke oppgitt at noen andel kan ledes utenom dette leddet.")
behov = (xi_min * a["Fp"] + Q_exc / T_CLEAR) / UPS_STAR
if behov * UPS_STAR > UPS_HI * a["Z"]: # som sim.py: bestill når behovet før marginen er over kapasiteten
okning = behov / a["Z"] - 1.0
grunnlag = (f"prognosen ({tall(a['Fp'], 1)} saker/mnd)" if a["har_prognose"]
else f"innstrømningen nå ({tall(a['Fp'], 1)} saker/mnd; ingen prognose oppgitt)")
ut.append(f"3. Utvid: dimensjoner etter {grunnlag}, ikke etter køen. "
f"Restanse over målet: {tall(Q_exc, 0)} saker, avviklet over {T_CLEAR:.0f} mnd.")
ut.append(f" Kapasitet: ({tall(xi_min)} x {tall(a['Fp'], 1)} + {tall(Q_exc, 0)}/{T_CLEAR:.0f}) / "
f"{tall(UPS_STAR)} = {tall(behov, 1)} saker/mnd, det vil si {tall(okning * 100, 0)} % mer"
+ (f" enn kapasiteten etter skjult avgang ({tall(a['Z'], 1)} saker/mnd)."
if a["skjult"] > 0 else "."))
ut.append(f" Bestilt kapasitet virker gradvis fra {L1:.0f} til {L2:.0f} mnd etter vedtak. "
"Trekk fra kapasitet som er bevilget, men ikke virker ennå."
+ (" Når restansen er avviklet, går andelen utenom tilbake, og behovet vurderes på nytt."
if a["andel"] > 0 else ""))
else:
ut.append("3. Utvid: ikke nødvendig med disse tallene.")
return ut
def les(fil):
try:
with open(fil, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
sys.exit(f"Fant ikke fila {fil}.")
except UnicodeDecodeError:
sys.exit(f"Fila {fil} er ikke lagret som UTF-8.")
except json.JSONDecodeError as e:
sys.exit(f"Fila {fil} er ikke gyldig JSON: feil på linje {e.lineno}, kolonne {e.colno}.")
except OSError:
sys.exit(f"Kunne ikke lese {fil}. Er det en mappe, eller mangler du lesetilgang?")
sjekk(data)
return data
def main(fil):
rader = [analyser(ledd) for ledd in les(fil)["ledd"]]
w = max(22, max(len(a["navn"]) for a in rader) + 2)
print("Trinn (c): utnyttelse og restanse i hvert ledd\n")
print(f"{'Ledd':<{w}}{'Utnyttelse':>11}{'Saksbeh.tid':>13}{'Mål':>6}{'Restansevekst/mnd':>19}")
for a in rader:
maal = tall(a['maal_tid'], 1) if a['maal_tid'] is not None else "-"
tid = tall(a['tid'], 1) if a['tid'] is not None else "-"
print(f"{a['navn']:<{w}}{tall(a['ups']):>11}{tid:>13}{maal:>6}"
f"{tall(a['vekst'], 0):>19}")
if any(a["tid"] is not None for a in rader):
print("(Saksbeh.tid er restanse delt på kapasitet, ikke målt saksbehandlingstid.)")
# skranken: høyest utnyttelse, deretter raskest voksende restanse relativt til kapasiteten
b = max(rader, key=lambda a: (round(a["ups"], 2), a["vekst"] / a["Z"]))
print()
if b["ups"] <= UPS_HI and b["vekst"] <= 0:
vokser = [a["navn"] for a in rader if a["vekst"] > 0]
if vokser:
print(f"Ingen ledd er over full utnyttelse, men restansen vokser i: {', '.join(vokser)}. "
"Kontroller tallene for innstrømning og kapasitet der.")
else:
print(f"Ingen ledd binder: høyeste utnyttelse er {tall(b['ups'])} ({b['navn']}), "
"og ingen restanse vokser.")
return
print(f"Skranken som binder: {b['navn']} (utnyttelse {tall(b['ups'])}, restanse {tall(b['Q'], 0)} "
f"mot mål {tall(b['Q_ref'], 0)}; vekst: {b['vekst_kilde']}).")
like = [a["navn"] for a in rader if a is not b and round(a["ups"], 2) == round(b["ups"], 2)]
if like:
print(f"Samme utnyttelse på to desimaler: {', '.join(like)}. Verktøyet velger da leddet der "
"restansen vokser raskest i forhold til kapasiteten. Forskjellen er for liten til å skille leddene.")
andre = [f"{a['navn']} ({tall(a['ups'])})" for a in rader
if a is not b and (a["ups"] > UPS_HI or a["vekst"] > 0)]
if andre:
print(f"Også over full utnyttelse eller med voksende restanse: {', '.join(andre)}. "
"Skranken kan flytte seg dit når den første er løst.")
plass = next(i for i, a in enumerate(rader) if a is b)
nedstroms = [a["navn"] for a in rader[plass + 1:]]
if nedstroms:
print(f"Merk: i den grad {', '.join(nedstroms)} får sakene sine gjennom skranken, viser tallene der "
"bare det som slipper gjennom. Når den løses, kan innstrømningen dit øke.")
print("\nTrinn (d)-(e): tiltak etter Theory of Constraints, i denne rekkefølgen\n")
for linje in tiltak(b):
print(linje)
print()
if plass > 0:
print("Å bremse leddene foran skranken til dens takt er ikke et tiltak: "
"i modellens domstolseksempel gir det det dårligste utfallet for kriminaliteten.")
print("Kontroller så de rettsstatlige skrankene (trinn f): det er sakstyper, ikke prosentandeler, som flyttes.")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("Bruk: python3 diagnose.py <inndata.json>")
main(sys.argv[1])