marmar-context / src / domain / checks / signed-off-mode.ts
signed-off-mode.ts
Raw
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<string, Array<Partial<CheckInput> & { medicines: CheckInput['medicines'] }>> = (() => {
  const med = (name: string, extra: Partial<CheckInput['medicines'][number]> = {}): 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<string, string>; engine: Map<string, string> } | null = null;

/** Every rule's current content hash, by rule id. */
export function currentRuleHashes(runCheck: RunCheck): { tables: Map<string, string>; engine: Map<string, string> } {
  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<string, string>();
  for (const [section, table] of TABLES) for (const rule of table) tables.set(rule.id, ruleContentHash(section, rule as Parameters<typeof ruleContentHash>[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';
}