marmar-context / src / domain / checks / coverage.ts
coverage.ts
Raw
import { findLabTest } from '../labs/catalog.js';
import type { CoverageOutput } from './clinician-types.js';
import type { EngineTrace } from './clinician-check.js';
import type { CalculatedEgfr } from './egfr.js';
import type { CheckInput, CheckResult } from './engine.js';
import { MONITORING_RULES } from './rules.js';

/**
 * CHK-3, CHK-9 (coverage): what the check did not check, for the end of every Context result and export. Products
 * with no rules, facts recorded that no rule read, facts missing that a rule here would read, rules waiting for
 * review, and check-in answers, which no rule reads. Says what happened; never what to do.
 */

const NOT_CHECKED = 'Recorded, not checked: no rules for this product yet.';
const PARTLY_CHECKED = 'Recorded, partly checked: some of what is in it has no rules yet.';
const NOT_READ = 'recorded, not read by any current rule.';
const CHECK_INS = 'Answers from check-ins are recorded as notes; no rule reads them.';
const PRODUCT_REASONS = new Set(['not_recognised', 'contents_unknown', 'no_rules', 'partly_recognised']);

const labName = (code: string) => findLabTest(code)?.name ?? code;
const names = (medicines: Array<{ name: string }>) => {
  const list = medicines.map((medicine) => medicine.name);
  return list.length <= 1 ? list.join('') : `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}`;
};

export function buildCoverage(context: { input: CheckInput; result: CheckResult; trace: EngineTrace; egfr: CalculatedEgfr | null;
  waiting: CoverageOutput['rules_waiting_for_review'] }): CoverageOutput {
  const { input, result, trace, egfr, waiting } = context;
  const products: CoverageOutput['products_not_checked'] = [];
  for (const item of result.what_we_couldnt_check) {
    if (!PRODUCT_REASONS.has(item.reason)) continue;
    if (products.some((known) => known.item === item.item && known.medicine_id === item.medicine_id)) continue;
    // A medicine partly recognised is also listed for its unruled ingredients: one line, the partly-checked one.
    const partly = result.what_we_couldnt_check.some((other) => other.item === item.item && other.medicine_id === item.medicine_id && other.reason === 'partly_recognised')
      || (item.reason === 'no_rules' && result.what_we_checked.medicines.includes(item.item));
    products.push({ item: item.item, medicine_id: item.medicine_id, reason: item.reason, plain: partly ? PARTLY_CHECKED : NOT_CHECKED });
  }

  const notRead: CoverageOutput['facts_not_read'] = [];
  const calculated = egfr?.status === 'calculated';
  if (input.subject.sex && !calculated) notRead.push({ fact: 'sex', item: 'Sex', plain: `Sex: ${NOT_READ}` });
  if (input.reproductive) {
    const item = input.reproductive === 'pregnant' ? 'Pregnancy' : input.reproductive === 'breastfeeding' ? 'Breastfeeding' : 'Planning a pregnancy';
    notRead.push({ fact: 'pregnancy', item, plain: `${item}: ${NOT_READ}` });
  }
  for (const condition of input.conditions) notRead.push({ fact: 'condition', item: condition, plain: `${condition}: ${NOT_READ}` });
  const weight = input.subject.weightKg;
  if (weight !== null && weight !== undefined && !trace.weightReadBy.length) {
    notRead.push({ fact: 'weight', item: 'Weight', plain: 'Weight: recorded, not read by any rule in this check (only the paracetamol maximum reads it).' });
  }
  const datesRead = new Set([...trace.monitoring.values()].flatMap((entry) => entry.ruleIds)
    .map((id) => MONITORING_RULES.find((rule) => rule.id === id)?.labCode).filter((code): code is string => Boolean(code)));
  for (const code of [...new Set(input.labs.map((lab) => lab.code))]) {
    if (trace.labsRead.has(code) || (code === 'creatinine' && calculated)) continue;
    const item = labName(code);
    notRead.push({ fact: 'lab', item, plain: datesRead.has(code)
      ? `${item}: its date is used for monitoring; its value is not read by any current rule.`
      : `${item}: ${NOT_READ}` });
  }

  const missing: CoverageOutput['facts_missing'] = [];
  const medicines = input.medicines.map((medicine) => ({ id: medicine.id, name: medicine.name }));
  if (input.medicines.length && input.subject.age === null && !input.subject.ageRange) {
    missing.push({ fact: 'age', plain: 'Age: needed for dose limits and for the older-adult and children\'s rules.', medicines });
  }
  if (trace.weightReadBy.length && (weight === null || weight === undefined)) {
    missing.push({ fact: 'weight', plain: 'Weight: needed for the paracetamol maximum under 50 kg.', medicines: trace.weightReadBy });
  }
  for (const gap of trace.missingLabs) {
    missing.push({ fact: gap.labCode, plain: `${labName(gap.labCode)}: needed with ${names(gap.medicines)}.`, medicines: gap.medicines });
  }
  if (egfr?.status === 'not_calculated') missing.push({ fact: 'egfr_calculation', plain: egfr.plain, medicines: [] });
  if (input.allergiesStatus === 'not_sure') missing.push({ fact: 'allergies', plain: 'Allergies: not confirmed.', medicines: [] });

  const lines = [
    ...products.map((product) => `${product.item}: ${product.plain.charAt(0).toLowerCase()}${product.plain.slice(1)}`),
    ...notRead.map((fact) => fact.plain),
    ...missing.map((fact) => fact.plain),
    ...(waiting.length ? [`Rules waiting for review: ${[...new Set(waiting.map((rule) => rule.rule_id))].join(', ')}.`] : []),
    CHECK_INS,
  ];
  return { products_not_checked: products, facts_not_read: notRead, facts_missing: missing, rules_waiting_for_review: waiting,
    check_ins: { read: false, plain: CHECK_INS }, lines };
}