Independent education · Sources and limitations documented
Portfolio / Smart NOA simulation

Software simulation

Source snapshot · 5 November 2026

simulator/engine/plan-safety.js

// Plan-level safety checks (final-check fixes, 2026-11). Pure; no DOM; deterministic.
// EDUCATIONAL SIMULATION — fictional patients — not a medical device, not clinical advice.
//
// computeLockouts(): the SAME lockout set the simulation enforces (drug-library LOCK rules + the
//   Python controller's configured free-text lockouts, config.yaml). sim.js uses it at case start.
// maintenanceHypnoticGap(): a GENERIC check over plan steps × lockouts. When a lockout removes the
//   only maintenance hypnotic from a GA plan (e.g. a volatile locked for an MH-susceptible patient,
//   propofol locked for a propofol allergy), the plan editor and the guided "Approve & start
//   induction" gate show a blocking warning and the full demonstration refuses to start. It never
//   changes the plan.
// tivaMaintenanceSteps(): the optional one-click "Add propofol TIVA maintenance" — the propofol
//   maintenance regimen filled by recommend() through buildPlan() (no number of its own), applied
//   only when the clinician presses the button (the caller logs it).

import { resolveLibrary, getDrug, listDrugs } from './library.js';
import { evaluateContraindications } from './dosing.js';
import { pythonLockouts, PY_TO_SIM_ID } from './interlocks.js';
import { buildPlan } from '../data/scenarios.js';
import { TIVA_RATIONALE } from '../data/templates.js';

/** Drug classes that can carry GA maintenance hypnosis on their own in this simulator (N2O, dexmedetomidine, ketamine do not). */
export const MAINTENANCE_HYPNOTIC_CLASSES = Object.freeze(['volatile', 'hypnotic']);
const MAINT_PHASES = ['induction', 'maintenance'];

/**
 * computeLockouts(patientDerived, library) -> { lockouts: { [drugId]: [reason…] }, pyLocks: [Python drug names] }
 * Library LOCK contraindications + Python free-text lockouts (case-insensitive substring match on ids/labels).
 */
export function computeLockouts(p, library = null) {
  const lib = resolveLibrary(library);
  const lockouts = {};
  for (const d of listDrugs(lib)) {
    const locks = evaluateContraindications(p, d.id, lib).filter((c) => c.severity === 'lock').map((c) => c.message);
    if (locks.length) lockouts[d.id] = locks;
  }
  const labelOf = (list, id) => { const x = (lib[list] || []).find((e) => e.id === id); return x ? x.label : ''; };
  const freeText = (ids, list) => (ids || []).flatMap((id) => [id.replace(/_/g, ' '), labelOf(list, id)]).filter(Boolean);
  const pyLocks = pythonLockouts({ egfr: p.egfr, allergies: freeText(p.allergies, 'ALLERGIES'), comorbidities: freeText(p.conditions, 'CONDITIONS') });
  const pySrc = (lib.SOURCE_IDS_ENGINE && lib.SOURCE_IDS_ENGINE.pythonConfig) || null;
  for (const py of pyLocks) {
    const id = PY_TO_SIM_ID[py];
    if (!getDrug(lib, id)) continue;
    (lockouts[id] = lockouts[id] || []).push(`Python controller configured lockout (config.yaml${pySrc ? ` [${pySrc}]` : ''}; case-insensitive free-text match)`);
  }
  return { lockouts, pyLocks };
}

/** Is this (plan or removed) step a GA maintenance hypnotic: a running infusion / vaporizer setting of a hypnotic class, started in induction or maintenance? */
export function isMaintenanceHypnoticStep(lib, s) {
  if (!s || s.invalid || !MAINT_PHASES.includes(s.phase) || !['infusion', 'inhaled'].includes(s.kind)) return false;
  const d = getDrug(lib, s.drugId);
  return !!d && MAINTENANCE_HYPNOTIC_CLASSES.includes(d.cls);
}

/**
 * maintenanceHypnoticGap(plan, patientDerived, { library, lockouts })
 *  plan.steps                — steps still in the plan (a locked one stays listed but is blocked at run time)
 *  plan.lockoutRemoved       — steps the plan builder dropped because the drug is locked (buildPlan)
 * -> { blocking, message, locked:[{ drugId, name, reason }], usable:[drugId], tivaAvailable }
 * blocking = GA plan, at least one maintenance hypnotic was planned but every one is locked out.
 */
export function maintenanceHypnoticGap(plan, patient, { library = null, lockouts = null } = {}) {
  const lib = resolveLibrary(library);
  const L = lockouts || computeLockouts(patient, lib).lockouts;
  const none = { blocking: false, message: null, locked: [], usable: [], tivaAvailable: false };
  if (!plan || plan.mode === 'MAC') return none;
  const present = (plan.steps || []).filter((s) => isMaintenanceHypnoticStep(lib, s));
  const removed = (plan.lockoutRemoved || []).filter((s) => isMaintenanceHypnoticStep(lib, s));
  const usable = [...new Set(present.filter((s) => !L[s.drugId]).map((s) => s.drugId))];
  const locked = [];
  for (const s of [...present.filter((x) => L[x.drugId]), ...removed]) {
    if (locked.some((x) => x.drugId === s.drugId)) continue;
    const d = getDrug(lib, s.drugId);
    locked.push({ drugId: s.drugId, name: d ? d.name : s.drugId, reason: (L[s.drugId] || [s.reason || 'locked for this patient']).join('; ') });
  }
  const tivaAvailable = !L.propofol && !!getDrug(lib, 'propofol');
  if (usable.length || !locked.length) return { ...none, locked, usable, tivaAvailable };
  const what = locked.map((x) => `${x.name} is contraindicated (${x.reason.replace(/[.\s]+$/, '')})`).join('; ');
  return {
    blocking: true, locked, usable, tivaAvailable,
    message: `No maintenance hypnotic after lockout: ${what}. Add an alternative (e.g. propofol infusion — TIVA) before approving.`,
  };
}

/**
 * Engine-plan steps for "Add propofol TIVA maintenance": the propofol maintenance regimen at maintenance
 * +0:00 (and its loading dose, if the regimen has one), stopped at the start of emergence. Every value comes
 * from recommend() via buildPlan(). -> { steps, skipped }
 */
export function tivaMaintenanceSteps(patient, library = null) {
  const lib = resolveLibrary(library);
  const { plan, skipped } = buildPlan({ mode: 'GA', sedationTarget: null, steps: [
    { phase: 'maintenance', atSec: 0, kind: 'infusion', drugId: 'propofol', regimenKey: 'maintenance', timingRationale: TIVA_RATIONALE.start },
    { phase: 'emergence', atSec: 0, kind: 'stop', drugId: 'propofol', timingRationale: TIVA_RATIONALE.stop },
  ] }, patient, lib);
  const started = plan.steps.some((s) => s.kind === 'infusion');
  return { steps: started ? plan.steps.map((s) => ({ ...s, addedBy: 'clinician_tiva' })) : [], skipped };
}
Source and scope

Published from Smart-NOA-Controller/simulator/engine/plan-safety.js at revision 6e5b205e3843. Full source text is retained; HTML presentation does not establish independent clinical review. In-memory simulation with no patient, monitor or pump interface; not clinically validated.