import { createHash } from 'node:crypto'; import { INGREDIENTS, PRODUCTS } from './catalog.js'; import type { CheckInput, CheckResult } from './engine.js'; import { classesFor } from './normalize.js'; import { ruleContentHash, stableJson, type CheckModules, type RuleSection } from './rule-hash.js'; import { ALLERGY_RULES, CHILD_RULES, INTERACTION_RULES, LAB_RULES, MONITORING_RULES, OLDER_ADULT_RULES } from './rules.js'; import { APPROVED_CONTENT, CLINICAL_SIGN_OFFS, ENGINE_APPROVED_CONTENT, ENGINE_CODED_RULES, engineCodedRuleFor, type EngineCodedRule } from './signoff.js'; import { CHECK_SOURCES } from './sources.js'; import { PLAUSIBLE_RESULTS } from '../labs/targets.js'; /** * CHK-7 (signedOffOnly): a table rule runs only when `approved` with the current hash an accepted sign-off recorded (the * recipe of tests/clinical-signoff.test.ts); an engine-coded rule only when ENGINE_APPROVED_CONTENT has its hash (none * yet). Hashes are worked out once per process. */ export type RuleReview = 'signed_off' | 'draft' | 'changed_since_sign_off' | 'not_in_accepted_sign_off' | 'not_yet_reviewed' | 'unknown_rule'; const TABLES: Array<[RuleSection, ReadonlyArray<{ id: string; review: string }>]> = [['interactions', INTERACTION_RULES], ['allergies', ALLERGY_RULES], ['older', OLDER_ADULT_RULES], ['children', CHILD_RULES], ['monitoring', MONITORING_RULES], ['labs', LAB_RULES]]; type RunCheck = (input: CheckInput) => CheckResult; const sha256 = (text: string) => createHash('sha256').update(text).digest('hex'); /** Fixed cases for each engine-coded rule's content hash: its wording in these cases is what a sign-off would approve. */ const ENGINE_RULE_CASES: Record & { medicines: CheckInput['medicines'] }>> = (() => { const med = (name: string, extra: Partial = {}): CheckInput['medicines'][number] => ({ id: name.toLowerCase().replace(/\W+/g, '-'), name, strength: null, form: 'tablet', timesPerDay: 1, unitsPerDose: 1, prnMaxPerDay: null, origin: 'prescribed', contentsKnown: true, ...extra }); return { 'dose-max': [{ medicines: [med('Ibuprofen', { strength: '400 mg', timesPerDay: 8 })] }, { medicines: [med('Co-codamol', { strength: '30/500', unitsPerDose: 2, timesPerDay: 6 })] }], duplicate: [{ medicines: [med('Panadol', { strength: '500 mg', unitsPerDose: 2, timesPerDay: 4 }), med('Paracetamol', { strength: '500 mg', unitsPerDose: 2, timesPerDay: 4 })] }, { medicines: [med('Brufen', { strength: '400 mg', timesPerDay: 3 }), med('Nurofen', { strength: '200 mg', timesPerDay: 3 })] }], 'paracetamol-lower-maximum': [{ medicines: [med('Paracetamol', { strength: '500 mg', unitsPerDose: 2, timesPerDay: 4 })], subject: { kind: 'person', name: 'Amina', sex: 'female', age: 80 } }, { medicines: [med('Paracetamol', { strength: '500 mg', unitsPerDose: 2, timesPerDay: 4 })], subject: { kind: 'person', name: 'Amina', sex: 'female', age: 45, weightKg: 45 } }], 'allergy-ingredient': [{ medicines: [med('Metformin', { strength: '500 mg', timesPerDay: 2 })], allergies: ['Metformin'] }], 'kidney-dose-metformin': [], }; })(); function engineRuleHash(rule: EngineCodedRule, runCheck: RunCheck): string { const wording = (ENGINE_RULE_CASES[rule.id] ?? []).map((example) => runCheck({ subject: { kind: 'person', name: 'Amina', sex: 'female', age: 45 }, allergies: [], conditions: [], labs: [], labTargets: {}, symptoms: null, emergency: { number: '112', extra: 'or go to the nearest hospital emergency unit' }, language: 'en', today: '2026-10-13', ...example, }).findings.filter((finding) => engineCodedRuleFor(finding.ruleId) === rule)); return sha256(stableJson({ id: rule.id, what: rule.what, wording })); } let cache: { tables: Map; engine: Map } | null = null; /** Every rule's current content hash, by rule id. */ export function currentRuleHashes(runCheck: RunCheck): { tables: Map; engine: Map } { if (cache) return cache; const modules: CheckModules = { runCheck, INGREDIENTS, PRODUCTS, CHECK_SOURCES: CHECK_SOURCES as CheckModules['CHECK_SOURCES'], classesFor: (key, context) => classesFor(key, context), PLAUSIBLE_RESULTS }; const tables = new Map(); for (const [section, table] of TABLES) for (const rule of table) tables.set(rule.id, ruleContentHash(section, rule as Parameters[1], modules)); const engine = new Map(ENGINE_CODED_RULES.map((rule) => [rule.id, engineRuleHash(rule, runCheck)])); cache = { tables, engine }; return cache; } const TABLE_RULES = new Map(TABLES.flatMap(([, table]) => table.map((rule) => [rule.id, rule] as const))); /** The sign-offs a mode accepts: every recorded one for `true`. */ export function acceptedSignOffs(mode: true | { acceptedSignOffs: readonly string[] }): string[] { return mode === true ? CLINICAL_SIGN_OFFS.map((signOff) => signOff.id) : [...mode.acceptedSignOffs]; } /** A rule's review status against the accepted sign-offs. */ export function ruleReview(ruleId: string, accepted: readonly string[], runCheck: RunCheck): RuleReview { const table = TABLE_RULES.get(ruleId); const hashes = currentRuleHashes(runCheck); if (table) { if (table.review !== 'approved') return 'draft'; const current = hashes.tables.get(ruleId); const approvedBy = CLINICAL_SIGN_OFFS.map((signOff) => APPROVED_CONTENT[signOff.id]?.[ruleId]).filter(Boolean); if (!approvedBy.includes(current!)) return 'changed_since_sign_off'; return accepted.some((id) => APPROVED_CONTENT[id]?.[ruleId] === current) ? 'signed_off' : 'not_in_accepted_sign_off'; } const engineRule = engineCodedRuleFor(ruleId); if (!engineRule) return 'unknown_rule'; const current = hashes.engine.get(engineRule.id); return accepted.some((id) => ENGINE_APPROVED_CONTENT[id]?.[engineRule.id] === current) ? 'signed_off' : 'not_yet_reviewed'; }