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<string, Record<string, unknown>>;
/** By test name: the medicines that need it and the rules that asked for it. */
monitoring: Map<string, { medicines: Medicine[]; ruleIds: string[] }>;
/** 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<string>;
}
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<string, SkippedRule>();
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(),
};
}