import { allergyTier, classifyReaction, detailFor, reactionOutput } from './allergy-detail.js'; import { clinicianTestWording, clinicianWording } from './clinician-wording.js'; import type { SkippedRule } from './clinician-types.js'; import { buildCoverage } from './coverage.js'; import { calculateEgfr } from './egfr.js'; import type { CheckInput, CheckResult } from './engine.js'; import { kidneyDoseHook } from './kidney-dose.js'; import { CLINICIAN_OPTIONS_VERSION, optionsInUse, type CheckOptions, type EngineHooks } from './options.js'; import { acceptedSignOffs, ruleReview } from './signed-off-mode.js'; import { normalizeText } from './normalize.js'; import { engineCodedRuleFor } from './signoff.js'; import { SPECIFIC_REFERENCES } from './sources.js'; /** * runCheck with options on (MARMAR Context only). The analysis gets hooks and records a trace; the result then gains * the options' additive fields. Nothing here runs for the app, which calls runCheck without options. */ type Medicine = { id: string | null; name: string }; /** What the analysis records when it has hooks (engine.ts analyse). */ export interface EngineTrace { skipped: Array<{ ruleId: string; medicines: Medicine[] }>; /** By finding id: what each finding was worked out from. */ facts: Map>; /** By test name: the medicines that need it and the rules that asked for it. */ monitoring: Map; /** Lab rule groups with a matching medicine but no result. */ missingLabs: Array<{ labCode: string; medicines: Medicine[] }>; /** The medicines that made a rule read the weight (the paracetamol maximum reads it when paracetamol is taken). */ weightReadBy: Medicine[]; /** Lab codes whose latest value a lab rule read. */ labsRead: Set; } type Analyse = (input: CheckInput, hooks?: EngineHooks, trace?: EngineTrace) => { result: CheckResult; held: string[] }; const SIGNED_OFF_PLAIN = 'Only rules whose current content a sign-off approved produced findings. Rules waiting for review are listed, not run.'; const CLINICIAN_PLAIN = 'Engine options for MARMAR Context, all drafts waiting for the clinical safety lead\'s sign-off.'; export function checkWithOptions(input: CheckInput, options: CheckOptions, analyse: Analyse): CheckResult { const defaultRun = (each: CheckInput) => analyse(each).result; const egfr = options.egfrFromCreatinine ? calculateEgfr(input) : null; const used: CheckInput = egfr?.lab ? { ...input, labs: [...input.labs, egfr.lab] } : input; const mode = options.signedOffOnly ? options.signedOffOnly : null; const accepted = acceptedSignOffs(mode ?? true); const hooks: EngineHooks = { calculatedLab: egfr?.lab ?? null, ...(mode ? { allow: (ruleId: string) => ruleReview(ruleId, accepted, defaultRun) === 'signed_off' } : {}), ...(options.allergyTiers ? { allergyTier: ({ allergy, matchKind, tier }) => allergyTier(matchKind, tier, classifyReaction(detailFor(allergy, input.allergyDetails))) } satisfies EngineHooks : {}), }; const trace: EngineTrace = { skipped: [], facts: new Map(), monitoring: new Map(), missingLabs: [], weightReadBy: [], labsRead: new Set() }; if (options.kidneyDoseLimits) { const kidney = kidneyDoseHook(used.labs, used.today, egfr?.lab ?? null); // signedOffOnly: the kidney-adjusted line is an engine-coded rule too; until it is signed off the usual line shows. const allowed = !mode || ruleReview('kidney-dose-metformin', accepted, defaultRun) === 'signed_off'; hooks.kidneyDose = allowed ? kidney : (context) => { if (kidney(context)) trace.skipped.push({ ruleId: 'kidney-dose-metformin', medicines: used.medicines.filter((medicine) => medicine.name === context.medicine) .map((medicine) => ({ id: medicine.id, name: medicine.name })) }); return null; }; } const { result } = analyse(used, hooks, trace); for (const finding of result.findings) { const facts = trace.facts.get(finding.id) ?? {}; if (options.clinicianWording) { const wording = clinicianWording(finding, facts, used); if (wording) Object.assign(finding, { clinician_title: wording.title, clinician_why: wording.why, clinician_wording_review: 'draft' as const }); } if (options.allergyTiers && finding.kind === 'allergy' && typeof facts.allergy === 'string') { finding.allergy_reaction = reactionOutput(facts.allergy, detailFor(facts.allergy, input.allergyDetails)); finding.tier_without_reaction = facts.ruleTier as CheckResult['findings'][number]['tier']; } if (options.specificReferences) { const keys = (facts.ingredientKeys as string[] | undefined) ?? null; finding.specific_references = (SPECIFIC_REFERENCES[finding.ruleId] ?? []) .filter((reference) => !reference.appliesTo || !keys || reference.appliesTo.some((key) => keys.includes(key))).map((reference) => ({ ...reference })); } if (options.coverage || mode) finding.rule_review = ruleReview(finding.ruleId, accepted, defaultRun); } for (const test of result.tests_to_ask_about) { const asked = trace.monitoring.get(test.test); if (options.monitoringMedicines && asked) Object.assign(test, { medicines: asked.medicines, rule_ids: asked.ruleIds }); if (options.clinicianWording && asked) { const wording = clinicianTestWording(asked.ruleIds[0], asked.medicines.map((medicine) => medicine.name)); if (wording) Object.assign(test, { clinician_title: wording.title, clinician_why: wording.why }); } } const skipped = skippedRules(trace, accepted, defaultRun); if (egfr) result.calculated_egfr = egfr.output; if (mode) result.signed_off_only = { accepted_sign_offs: accepted, skipped, plain: SIGNED_OFF_PLAIN }; if (options.coverage) { const ran = result.findings.filter((finding) => finding.rule_review && finding.rule_review !== 'signed_off') .map((finding) => ({ rule_id: finding.ruleId, kind: engineCodedRuleFor(finding.ruleId) ? 'engine' as const : 'table' as const, review: finding.rule_review!, medicines: finding.medicines, ran: true })); result.coverage = buildCoverage({ input: used, result, trace, egfr: egfr?.output ?? null, waiting: [...ran, ...skipped.map((rule) => ({ ...rule, ran: false }))] }); } result.clinician = { options_version: CLINICIAN_OPTIONS_VERSION, options: optionsInUse(options), wording_review: 'draft', plain: CLINICIAN_PLAIN }; return result; } /** Each skipped rule once per set of medicines, with why it was skipped. */ function skippedRules(trace: EngineTrace, accepted: readonly string[], run: (input: CheckInput) => CheckResult): SkippedRule[] { const seen = new Map(); for (const { ruleId, medicines } of trace.skipped) { const key = `${ruleId}|${medicines.map((medicine) => `${medicine.id}:${medicine.name}`).sort().join('+')}`; if (seen.has(key)) continue; seen.set(key, { rule_id: ruleId, kind: engineCodedRuleFor(ruleId) ? 'engine' : 'table', review: ruleReview(ruleId, accepted, run), medicines }); } return [...seen.values()]; } /** What the options add to checkFingerprint: the options, and the input fields only they read. */ export function fingerprintOptions(input: CheckInput, options: CheckOptions): unknown { return { v: CLINICIAN_OPTIONS_VERSION, o: optionsInUse(options), a: (input.allergyDetails ?? []).map((detail) => [normalizeText(detail.allergy), detail.reaction ?? null, detail.reactionKind ?? null, detail.onset ?? null, detail.severity ?? null, detail.year ?? null]).sort(), u: input.labs.map((lab) => [lab.code, lab.observedAt, lab.unit ?? lab.evidence?.result.unit ?? null]).sort(), }; }