This is the working plan for interactive/. It records what the explorer is for, what each tab contains, which assumptions the reader can change, how each number is produced, and what has been decided. Update it whenever a stage lands or a decision changes; the change log is at the end.
The paper (“The Arithmetic of Suspicion”) and the Python model (../thirlwall_statistical_model.py, version 7, output in ../example_output.txt) remain the source of truth. The explorer recreates their numbers and lets the reader see how those numbers move when assumptions change. It never replaces them.
| Stage | Scope | Owner | Status |
|---|---|---|---|
| 1 | Figure 2 explorer: threshold, base rate, unit type | Codex, reviewed by Claude | Done (commits be0d1be, 8fb9b70, 7c11a99) |
| 2a | Python reference export: full-precision JSON of every per-unit-type result | Claude | Done: reference/reference.json, printed output verified identical |
| 2b | Five tabs following Tables 1–5, shared state, live tables, all “Ready” controls | Codex, reviewed | Done: reference arithmetic, five linked views, file-open bundle, 1400/390 px review screenshots and regression checks |
| 3a | Simulation layer: JavaScript port of the model, validated against the Python baseline; no new reader controls | Codex, reviewed | Done: 288/288 cells within gate, all medians and Tables 4–5 pass; file-open worker smoke passed |
| 3b | Simulation mode in the page: the five shortlisted controls | Codex, reviewed | Done: selective worker results, source labels, intervals, paper round trips, file-open browser checks and six review screenshots |
| 3c | Rule E recalibration when the world changes | Codex | Done — Stage 3 complete: six paper thresholds and independent crossing rates pass; changed-mortality rule E and file-open browser checks pass |
| 4 | Optional extensions (see section 9) | — | Not planned |
The paper’s tables are snapshots. Each rests on assumptions. The explorer turns each table into something the reader can take apart: change an assumption, watch the table rebuild, and follow the change through to the human cost.
reference/reference.json (produced by Stage 2a). For file:// use, build.mjs should inline it into the bundle as a constant rather than fetch it. Every value is regression-tested against example_output.txt at the printed precision.derive.js holds pure functions that turn state plus reference data into every displayed number (section 5). There is no DOM access, and everything is unit-tested.derive.js output only.sim/ holds a JavaScript port of the model, run in a Web Worker. It feeds the same derive.js functions, so the views do not change.build.mjs into app.js so the page works from file://. --check must pass.The scenario bar sits above the tabs and lists every current choice, for example “Rule C · own-exposure · +4 · base rate 1 in 10,000 · staff-proportional · q = 1 · 18 months”. Each item links to the tab where it is set.
Key for “Needs”:
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Number of units by type | 45 / 85 / 45 | 3 sliders, 0–150 | Ready (system totals scale; per-unit rates unchanged) |
| Expected deaths a year per unit | 20 / 4 / 1 | 3 sliders | Sim + calib |
| Nurses on each roster | 100 / 40 / 20 | 3 sliders | Sim (risk weights in Tab 4 are Ready) |
| Year-to-year variation in death rate | 25% | Slider, 0–50% | Sim + calib |
| Baseline for rules A–D | Previous 3 years’ average, floor 0.5 | — | Fixed |
| History; years monitored | 48 months; 10 years | — | Fixed |
Question: How many alarms does chance alone produce? Takeaway: Chance alone produces between 3 and 34 alarms a year in England and Wales, depending on the rule.
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Monitoring rule | A, B, C, D, E10, E50 | Six-way choice | Ready |
| Alarm line, rules A–C | 2 SD (97.7%); B uses 3 SD | Slider, 1.5–3.5 SD | Sim |
| How often checked | Yearly (A, B), monthly (C, D) | Switch | Sim |
| Rule D trigger | Deaths doubled, at least 4 | Sliders: factor 1.5–3, minimum 2–8 | Sim |
| Rule E false-alarm target | 1 in 10 / 1 in 50 unit-years | Slider over the 11 swept settings inside Tab 4’s Figure 2 section | Ready at the 11 points; otherwise Sim + calib |
| Rule E tuned to detect | A doubling | — | Fixed |
Graph: 175 dots grouped 45 / 85 / 45, with the expected number of alarming units in a typical year lit by type. Deterministic, labelled “expected count, a typical year”. Live table (Table 1): rows A–E50, with columns for what triggers the rule, alarm unit-years a year, episodes a year, and the share of unit-years alarmed in intensive care, local and special care units. The selected rule’s row is highlighted. Totals respond to unit counts. Working: alarm unit-years = Σ units_t × share alarmed_t.
Question: How often does a rota search after a chance alarm produce a “significant” nurse? Takeaway: About half of chance alarms produce a “significant” nurse under the own-exposure test, and about one in forty under the maximum-adjusted test.
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Rota test | Average-exposure, own-exposure, maximum-adjusted | Three-way choice | Ready |
| Significance level | 5% | Slider, 1–10% | Sim |
| Shift shares; staff mix | 13 / 21 / 27% of shifts; 35 / 50 / 15% of staff | Sliders | Sim |
| Mechanism: roster size × deaths reviewed | — | Two sliders | Ready (exact calculation; labelled “the mechanism”, not Table 2’s figure) |
| Attendance independent across nurses and deaths | Yes | — | Fixed |
| Minimum deaths to review | 2 | — | Fixed |
Graph: alarms a year, then “significant” nurses a year, as two linked bars. The test switch visibly shrinks the second bar. The mechanism panel plots the chance that someone on the roster looks significant against roster size. Live table (Table 2): for the selected rule, rows for intensive care, local, special care and all alarms weighted. Columns: deaths reviewed (median), top nurse attended (median), and the three tests’ shares. A footer row gives nurses flagged a year under the selected test. (The paper’s Table 2 shows rule C; the explorer generalises it to the selected rule and says so.) Working: flagged a year = Σ units_t × reviewed alarms_t × share passing_t.
Mechanism check values (independent attendance, the model’s shift mix, 5%, every nurse tested): roster 20 and 3 deaths gives 38%; 40 and 8 gives 49%; 100 and 28 gives 89%; 200 and 28 gives 99%.
Question: When an offender is present, how often does the unit alarm, and how often does the rota point to them? Takeaway: No rule does both: rules that alarm often point to the wrong nurse, and rules that point correctly rarely alarm.
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Offender’s extra deaths | +4 or +7 expected | Switch | Ready; continuous 1–15 is Sim |
| Unit type highlighted | Local | Switch | Ready |
| Offender’s shift share | 21% | Slider | Sim |
| Length of offending | 12 months | Slider, 3–24 | Sim |
| Present at every extra death; fair tie-break | Yes | — | Fixed |
Graph: detection by unit type for the selected rule, each bar marked with the background (no-offender) rate and with identification. Live table (Table 3): rows A–E50; columns for detection in intensive care, local and special care units at the selected effect, the local-unit identification rate, and the background rate. Working: detection includes alarms that would have happened anyway; the background column shows how much.
Question: For every offender-year detected, how many unit-years are falsely flagged? Takeaway: The base rate dominates: changing the rule moves the ratio about fourfold, while the rarity of offenders moves it by hundreds.
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Base rate | 0.1 and 1 (reference); 3, 10, 30 (stress tests) | Log slider 0.1–30, presets | Ready |
| Risk allocation | In proportion to staff; equal as a check | Switch | Ready |
| Tune the idealised chart (Figure 2) | 11 settings | The Stage 1 explorer, collapsed by default | Ready |
| Detection counts any alarm in an offender-year | Yes | — | Fixed (background shown in Tab 3) |
Graph: the ratio for each rule at the current base rate, as a bar chart on a log scale with the selected rule highlighted. Years between detections is shown as the secondary number. Live table (Table 4): rows A–E50; columns for the five printed base rates plus a “your rate” column. It responds to allocation and unit counts. Working: the formula in section 5, with the selected rule’s values substituted. Rule: never plot rules A–D on the Figure 2 axes. Figure 2 counts chart crossings, while Table 4 counts falsely flagged unit-years.
Question: What does this cost in people, and how likely is a flagged nurse to be the offender? Takeaway: q sets the volume of harm, not the odds: searching fewer alarms flags fewer innocent nurses and finds proportionally fewer offenders.
| Assumption | Paper value | Control | Needs |
|---|---|---|---|
| Share of alarms searched (q) | 1, 0.5, 0.25, 0.1 | Slider 0–1, presets | Ready |
| Investigation length | 18 months | Slider, 1–36 | Ready |
| When a nurse is flagged | Own-exposure test passes | Follows Tab 2 | Ready |
| Chance a flagged nurse is the offender | Own-exposure test | Display | Ready with the D1 export (exact, any base rate, +4 or +7); “not computed” for the maximum-adjusted test |
Graph: nurses flagged a year and nurses off the wards at any one time, against q. Live table (Table 5): rows A–E50; columns for q = 1, 0.5, 0.25, 0.1 plus a “your q” column, and off-ward and posterior columns for the current settings. Working: flagged = Tab 2 flags × q; off wards = flagged × months ÷ 12; posterior as in section 5.
Stage 2a added an optional --export PATH to the Python script. reference/reference.json was produced by the full default run (python thirlwall_statistical_model.py --export interactive/reference/reference.json). It contains, at full precision:
flag_off), and any-nurse flag rate (flag_any).The export uses no random numbers and writes values already computed. A full run with --export produced output identical to example_output.txt apart from the timing line.
JSON keys:
inputs: model inputs (unit types with n, mean and staff; cv; exposure shares and mix; offender_f; rules; base rates; q).weights: staff and equal risk weights by unit type.cusum_h: thresholds keyed "E10|NICU" and so on.chance[rule][type]: alpha (share of unit-years alarmed), ep (episodes per unit-year), rev (reviewed alarms per unit-year), s_avg, s_own and s_adj (test shares among reviewed alarms), flag_ep and flag_distinct (own-exposure flags per unit-year), med_k, med_top.detection["4"|"7"][rule][type]: d (detection), bg (background), flag_off (offender identified, per detection), flag_any (any nurse flagged, per detection).exact_count_ratio_p1: block 8.sweep: block 9, with per-type thresholds.Verification already done: the section 5 formulas applied to this file reproduce every line of blocks 4, 5 and 6 of example_output.txt character for character (30 of 30 lines).
derive.js)With unit counts n_t, staff s_t, base rate p (per unit-year, the slider value ÷ 10,000), and reference values from section 4:
risk weight w_t = s_t × Σn ÷ Σ(n × s) (or 1 if equal allocation)
false flagged/yr = Σ n_t (1 − p w_t) alpha_t
detected/yr = Σ n_t p w_t d_t (d_t at +4 or +7)
ratio = false flagged/yr ÷ detected/yr
years per detection = 1 ÷ detected/yr
alarm unit-years/yr = Σ n_t alpha_t
flags/yr (own-exp) = Σ n_t flag_ep_t
flags/yr (max-adj) = Σ n_t rev_t s_adj_t
flags/yr at q = flags/yr × q
off wards = flags/yr at q × months ÷ 12
posterior = Σ n_t p w_t d_t flag_off_t
÷ [Σ n_t p w_t d_t flag_any_t + Σ n_t (1 − p w_t) flag_ep_t]
These match false_true() and posterior() in the Python script. Note that q cancels out of the posterior, which is the point of Tab 5.
example_output.txt at the printed precision.Use the paper’s terms:
“Detection” always means the unit alarms in an offender-year. It never means the offender is identified.
reference/reference.json and the section 5 arithmetic. It is exact and test-guaranteed to match the paper. All “Ready” controls stay in paper mode.paper, derived, simulated), and the “How this number is made” panel names it.sim/ reimplements thirlwall_statistical_model.py sections 1–3 exactly as written, not approximately. The behaviour to reproduce (Python names in brackets):
sim_unit): one gamma draw per year, mean mean, shape K = 1/CV², scale 1/K; monthly mean = annual rate ÷ 12 for all 12 months of that year; deaths ~ Poisson(monthly mean); 48 months of history plus 120 monitored. With an offender, months 48–59 add Poisson(extra ÷ 12) offender deaths.ppf: the smallest k with CDF(k) ≥ q. The review window is that year.checks, CUSUM branch): S = max(0, S + deaths × ln 2 − true monthly mean); the window starts the month after S was last 0; alarm when S ≥ h; then S = 0. At paper mortality, h comes from cusum_h in the reference file. When mortality changes, Stage 3c recalibrates h from 50,000 null paths per affected type.argmax); the three tests as in MODEL.md at 5%; a nurse is flagged by the own-exposure test.With the paper’s inputs, the JavaScript simulation must agree with reference.json:
chance and detection (alpha, ep, rev, test shares, flag_ep, flag_distinct, d, bg, flag_off, flag_any): z = (JS − Python) ÷ √(SE_JS² + SE_Python²), using binomial or Poisson standard errors from each run’s own counts.| Pass: no | z | > 4, and no more than 1% of cells with | z | > 3. |
med_k, med_top) within 1.npm run validate-sim (slow; not part of the quick test suite) and writes a report, sim/validation-report.md, listing every cell with JS value, Python value and z. The report is committed.Exact equality with Python is impossible (different random generators) and is not a goal. The Python baseline is the guarantee; the simulation shows direction and size.
file://, create it from a Blob URL of inlined source, because a worker file cannot be loaded from file://.reference.json’s chance and detection, plus the counts behind each value. derive.js then works unchanged.| Control | Range | Simulation inputs changed | Tables affected |
|---|---|---|---|
| Expected deaths a year, by unit type | 0.5–60 | mean per type |
1–5 |
| Nurses on each roster, by type | 10–200 | staff per type; risk weights |
2–5 |
| Alarm line, rules A–C | 1.5–3.5 SD, converted to the quantile q = Φ(SD), except that 2 SD and 3 SD use the paper’s exact 0.977 and 0.9987 (Φ gives 0.97725 and 0.99865) | the quantile q | 1–5 for A–C |
| Significance level | 1–10% | the rota tests’ threshold | 2, 3 (identification), 5 |
| Offender’s extra deaths | 1–15 expected | extra |
3–5 |
Changing deaths a year triggers rule E recalibration for that unit type in the worker. The other rules update first; the rule E rows are dimmed until their new thresholds and simulated cells arrive.
Simulation mode can also compute what paper mode cannot: the chance that a flagged nurse is the offender under the maximum-adjusted test. Show it there, labelled simulated.
| # | Question | Options | Recommendation | Decided |
|---|---|---|---|---|
| D1 | Add the reference export to the Python script? | Yes / No | Yes | Yes, 2026-10-06; done |
| D2 | Stage 3 scope | All 12 Sim controls / a shortlist | Shortlist first | Shortlist, 2026-10-06: deaths a year, roster size, alarm line, significance level, offender’s extra deaths |
| D3 | Rule E when the world changes | Recalibrate in the browser (a few seconds) / grey out | Recalibrate | Recalibrate, 2026-10-06, with a progress indicator; grey out the 1-in-5,000 and 1-in-10,000 settings |
| D4 | Show paper values beside live values | Yes / No | Yes | Yes, 2026-10-06: small and muted, only where they differ |
| D5 | Where the explorer is hosted, and the paper link | Repo only / a public page | Decide before the repo goes public. | — |
build.mjs normalises CRLF on read and in --check, and the bundle-equality test does the same, so a Windows checkout passes; verified with every source converted to CRLF (41/41, check clean).screenshots/stage3c/.2026-10-07: Stage 3b enabled mortality, roster size, alarm line, significance and offender effect in one URL-backed state. Selective worker jobs update the visible scenario first, with progress, stale-value dimming, 95% intervals and paper comparisons. At exact +4 or +7 with the other simulation controls at their defaults, the page uses the Python paper values. Rule E is unavailable only where mortality changes, pending Stage 3c recalibration. Simulation mode computes the maximum-adjusted posterior from deterministic additional rota counts. The 288 Stage 3a validation cell values remain byte-identical to the committed report; all Stage 3b direction, source, interval and paper round-trip checks passed. Six desktop/phone review screenshots are Git-ignored under screenshots/stage3b/.
Browser timings from the project’s file:// page in Chrome at paper defaults, excluding screenshot time (milliseconds, input to first updated cell / full completion):
| Control change | First update | Complete |
|---|---|---|
| Local-unit expected deaths, 4 → 8/year | 449 | 1,968 |
| Local-unit roster, 40 → 80 nurses | 454 | 2,677 |
| Alarm line, 2 → 2.5 SD | 413 | 3,264 |
| Significance, 5% → 1% | 429 | 6,927 |
| Offender effect, +4 → +5 | 776 | 6,004 |
2026-10-07: Stage 3a engine added under sim/, leaving paper mode and reader controls unchanged. Seeded gamma, Poisson and binomial samplers have distribution checks; chance, rota and detection paths cover all six rules and return raw counts. A Blob worker runs from file://, prioritises the selected cell and reports progress. Full validation (2,000/10,000 units per cell) passed: 288 cells, largest |
z | 2.917, none over 3 or 4, all 36 medians within 1, and all 168 recalculated Table 4/5 values within combined simulation 95% intervals. Actual project run: 8.04 s in Node and 11.17 s in Chrome worker. See sim/validation-report.md. |
--export added to the Python script; reference/reference.json generated; output verified identical; Tables 4, 5 and posteriors recreated exactly from the export.