2026-11 audit: findings, fixes and tests
SIMULATION — fictional patients — educational use only. Not a medical device. Not medical advice. Physiology and PD values changed here are illustrative author estimates (listed in
MODEL_CARD.mdand inSOURCES.md→ "Model assumptions"). Passing tests checks the code; it does not validate the simulator.
Two independent audits (clinical pharmacology; correctness and security) of branch autorun-2026-11. Label and guideline statements were re-checked by web search on 2026-11-05. Direct page fetches were not available (DNS blocked), so "verified" means a web-search record of the label or guideline text. The record for each source is in verifiedVia (data/drugs.js) and in SOURCES.md.
Test files: tests/audit-2026-11.test.mjs (JS, "AT"), tests/ui.smoke.mjs (browser, "UI"), test_safety_hardening.py::AuditNovember2026 (Python, "PY"). Other suites were updated where their expectations changed.
Clinical findings
| # | Finding | Fix | Test |
|---|---|---|---|
| C1 | Midazolam: no ≥60 y / debilitated initial-dose cap | Adjustment age>=60|asa>=3: fixed 0–1.5 mg (default 1 mg), "over no less than 2 minutes; wait ≥2 min; >3.5 mg not usually necessary" (S17, re-verified). ASA ≥ 3 stands in for "debilitated or chronically ill" (simulator mapping). All regimens are now given over ≥ 2 min. The 78-y example recommends 1 mg (range max 1.5). |
AT "clinical 1" |
| C2 | Desflurane not locked for halogenated-agent sensitivity | LOCK allergy:halogenated_volatile, label wording (S36, re-verified). The allergy label now names desflurane. |
AT "clinical 2/4/19" |
| C3 | Phenylephrine 200 mcg read as "per bolus" | The 2019 label reads "not to exceed a total dosage of 200 mcg". The conservative reading is applied: scope: 'cumulative boluses'. All boluses in the session together are ≤ 200 mcg; infusions are not counted. Per-bolus wording on the current US label was not verified. |
AT "clinical 3"; sim.test, dosing.test |
| C4 | Ketorolac: missing contraindications | LOCK for new conditions bleeding_risk (label wording on cerebrovascular bleeding, haemorrhagic diathesis, incomplete haemostasis, high bleeding risk) and peptic_ulcer_gi_bleed. Runtime and plan-time WARN: "contraindicated as a prophylactic analgesic before any major surgery" before incision, and the WARNINGS text "peri-operative use should be avoided … when hemostasis is critical" during surgery. Hypovolaemia was already locked. CABG and labour are out of scope. Probenecid, pentoxifylline and "history of" PUD were not re-verified (see SOURCES "Not included"). |
AT "clinical 2/4/19", "clinical 4" |
| C5 | Sugammadex reversal flat across doses | 1:1 molar binding with label molecular weights: rocuronium bromide 609.70; sugammadex sodium 2178.01, where 100 mg sugammadex = 108.8 mg salt. Slow free-sugammadex elimination (illustrative). Effect-site speed-up now depends on the free-sugammadex : rocuronium molar excess (illustrative). A dose below the label tier (T2 → 2 mg/kg; TOF 0–1 → 4 mg/kg; ≤3 min after ≥1.2 mg/kg → 16 mg/kg) gives a warning in the log and feedback, plus a medium alarm. Results: tier-appropriate doses reach TOFR 0.9 in 85–112 s; 2 mg/kg at deep block takes 203 s; 2 mg/kg after 1.2 mg/kg is incomplete. | AT "clinical 5" |
| C6 | Dexmedetomidine sedation read as "general" | Hypnotic weight 0.55 → 0.35 (illustrative). Labelled procedural doses give depth ≈ 70–73 (moderate). Bradycardia is kept. | AT "clinical 6" |
| C7 | Anaphylaxis taught as IM-only; IM too effective at MAP ≈ 28 | Added rescue_iv: 50 micrograms IV, titrated and repeated, then infusion (AAGBI 2009 = S44; RCUK perioperative algorithm = S45; both verified). It is a guideline dose, not an FDA label dose, and IV boluses stop at 0.05 mg (policy). Route selector added to the Run screen; plan editor offers the regimen. The teaching point now gives the IV titration with sources. IM/SC absorption slows when MAP is far below baseline (factor 1 → 0.1; illustrative): MAP recovery after 0.5 mg IM now takes ≈ 5 min, down from ≈ 2–3 min. |
AT "clinical 7"; models.test |
| C8 | EtCO2 shown during apnoea | vitals.etco2 = null with no breaths and no support; internal PaCO2 still rises; the tile shows "— (no breaths)". |
sim.test apnoea test; AT |
| C9 | Dexmedetomidine hepatic note only on one regimen | Hepatic-impairment adjustment on every regimen. | AT "clinical 9-12" |
| C10 | Fentanyl elderly reduction only a note | Label PRECAUTIONS wording ("appropriately reduced in elderly and debilitated patients") is now a plan-time warning. The label gives no figure, so the default stays at 2 mcg/kg and the warning says to reduce it. | AT "clinical 9-12" |
| C11 | Acetaminophen <50 kg absolute caps missing | 750 mg per dose and 3750 mg/day (lesser of these and 15 mg/kg, 75 mg/kg/day), verified. Also a 1000 mg per-dose cap for ≥50 kg. | AT "clinical 9-12" |
| C12 | Eleveld concomitant switch included IV lidocaine | Switch is now opioids only, as published. S42 text and log updated. | AT "clinical 9-12" |
| C13 | N2O alone reached general anaesthesia | The N2O MAC fraction counts 0.5 toward hypnosis (illustrative). 70% N2O alone gives depth ≈ 77. | AT "clinical 13" |
| C14 | Ketamine airway obstruction like propofol | Only 30% of ketamine hypnosis counts toward obstruction (illustrative). Obese OSA example: minimum RR 17, versus apnoea with propofol. | AT "clinical 14" |
| C15 | Epinephrine high-rate response plateaued | Added a high-EC50 component: response is monotonic, with MAP ≈ +40% at 0.5 and +54% at 1 mcg/kg/min (illustrative). | AT "clinical 15" |
| C16 | Sedation-target gate had no hold time | SEDATION_TARGET_HOLD_SEC = 10 s (autorun-config, simulator convention). |
AT "clinical 16"; autorun.test |
| C17 | Midazolam 1 mg too strong | Hypnotic EC50 0.6 → 1.0 (illustrative). 1 mg → minimal; 2.5 mg → moderate. | AT "clinical 17" |
| C18 | Python docstring said "closed-loop" | Reworded: "rule-checking loop over synthetic values (educational; not a controller)". | ruff/review |
| C19 | Ketamine HTN and lidocaine heart block shown as WARN | LOCK with label CONTRAINDICATIONS wording (lidocaine WPW also LOCK). SPEC §4 updated. | AT; drugs.test |
Code findings
| # | Finding | Fix | Test |
|---|---|---|---|
| K1 | Spurious hold at exactly the Python ceiling | convertRateSafe returns the value itself for the same unit and rounds other conversions to 12 significant figures. Admission (sim.js), the per-step check (InterlockCore.rateFaults) and the plan editor/Run form (exceedsInterlockCeiling) all compare in the Python unit with RATE_CEILING_EPS = 1e-9. Python _rate_faults uses RATE_CEILING_TOLERANCE = 1e-9. Pre-filled ceilings never round above the ceiling. |
AT "code 1" (weight sweep, sim stepped; constant parity with Python); PY tolerance tests |
| K2 | Gate double-click approved two gates | Click approves only autoGate.dataset.gate (the id that was rendered). Button disabled while processing plus a 600 ms cooldown. |
UI "double-click approves only the gate that was shown" |
| K3 | Stale extubation dialog; clock ran while open | Sim pauses while the dialog is open and resumes on close. Readiness is re-evaluated on confirm; if it changed, the dialog is redrawn and nothing is done. The override records the unmet list current at that moment. | UI "extubation dialog pauses the clock…" |
| K4 | Prototype names accepted as units | Object.hasOwn on a null-prototype table. Non-finite converted amounts and rates are rejected. |
AT "code 4" |
| K5 | Plan normalisation trusted raw values | Defaults are written after the spread. Non-numeric or negative atSec marks the step invalid (logged, never runs). Ids are unique strings. |
AT "code 5" |
| K6 | Citation and pre-fill clicks ended automatic mode | .src-tag buttons and drug-row Bolus/Adjust (data-auto-safe="prefill") are exempt; Give still ends it. |
UI "reading a citation or pre-filling the form…" |
| K7 | parseClock rejected h:mm:ss |
Accepts h:mm:ss. Timeline ticks are positioned from numeric time. | AT "code 7" |
| K8 | "Give over" < 1 min silently pushed | Engine blocks 0 < over < 60 s with a reason. Run form and plan editor show an explicit error ("blank = push; ≥ 1 min"). | AT "code 8" |
| K9 | Malformed scripted reading crashed the loop | Python and the JS port treat a non-pair reading as unusable: hold latches, RED, all zero. | PY test_malformed_scripted_reading_fails_safe; AT "code 9" |
| K10 | Interlock event logging did not match its comment | Code now matches the comment: each code is recorded at most once per 60 s while it persists. | AT "code 10" |
| K11 | Test gaps | Parity sweep steps the sim. ui.smoke.mjs starts its own Node http server, fails if Playwright is missing unless SMOKE_SKIP=1, and treats console warnings as failures. Added npm run test:ui. New UI tests for K2, K3 and K6. |
— |
| K12 | Live store exposed on window |
window.__smartNoa exists only with ?test=1 and exposes only snapshot() (deep copy). |
UI (uses the hook) |
| Lint | ruff/pyflakes/mypy/bandit; eslint 16 findings | pyproject.toml (ruff E,W,F,B,UP,I,SIM,C4,S; S101 allowed in test_*.py; S311 and bandit B311 justified for the demo's fictional random readings). example_walkthrough.py uses explicit checks instead of assert, and its output is unchanged. PEP 585, import order, zip(strict=True). simulator/eslint.config.mjs added, with npm run lint and dev dependencies only (simulator/.gitignore ignores node_modules). |
all tools report zero |
Deliberately not changed
- Code finding 13 (CSP/frame-ancestors as HTTP headers). These need the hosting server's configuration. A static repository cannot set response headers, and
frame-ancestorsis ignored in a meta tag. The meta CSP is unchanged. - Ketorolac rules for "prophylactic before major surgery" and "intraoperative haemostasis" stay as warnings, not locks. The simulator cannot know whether surgery is major or whether haemostasis is critical. Both show the label wording.
- Fentanyl elderly dose. The label gives no reduced figure, so none was invented. The warning asks the user to reduce the dose.
- Lidocaine WPW was also made a LOCK, because the S28 label lists it among the contraindications.
- MAC obese-OSA example. After the midazolam recalibration it holds "moderate" only briefly before the procedure starts, and is then mostly "minimal". The plan doses are label lower bounds, so this outcome comes from the illustrative model and is not a dosing change.
EXAMPLE_RESULTS.json. The Python fixes do not change any example output. The regenerated output is byte-identical.
Additions (owner-approved, November 2026)
Implemented from the owner-approved additions specification (sections A–F) on top of the audited branch. Every new dose and statement was re-checked by web search on 2026-11-05 (page fetches still failed DNS); the record is in verifiedVia (data/drugs.js) and in SOURCES.md → "2026-11 additions (S46–S51)". Items that could not be verified are listed in SOURCES.md → "Not included, and why". Every new value is in the parameter inventory and MODEL_CARD.md (components prophylaxis, pretreatment, fluids, template; regenerated with tools/model-card-tables.mjs).
Test files: tests/additions-2026-11.test.mjs (JS, "AD"), tests/ui.smoke.mjs (browser, "UI"). tests/drugs.test.mjs now accepts the pretreatment regimen key.
| # | Addition | Implementation | Tests |
|---|---|---|---|
| A | Lidocaine pretreatment before propofol | lidocaine.regimens.pretreatment = 10 mg (propofol label S11: "prior injection of IV lidocaine (1 mL of a 1% solution)"). Offered in the induction picker and in the GA templates just before propofol. Counts toward cumulative lidocaine (summary().lidocaine: mg, mg/kg IBW, pretreatment mg). Foo 2021 overlap (S1, ~4 h, simulator mapping): pretreatment and an IV lidocaine analgesic dose within 4 h → LOW alarm lidocaine_overlap + log/plan warning. Teaching note: label pH 7–8.5, so the pain is not from an acidic solution; generally attributed to aqueous-phase propofol (mechanism unsourced; Jalota 2011 = S51 for effectiveness). Injection pain is not modelled numerically. |
AD A1–A4; UI "technique templates" |
| B | IV fluids | FLUIDS (lactated Ringer's S47, 0.9% NaCl S48: composition, label cautions; no fixed dose). Actions fluid_bolus:<ml>:<fluidId>, fluid_maintenance:<fluidId>:<mL/h>, fluid_maintenance_stop; plan steps fluid / fluid_bolus; plan editor "Fluids" row per phase; Run-screen fluids panel with totals by type; debrief totals by type. Default 100 mL/h and the 1000 mL/h input limit are simulator conventions. Same illustrative volume model for both types (maintenance adds to retained volume at its rate). SMART (S50) shown as an evidence note. |
AD B1–B5; UI "fluids panel" |
| C | Antibiotic prophylaxis | ANTIBIOTICS (Bratzler 2013 = S46): cefazolin 2 g / 3 g if ≥ 120 kg, start ≤ 60 min before incision, redose 4 h; clindamycin 900 mg (≤ 60 min, redose 6 h); vancomycin 15 mg/kg (≤ 120 min, no redose). New allergy beta_lactam_severe locks cefazolin; selection falls to clindamycin, then vancomycin (engine/prophylaxis.js). MEDIUM alarms "Surgery started without antibiotic prophylaxis in the recommended window" and "Prophylaxis redose due" (logged). Guided mode: checklist warning before Start surgery that the clinician must acknowledge (acknowledge_checklist:antibiotic); full demonstration pauses for it; neither acknowledges it. Example GA plans now include guideline prophylaxis (agent chosen at runtime). Timing only, no PK. |
AD C1–C6; UI "antibiotic checklist in guided mode" |
| D | Technique templates | data/templates.js (no doses) + engine/templates.js buildTemplatePlan() (doses from recommend() via buildPlan()): GA opioid-based; GA opioid-free (POFA note S49; lidocaine pretreatment omitted when an IV lidocaine infusion is planned); MAC dexmedetomidine (label procedural dosing, new sedation_minimal regimen with the same label values, minimal/moderate/deep, optional midazolam/propofol, "beyond deep = GA template", interlock-ceiling note); MAC propofol. Locked primary drugs use the template fallback (sevoflurane → propofol infusion for MH). Plan screen "Start from a technique template" selector with optional-step checkboxes; steps stay editable. |
AD D1–D5; UI "technique templates" |
| E | Guided / full-auto integration | Fluid, antibiotic and pretreatment steps run from the plan scheduler at their offsets through the same sim actions; maintenance fluids continue across phases; antibiotic checklist is a gate warning before Start surgery. All four templates complete in guided and full modes for all four example patients (runner never acknowledges alarms/checklists or releases holds). | AD E1–E2 |
| F | Quality bar | Node tests for every rule above; browser smoke cases for templates, fluids and the antibiotic checklist; zero ESLint findings; zero console warnings/errors; existing tests unchanged in intent. | all suites |
Deviations from the request (each recorded in SOURCES.md): clindamycin uses the guideline 60-min window (the request's "60–120 min" applies to vancomycin only, and only as "within 120 min"); no label wording was found for "dexmedetomidine alone may not achieve deep sedation", so the note states only that the label studies permitted rescue midazolam; POFA PONV figures are not shown (direction only).
Advisory haemodynamic rescue and guided/automatic fixes (owner-approved, November 2026)
| Item | Implementation | Provenance / sources | Tests |
|---|---|---|---|
| Hypotension advisory | engine/rescue.js evaluateRescue() on MAP < ALARM_THRESHOLDS.MAP_LOW (reused, strict "<"); agent by HR (< 60 → ephedrine, else phenylephrine); dose = drug-library rescue regimen default via recommend(), capped by the regimen max, per-bolus and cumulative label ceilings |
selection rule: simulator convention; doses: FDA labels S18, S19 | R1–R3, R5 |
| Repeat interval | Suppressed with "Reassess — next dose allowed in mm:ss" from the last vasopressor bolus of any source: phenylephrine 60 s (label "every 1–2 minutes", lower bound), ephedrine 120 s and other vasopressors 120 s (no label interval — simulator convention) | S18; convention | R4 |
| Cumulative maximum | Phenylephrine 200 mcg all boluses (conservative 2019-label reading), ephedrine 50 mg; below the regimen minimum → "label maximum reached — consider other causes / escalate; not simulated" | S18, S19 | R5 |
| High MAP | MAP > MAP_HIGH: text only (depth/analgesia, sensor); no drug or dose (no verified dosing source) |
convention | R6 |
| Suppression | MAP/HR sensor fault; anaphylaxis scenario active (epinephrine stays manual; scenario guidance S44/S45); epinephrine never suggested | convention | R7 |
| Clinician approval only | "Give suggested bolus" → advisor.accept() → sim.administer(..., {source:'advisory'}), logged ADVISORY accepted; "Dismiss" logged; never accepted in manual/guided/full; never acknowledges alarms or releases holds; card is data-auto-safe (does not end guided mode) |
owner requirement | R8, R9, R10, UI |
| Record | summary().advisories (shown/accepted/dismissed with times) in the debrief and session JSON |
— | R8, UI |
| Config / inventory | engine/rescue-config.js RESCUE_CONFIG (16 entries) → parameter inventory component rescue and MODEL_CARD "Advisory haemodynamic rescue" |
as listed | R11 |
| Bug: stale guided/auto panel on self-pause | Runner events now re-render the panel immediately (also after the stepping loop); paused banner with reason + exact action (pauseAction(), status().pauseDetails); Resume enabled and focused; assertive announcement |
— | G1, UI "self-pause" |
| Bug: MAC guided cannot finish (obese OSA) | Sedation-below-target clinician item: Titrate (one step within the label range; sedationTitration(), AUTORUN_CONFIG.SEDATION_TITRATION_*) or Acknowledge and start at current level (sim.action('acknowledge_sedation_below_target'), periop.sedationAck); full demonstration pauses after SEDATION_BELOW_TARGET_PAUSE_SEC and never chooses; once met, the gate hold completes even past its time limit |
S11, S17, S32 (anchor); step size and intervals: convention | G2–G4, E1 (updated) |
| Example-plan "Why this time" | exampleTimingRationales(): matching template step's rationale, else "Example plan timing (simulator convention)" |
as template | G5, UI |
Final-check fixes (owner-approved, November 2026)
Found by ~1,300 automated runs against the live build. Tests: tests/final-check-2026-11.test.mjs (Node) and two blocks of tests/ui.smoke.mjs.
| Item | Implementation | Provenance / sources | Tests |
|---|---|---|---|
| Lockout leaves a GA plan without a maintenance hypnotic (moderate: MH patient + sevoflurane plan ran with the index rising 38 → 85 and no alarm) | engine/plan-safety.js: computeLockouts() (the engine's own lockout set, now also used by sim.js) and maintenanceHypnoticGap() — generic over plan steps (and steps buildPlan() dropped as locked, plan.lockoutRemoved) × lockouts; volatile/hypnotic classes in induction/maintenance. Blocking warning in the plan editor (planIssues, Continue to run disabled; deleting a locked volatile keeps the warning), guided Approve & start induction unmet item, full demonstration refuses to start; logged at case start. Optional clinician-pressed Add propofol TIVA maintenance (tivaMaintenanceSteps() → buildPlan() → recommend(); no number of its own; "Why this time" TIVA_RATIONALE; logged). Never modifies the plan by itself |
lockout rules: drug library (labels) + config.yaml; TIVA dose: propofol label regimen (S11) | F1a ×5, C, E, UI "MH patient + sevoflurane" |
| Depth-high (awareness-risk) alarm | sim.js _depthHighAlarm() / _depthHighArmed(): HIGH when the index > GA_DEPTH_BAND upper bound (60, reused) for DEPTH_HIGH_ALARM_SUSTAIN_SEC (60 s); clears at ≤ DEPTH_HIGH_ALARM_CLEAR (58), then latched until acknowledged. Armed: GA, airway secured, loss of consciousness reached, induction/maintenance phase, not "agents off" after the end of surgery. Engine alarm in every mode; guided/full pause via the existing HIGH rule; Run-screen depth tile shows it |
band S52/S53; sustain, hysteresis, priority: simulator convention | F1b ×5 (incl. 160-run sweep: zero spurious alarms) |
| "NaN" in the sensor-fault message | interlocks.js readingText() → "unusable"; simulator alarm texts likewise. Display only — decisions and Python parity unchanged (tests/parity.test.mjs compares outcomes, not messages) |
— | F2, UI |
| Stale numbers on faulted tiles | HR, BP and SpO2 tiles show "—" with aria-label "no valid reading (sensor fault)" while faulted; restored when the fault clears; trend charts keep a gap; debrief adds the MAP range; all vital min/max skip fault gaps |
— | F, UI "sensor fault" |
| Sustained MAP < 60 re-paused guided/automatic mode every second | Continue with hold latched (this hold only): clinician button in the paused panel (runner.clinicianAction('continue_with_hold')), logged, never a default; covers only the current hold episode (sim.state.interlocks.holdEpisode: held drugs + triggers + latch time); a new trigger/held drug, a re-latch after recovery (MAP ≥ 62 / HR ≥ 50, alarm clearing hysteresis reused) or a new HIGH alarm pauses again; does not release the hold or acknowledge alarms; ends when the hold is released |
simulator convention, owner-approved (AUTORUN_CONFIG.CONTINUE_WITH_HOLD_OPTION) |
A1–A3 |
Finding while testing the reported case: after the clinician adds TIVA, the MH patient on the healthy-adult GA example completes the automatic demonstration with no depth-high alarm and the index never above 60, but with the label-default propofol 100 mcg/kg/min plus the plan's fentanyl and midazolam it sits in 40–60 for only 26–43 % of maintenance/surgery time (seeds 1–20); the rest is 34–39 (below the band, the same illustrative-model behaviour documented for 1 MAC of a volatile). No dose was changed.
Depth-low advisory, text only (owner-approved, November 2026)
Background: the illustrative depth model reads ~34–39 at label-default maintenance doses (finding above; the healthy sevoflurane GA example spends ~68 % of maintenance below 40). The owner chose a text-only advisory over recalibrating the model. No dose, model parameter or example plan was changed. Tests: tests/depth-advisory-2026-11.test.mjs (DA1–DA9) and two blocks of tests/ui.smoke.mjs.
| Item | Implementation | Provenance / sources | Tests |
|---|---|---|---|
| Advisory condition | engine/depth-advisory.js depthLowArmed() + DepthLowAdvisor.update() (after every sim.step, and — since 05l — immediately after every phase change, action and drug change, so it disarms on the same simulated second): index < GA_DEPTH_BAND lower bound (40, reused) for ≥ DEPTH_LOW_ADVISORY_SUSTAIN_SEC (300 s since 05l; 120 s in 05k); any sample ≥ 40 restarts the count; ends at ≥ DEPTH_LOW_ADVISORY_CLEAR (42) or when disarmed. Armed: GA, airway secured, loss of consciousness reached (the depth-high alarm's marker), maintenance phase after incision, not "agents off" after the end of surgery; never in MAC/sedation, pre-LOC or emergence |
band S52/S53; sustain and hysteresis: simulator convention (TIMING_EVIDENCE row 65) | DA1, DA3–DA5 |
| Text only, not an alarm | Headline, 4-line checklist and footnote with no drug, dose, rate or percentage, no outcome claim. Adds no alarm, never pauses guided/full mode, acknowledges nothing, changes no dose/rate; only "Dismiss" (logged ADVISORY dismissed … DEPTH-n) |
owner requirement | DA2, DA6, DA7 |
| Pre-incision gate unchanged | Before incision the pre-incision item (acknowledge an index below the band) remains the gate; the advisory arms only after surgeryStartAt |
— | DA9, UI |
| UI | ui/advisory.js depthAdvisoryCard on the Run screen (#dadv, role=region, headline aria-live=polite) and in the guided/automatic panel (#auto-dadv, not live); data-auto-safe (Dismiss does not end guided mode). With the MAP < 65 card both are shown and each names the other (#adv-cross, #dadv-cross). Debrief #db-depth-advisories: shown/dismissed with times, total time below the band, time outside the band |
— | UI ×2 (incl. 390 px) |
| No effect on runs | Example plans × guided/full × seeds 1–20 with and without the advisor: identical completion outcome, dose log, final amounts/rates, alarm history, pauses and runner history. Frequency: ga_healthy full 20/20 runs (21 cards), ga_elderly_cad_ckd full 20/20 (23), tiva_mh full 14/20 (14), MAC 0; guided 0/20 for every plan (the test driver approves each guided gate as soon as it is enabled, so simulated surgery lasts seconds) | — | DA7, DA8 |
| Config / inventory | TIMING_CONFIG.DEPTH_LOW_ADVISORY_SUSTAIN_SEC, DEPTH_LOW_ADVISORY_CLEAR → parameter inventory component timing, MODEL_CARD timing table and section "Advisory: depth index below the GA band"; summary.depthAdvisories in the session JSON |
simulator convention | DA1 |
Palliative pain management and end-of-life care modules (owner-approved, November 2026)
Built from the owner-approved palliative specification on top of the timing-review branch (ececfc9). New files: data/palliative.js (sources S100–S120, opioid label starting doses, rules, sedation framework values, ethics panel, paraphrased conversation topics, six fictional cases), engine/palliative.js (pure, deterministic, minute-step symptom/PK-PD model; author estimates in MODEL), ui/palliative.js (module screens), tests/palliative.test.mjs (Node), a palliative block in tests/ui.smoke.mjs (browser). Sources were verified by WebSearch on 2026-11-05 (WebFetch blocked by DNS for every publisher/NCBI/CDC/DailyMed host); each entry's verifiedVia says what was and was not seen; unverifiable items are in SOURCES.md → "Palliative modules — not simulated, and why". Every value is in MODEL_CARD.md → "Palliative and end-of-life modules" (78 rows, regenerated by tools/model-card-tables.mjs).
| Area | Implementation | Sources |
|---|---|---|
| Module chooser, banner, ethics panel | Header buttons Perioperative / Palliative pain management / End-of-life care. Palliative screens show the verbatim banner and an "Ethics, law & communication" button on every screen (definitions incl. proportionate vs continuous deep sedation, euthanasia/MAID distinction, intent, proportionality, double effect, capacity, surrogates, advance directives, interdisciplinary review, documentation, hydration, jurisdiction, CDC scope). The perioperative simulator is unchanged (all earlier tests pass). | S100–S105 |
| Opioid starting doses (opioid-naïve) | Oral morphine 10–20 mg q4h; IV morphine 0.1–0.2 mg/kg q4h; oral oxycodone 5–15 mg q4–6h; oral hydromorphone 2–4 mg q4–6h; IV hydromorphone 0.2–1 mg q2–3h. Hydromorphone ×½ (eGFR 30–59 / hepatic) or ×¼ (eGFR <30 / severe hepatic) — label "one-fourth to one-half", degree mapping = simulator mapping. Morphine eGFR <30 and oxycodone eGFR <60: acknowledged warnings. SC routes not simulated. | S110–S115, S107 |
| Titration, breakthrough, rotation | Daily review gate (24 h, convention): +50% suggestion (NCCN 50–100%) when mean pain ≥4 and not improved; breakthrough 10% (10–20%) of the 24-h dose; PRN ≥60 min oral / ≥15 min IV. Rotation: oral morphine→oxycodone 1.5:1, →hydromorphone 5:1, oral:IV morphine 2:1–3:1; cross-tolerance reduction entered by the clinician (0–50%, no default: no panel consensus). Other conversions: "specialist conversion; not simulated". Nothing changes without approval. | S108, S107, S109 |
| Non-cancer (CDC 2022) | Scope stated (excludes cancer, palliative, end-of-life care); MME/day as risk estimate only; approving an increase that crosses 50 MME/day requires the Rec. 4 "pause and reassess" acknowledgement; naloxone-offer, PDMP and acute-pain-quantity notes. | S105 |
| Bowel regimen, naloxone, opioid safety | LOW alarm + review item when a scheduled opioid has no bowel regimen; naloxone 0.1 mg IV increments ≥2 min apart, titrated to RR, pain-crisis/withdrawal alarm; HIGH alarms for RR <8 and unintended RASS ≤ −3 pause the run and are never auto-acknowledged. | S107, S116, S14, S120 |
| End-of-life symptoms | Dyspnoea (fan, positioning, oxygen only if hypoxaemic; opioids), secretions (evidence note; no dose), delirium (reversible causes first; no doses), comfort-focused monitoring teaching toggle (convention), hydration as a separate documented decision. | S117–S119, S101 |
| Palliative sedation | Gate requires all six documentation items (refractory symptom + treatments + reason, goals-of-care conversation, consent/assent incl. depth, interdisciplinary input, prognosis, jurisdiction acknowledgement). Clinician approves every action: target RASS + symptom; midazolam light 2.5 mg SC / 1.25 mg IV, deep 5–10 mg SC / 2.5–5 mg IV (deep targets only with prognosis hours–days), repeat half bolus after 20 min SC / 5 min IV, infusion starts 1 mg/h, >10 mg/h blocked (second-line agents not simulated). Proportionality alarm (deeper than target ≥30 min), comfort-not-achieved alarm; the engine never escalates; the comfort opioid continues and "increase opioid to sedate" is blocked. Debrief: checklist, proportionality timeline, titration table. | S101, S100, S102, S117, S120 |
| Hard exclusions | No content, doses or guidance for MAID/euthanasia or hastening death; no "lethal"; no survival/life-span/efficacy claims — enforced by a text-scan test over the palliative files and the SOURCES.md section. | — |
| Quality | Free text (family notes, documentation, adverse effects) only as input values/textContent; CSP unchanged (no inline scripts/handlers/styles); labels on every input; aria-live announcements; 390 px without horizontal scroll (browser test); zero console warnings. ui/sources.js source-tag parser now accepts three-digit ids. |
— |
Timing review (November 2026)
Owner requests: clinical timing fixes, a visible timing rationale per step, state-gated progression ("pharmacology waits for pharmacology"), a plan timing validator, a pre-incision verification gate, the processed-EEG index relabel with a sourced GA band, and deterministic browser tests. Sources were re-checked by web search on 2026-11-05 (page fetches failed DNS); what each source says is recorded in data/drugs.js (supports, verifiedVia) and SOURCES.md → "2026-11 timing review" and "Not included, and why". Every timing threshold is in engine/timing.js TIMING_CONFIG (parameter inventory component timing; template offsets: component template_timing; MODEL_CARD.md → "Timing rules and template timing").
| # | Item | Old → new | Source / convention |
|---|---|---|---|
| 1 | mac_dex: dexmedetomidine maintenance | loading (1 mcg/kg over 10 min) and maintenance both at sedation +0:00 → loading +0:00, maintenance +10:00 (anchored to the end of the loading actually given) | Label: "Initiate at one mcg/kg over 10 minutes, followed by a maintenance infusion initiated at 0.6 mcg/kg/hour" (S9) |
| 2 | mac_propofol (and the MAC example plan): propofol maintenance | initiation (0.5 mg/kg over 5 min) and 25 mcg/kg/min both at +2:00 → initiation +2:00, maintenance +7:00 | Label: MAC initiation over 3–5 min, then maintenance rates for the first 10–15 min of maintenance (S11) |
| 3 | Any loading + maintenance (buildPlan, plan editor) | maintenance at the loading start → at loading start + loading duration; the plan editor and the Timing check warn when a user overlaps them; a cross-phase pair is anchored with afterLoadingOf (opioid-free lidocaine infusion) |
S9, S11, S1 |
| 4 | ga_opioid: fentanyl → propofol | propofol +1:00 → +3:00 (lidocaine pretreatment +2:50, rocuronium +4:00); same spacing in the GA example plans | Simulator convention (3 min) anchored to the fentanyl label: maximal effect "may not be noted for several minutes" (S13); rocuronium after the propofol label onset of 40 s (S11) |
| 5 | ga_opioid_free: rocuronium | +1:30 → +2:00 (60 s after propofol) | Label onset (S11); spacing = convention |
| 6 | ga_opioid_free: dexmedetomidine stop | emergence +0:00 → 15 min before the planned end of surgery (maintenance-phase stop with beforeEndSec; full demonstration: planned end − 15 min; guided/manual: at End surgery; emergence at the latest) |
Simulator convention: no label/guideline/verified trial interval (protocols seen: 10–30 min or at the end; POFA primary text not confirmed) |
| 7 | ga_opioid_free: lidocaine and ketamine stops | unchanged (emergence +0:00), now labelled | Foo 2021 does not say "stopped at end of surgery" (≤ 24 h with review, S1); ketamine consensus gives no stop time (S10) → simulator convention (the simulation ends at emergence) |
| 8 | Premedication midazolam | at premed +0:00, induction any time after → given in pre-op holding; Transfer to OR ≥ 5 min after the bolus ends; OR setup 2 min; preoxygenation 3 min; induction ≥ 10 min after the premedication ends (countdown, gate disabled with the reason); plan induction/sedation drugs never start while a premedication bolus is pending or running (engine guard, all modes) | 5 min: clinical practice convention (owner) with label support (midazolam sedation within 3–5 min, S17); OR setup and preoxygenation: simulator conventions |
| 9 | Start surgery | automatic 2 min after maintenance once depth ≤ 60 (SURGERY_START_MIN_SEC, SURGERY_DEPTH_MAX removed) → pre-incision verification gate in guided and full modes: airway + ventilation; processed-EEG index in the GA band 40–60 (above 60 blocks; below 40 needs the clinician's acknowledgement); analgesia per plan; TOF count ≤ 2 if an NMB is planned; antibiotic in window (required; acknowledgement no longer suffices in guided/full); WHO time out confirmed by the clinician. Full demonstration waits for state items and pauses for clinician items; it never confirms them |
GA band: B-Unaware (S52), BAG-RECALL (S53); WHO time out (S54); antibiotic window (S46); TOF ≤ 2: convention |
| 10 | MAC Start procedure | automatic once the target held → gate with the target hold, antibiotic (if indicated) and the WHO time out | S31, S46, S54 |
| 11 | Rocuronium top-up | given at its offset → held until TOF count ≥ 3 (status "waiting" with the reason); manual top-up warned and logged | Label: "administered at 25% recovery of control T1 (defined as 3 twitches of train-of-four)" (S12) |
| 12 | Repeat midazolam | at its offset → held until 4 min after the previous dose | Label: give over ≥ 2 min, wait ≥ 2 more min (S17) |
| 13 | Depth number | "simulated depth index (0–100, illustrative; not BIS)" → "Processed-EEG depth index (BIS-style, simulated)"; GA band 40–60 on the tile, the trend chart and in the pre-incision gate; time outside the band during surgery in the Timing check | S52, S53; sedation bands and agent caveats not shown (not verified) |
| 14 | Timing check | new: engine/timing.js plan validator (premed → induction, loading → maintenance, opioid → hypnotic, pretreatment, hypnotic → NMB, antibiotic window/redose, PONV times, reversal after agents off with TOF tier, repeat-bolus intervals, stop-before-end anchors) and live checks; shown on the plan screen, in the guided/automatic panel and in the debrief (and in the session JSON, summary.timingChecks) |
each check shows its source or "simulator convention" |
| 15 | Browser tests | 60× wall-clock waits → step-driven: ?test=1 hook testClock(true) stops wall-clock advance; advance(sec) steps simulated time synchronously with the runner ticking; every wait is a condition on state/DOM (one test checks the real play loop with explicit conditions) |
— |
Consequences recorded honestly:
- Model calibration vs the GA band. In this illustrative model 1.0 × age-adjusted MAC of sevoflurane gives an index of about 35–42 during maintenance, often below 40. The model was not recalibrated; the gate asks the clinician to acknowledge an index below the band and the Timing check reports the time outside it. No alarm was added.
- mac_propofol at the label lower-bound rate. With the corrected sequence (no loading/maintenance overlap) the 27-y MH example patient stays at minimal sedation at 25 mcg/kg/min, so the moderate target is not reached: the full demonstration stops and reports it, guided mode keeps waiting (test E1 records this case). No dose was changed.
- 1-Hz stepping. A plan step due at a phase start runs in that phase's first simulated second; live timing checks allow 1 s (
STEP_TOLERANCE_SEC). - Tests updated for the new behaviour (intent kept): autorun gate lists and case-action sequences (new Transfer to OR, Start surgery and Start procedure gates; the clinician confirms the time out); C5/C6 (antibiotic now required in guided/full); E1 (clinician items; mac_propofol MH case above); A4 and the sim fixture GA run (propofol now at induction +3:00, so they run 2 min longer); audit "clinical 16" (pause for the time out). New:
tests/timing-2026-11.test.mjs(T1–T18) and browser cases for the Timing check panel, rationale display, gate countdown and the required antibiotic item.
Timing cross-check (November 2026)
After the timing review, every timed element (templates, example plans, guided/automatic gates, TIMING_CONFIG) was compared with FDA labels and guidelines; the table, the sources seen and a clinician-review checklist are in TIMING_EVIDENCE.md. Page fetches were refused by the session's egress proxy, so all statements come from WebSearch records (marked where only a summary was seen). Result: 21 agree, 8 partially, 25 have no published timing (kept as labelled conventions), 1 conflict fixed — mac_propofol and the MAC example plan started propofol at +2:00, when the midazolam injection ends; midazolam label (wait ≥ 2 more min to evaluate, S17) and ASA 2018 moderate sedation (allow time between doses to assess each, S32) → propofol now +4:00 (TIMING_CONFIG.MAC_SEDATIVE_AFTER_MIDAZOLAM_SEC, maintenance +9:00; row 2 above is superseded for the start time). New sources S55 (DAS 2015: preoxygenate to end-tidal O2 0.87–0.9) and S56 (ASA 2022 difficult airway), recorded in TIMING_CONFIG.PREOXYGENATION_END_POINT (documentation only; end-tidal O2 is not modelled). No clinician review has been recorded.
Depth-low advisory tuning (05l, owner-approved)
| Change | Detail |
|---|---|
| Same-step disarm | The advisor is re-evaluated (zero elapsed time) right after setPhase, action and administer, so the card disarms on the same simulated second as "Begin emergence" or agents off. In 05k it stayed up for one more simulated second (seen in 4 elderly full-auto runs). A zero-time re-evaluation cannot start or lengthen an episode (test DA-L2). Test DA-L checks ga_healthy and ga_elderly_cad_ckd, full-auto, seeds 1–20: never visible in emergence; the disarm and its log entry carry the emergence second. |
| Trigger lengthened | DEPTH_LOW_ADVISORY_SUSTAIN_SEC 120 → 300 s (simulator convention) to limit alert fatigue: at 120 s the card appeared in nearly every GA case because the illustrative model reads deep at label-default doses. 300 s still flags sustained deep readings. Clearing value (42) unchanged. |
Code audit, 05l
Five findings from a code audit of the 05l branch (owner-approved fixes; behaviour otherwise unchanged). New tests: tests/audit-05l.test.mjs (A05l-3a, -3b, -4, -5), DA-R in tests/depth-advisory-full-2026-11.test.mjs, and one browser case in tests/ui.smoke.mjs. DA7 is unchanged in content but split by mode across tests/depth-advisory-2026-11.test.mjs (guided) and tests/depth-advisory-full-2026-11.test.mjs (full). The shared driver is tests/depth-advisory-runs.mjs.
| # | Finding | Fix | Tests |
|---|---|---|---|
| 1 (low, safety) | The plan scheduler inside _substep called the public action() / administer(). Since 05l those also refresh the depth-low advisor, so the refresh ran mid-substep, after tSec had advanced but before the vitals had. It then counted dt = 1 s on the previous second's index (27 such updates across the 4 example plans; once a below-band run went from 117 to 118 s). This broke the "zero elapsed time" guarantee in the 05l tuning row above. |
(a) The scheduler calls the internal _actionImpl / _administerImpl, which do no refresh. (b) _refreshDepthAdvisor() calls update({ refreshOnly: true }). That path never accumulates time, never moves lastT and never starts an episode. It only ends an active episode that is now disarmed or has recovered. Time is counted only by the post-step update(). |
DA-R instruments every advisor update in 80 full-auto runs (4 example plans × seeds 1–20) and asserts: no update from inside _substep; no refresh changes lastT, time below the band or the entry count; no refresh lengthens a run or an episode (935 refresh calls, 2 episodes ended by a refresh). It compares each run with a step-only shadow advisor on the same run: card counts, time below the band, shown seconds and statuses are identical. The only allowed difference is a disarm that ends ≤ 1 s earlier. Counts: ga_healthy 20/20 runs (20 cards), ga_elderly_cad_ckd 20/20 (20), tiva_mh 3/20 (3), mac_obese_osa 0. These match the counts before the fix, and runs with and without the advisor are identical. DA-L (disarm on the emergence second) and DA-L2 still pass. Without fix (a) or (b), DA-R fails. |
| 2 (medium, safety/UX) | ui/monitor.js renderAlarms rebuilt the whole alarm list, Acknowledge buttons included, whenever any message's live reading changed. In a hemorrhage case this happened in 121 of 384 s, and nearly every 150 ms render above 1×. A click that straddled a rebuild was lost, so a HIGH alarm could fail to acknowledge. |
Keyed reconciliation. There is one persistent <li data-alarm=id> per alarm id. Texts update in place (setText), and items are added or removed only when the set of ids changes. Nodes move only when the priority/onset order changes. The Acknowledge button element persists while its alarm exists: only disabled, its label and aria-label are updated. Focus is kept (the element is never replaced, and it is refocused if a reorder moved it). |
UI "alarm list is reconciled by id": elderly example, hemorrhage + anaphylaxis hypotension, HIGH map_critical. The same element handle survives step-driven renders and then ≥ 3 text changes while playing at 20×, focus stays on it, and a click acknowledges it (logged once). The case fails on the old renderer ("same element after render 1"). |
| 3 (low, structure) | The critical MAP/HR alarm texts hard-coded "60" and "48". _hypnoticSeenRunning (read by the depth-low advisory's "agents off after surgery" rule) and the LOC marker _locAt were set as side effects of _depthHighArmed(), so they depended on evaluation order. |
Alarm texts read NOA_CONFIG.hemodynamic_thresholds. New _updateDepthArmingState(V) is the only writer of _locAt and _hypnoticSeenRunning. It is called once per substep before the alarms, with the same gating as before (shared helper _depthArmingPreconditions). _depthHighArmed() is now a pure read. The three arming conditions (depth-high alarm, depth-low advisory, depthOutOfBandSec statistic) were deliberately not merged into one. They differ on purpose: the alarm is armed in induction, the advisory only after incision in maintenance, and the statistic needs no LOC. A larger refactor was out of scope. |
A05l-3a (source has no literal threshold, and the hemorrhage-case messages read the config). A05l-3b (one writer each; on GA runs, evaluating the two arming checks in either order, repeatedly, gives the same answers and leaves the state unchanged). DA7 and DA-R show unchanged behaviour. |
| 4 (low, performance) | npm test ran every file in one process through tests/index.js (23.0 s; DA7 alone took 9.6 s). |
"test": "node --test tests/*.test.mjs" (the shell expands the glob on POSIX, so Node ≥ 18 works; Node ≥ 21 also expands it itself, e.g. on Windows). Node runs the files in parallel processes. tests/index.js is kept because SPEC §1 documents node --test simulator/tests, which needs it. DA7 is split by mode across two files that run in parallel, with the same assertions. Its with-advisor runs are cached and reused by DA-R, so finding 1's test adds no simulation runs. |
A05l-4: the script is the glob, every *.test.mjs on disk is covered, there are no stray test-like files, and index.js loads the same set. 269 tests (263 + 6). Wall time: 15.5–15.9 s (was 23.0 s). |
| 5 (info) | Interlocks evaluate before physiology in sim._substep (step 4 before step 9). A hold therefore latches one simulated second after the displayed reading crosses the threshold. |
Not changed: a known, accepted ordering. It mirrors the Python controller loop, which applies its rules to the reading available at the start of each one-second iteration (smart_noa_controller.py monitor_and_control). The simulator likewise gives the interlock the reading displayed at the end of the previous second, and the parity tests (tests/parity.test.mjs) rely on this. No drug is delivered during the lag: in the latching substep the held infusions are zeroed before PK (step 6) consumes them. A code comment at the interlock call records this. |
A05l-5: dexmedetomidine infusion, then hemorrhage + anaphylaxis hypotension. On the second MAP < 60 is displayed the hold is not yet latched. On the next second it latches, the rate is 0 and the cumulative dexmedetomidine is unchanged (no drug in the lag second). The source ordering is also checked. |
Finding 2, extended to every live UI list with controls (owner request). All of these now use the shared keyed reconciliation in ui/dom.js (reconcileKeyed, plus setChildrenIf for source-tag groups):
- Palliative alarms (
ui/palliative.js). The 250 ms timer rebuilt this list on every refresh while a non-HIGH alarm was shown, so its Acknowledge buttons were replaced each time. It now keeps one persistent<li>per alarm id and its button. - Palliative pause box. It keeps a persistent message and Resume button.
- Palliative daily review. It keeps one row per suggestion. A row is rebuilt only when its own status changes, so other rows' checkboxes, selects and buttons keep the user's input. The list was only rebuilt on user actions, because the timer stops at the review pause.
- Run-screen pre-incision checklist (
#auto-checklist-items) and extubation readiness (#mon-ready). Their live values (depth index, vitals) changed every render and rebuilt their source-tag buttons each time. Their rows are now keyed by item id. - Timing check (
#auto-timing,ui/timing-view.jstimingCheckItem/updateTimingCheckItem). Its rows are keyed by id plus occurrence, because ids repeat.
Checked and left as they are, because they rebuild only on a real change or hold no per-item controls:
- Advisory cards (
ui/advisory.js): the buttons persist; the suggestion and checklist rebuild only when the entry or drug changes. - Guided-panel clinician buttons: created once; only their hidden/disabled state and labels change.
- Drug table: rebuilt only when the set of drugs changes; cells are updated in place.
- Paused-reason list, unmet list, sequence, log, fluids totals, MAR, palliative tiles and EOL gate list: text only, no per-item controls.
- EOL sedation controls: persistent.
Tests:
- UI "palliative alarm list is reconciled by id": the same element and focus survive step-driven refreshes and the real 250 ms timer, and a click acknowledges. It fails on the old code ("same element after refresh 1").
- The perioperative alarm UI case also checks that a readiness source-tag button survives renders while the readiness values change.
Note: the frequency figures in the depth-low advisory table above (21/23/14 cards) were measured at the 05k 120 s trigger. At 300 s (05l), full-auto seeds 1–20 give ga_healthy 20/20 runs (20 cards), ga_elderly_cad_ckd 20/20 (20), tiva_mh 3/20 (3), MAC 0 (the DA7 diagnostic).