marmar-context / src / domain / checks / engine.ts
engine.ts
Raw
import { emergencyAction, type EmergencyAdvice } from '../../shared/emergency/contract.js';
import { createHash } from 'node:crypto';
import { INGREDIENTS, INGREDIENT_BY_KEY, PRODUCTS, type DrugClass } from './catalog.js';
import { CHECK_SOURCES, type CheckSourceId } from './sources.js';
import {
  ALLERGY_RULES, CHILD_RULES, INTERACTION_RULES, LAB_RULES, MONITORING_RULES, OLDER_ADULT_RULES, RED_FLAGS,
  type AllergyClassRule, type Evidence, type LabBound, type Matcher, type Tier,
} from './rules.js';
import { classesFor, correctSpelling, ingredientName, isLocal, isPatchOrSpray, matchIngredients, matches, normalizeText, type Strength } from './normalize.js';
import { readStrength, type StrengthReading } from './amounts.js';
import { DOSE_NOT_IN_MG, DOSE_PROBLEM_PLAIN, doseOf, TOPICAL, type Dose } from './dose-amount.js';
import { findLabTest } from '../labs/catalog.js';
import { readsAsEnglish } from './english.js';
import { intentIn, withoutWordsOfIntentRead } from './intent.js';
import { paracetamolOverLimit } from './overuse.js';
import { CHECK_SIGNED_OFF } from './signoff.js';
import { implausibleResult, targetProblem } from '../labs/targets.js';
import { hasOptions, type AllergyDetail, type CheckOptions, type EngineHooks, type KidneyLimitOutput } from './options.js';
import { checkWithOptions, fingerprintOptions, type EngineTrace } from './clinician-check.js';
import type { ClinicianFindingFields, ClinicianResultFields, ClinicianTestFields } from './clinician-types.js';

/**
 * The deterministic V2.2 check (rules decide, the model explains).
 *
 * Pure: no I/O. Given what the person entered, it returns every finding with
 * its tier, sources and plain template wording. The optional writing layer may
 * rephrase the wording but never changes a tier, a finding or a source; when it
 * fails its post-checks, this wording is what people see.
 */

/**
 * ENGINE_VERSION changes with how the engine reads and words what it is given (2.2.0: combinations dose-checked one medicine
 * at a time, strength_unreadable; 2.2.1: maximum-only dose wording; 2.2.2: incomplete clinical context is visible in the verdict).
 * RULES_VERSION moves with each clinical sign-off of the rule tables and catalogue
 * (signoff.ts): 2026-10-15 recorded the pharmacist's approval of the review pack of 13 October 2026 (rules
 * draft-2026-10-14, engine 2.1.0). Engine 2.2.0's changes since then wait for a top-up review.
 */
export const ENGINE_VERSION = '2.2.2';
export const RULES_VERSION = '2026-10-15';

export type VerdictTier = Tier | 'nothing_found' | 'couldnt_check';

/**
 * Why something on what_we_couldnt_check wasn't checked. The first six are
 * about a medicine on the list; the rest are about the person or their results.
 * Unassessed clinical context also prevents a clean verdict when no actionable finding exists.
 * strength_unreadable: a strength was entered for a medicine with a daily
 * maximum, but couldn't be read, so its dose wasn't checked (gap 2).
 * strength_not_per_ml: a liquid's strength that isn't per ml ("120 mg"), so
 * its dose in ml couldn't be worked out (M1).
 * dose_unit_unknown: an adult's dose, or most a day, that can't be turned
 * into mg (spoons, drops, "1 dose" of a liquid, mg of a combination), for a
 * medicine whose dose would otherwise be checked (ruling A).
 */
export type CouldntCheckReason =
  | 'not_recognised' | 'contents_unknown' | 'no_rules' | 'partly_recognised' | 'strength_unreadable' | 'strength_not_per_ml' | 'dose_unit_unknown'
  | 'symptoms_not_screened' | 'symptoms_unclear' | 'course_ended' | 'age' | 'pregnancy' | 'condition' | 'lab' | 'allergy';
const MEDICINE_REASONS: ReadonlySet<CouldntCheckReason> = new Set(['not_recognised', 'contents_unknown', 'no_rules', 'partly_recognised', 'strength_unreadable',
  'strength_not_per_ml', 'dose_unit_unknown']);
/** The medicine reasons about its dose only: the subline says "The dose of Calpol couldn't be checked." */
const DOSE_REASONS: ReadonlySet<CouldntCheckReason> = new Set(['strength_unreadable', 'strength_not_per_ml', 'dose_unit_unknown']);

const wholeWord = (phrase: string) => new RegExp(`(?<![a-z0-9])${normalizeText(phrase).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`);
/** The allergy families an ingredient belongs to, from its classes (N3): "Nurofen" is ibuprofen, an NSAID. */
const familiesOf = (key: string): AllergyClassRule[] => {
  const classes = classesFor(key, { name: key });
  return ALLERGY_RULES.filter((rule) => rule.matches.some((matcher) => matches(matcher, key, classes)));
};
/** Which ingredient of an allergy brings a family (L8): the ibuprofen in "Nurofen Plus" for NSAIDs. */
const familyFrom = (keys: string[], rule: AllergyClassRule) => keys.find((key) => familiesOf(key).includes(rule)) ?? null;
/**
 * Words of 6 or more letters from the allergy families' names: their terms,
 * and every catalogue name (generic or brand) of a medicine in a family. A
 * misspelled allergy is only ever corrected towards one of these (H-C, N9):
 * never "Lanolin" to Lanoxin or "Inulin" to insulin.
 */
const ALLERGY_WORDS: ReadonlySet<string> = new Set([
  ...ALLERGY_RULES.flatMap((rule) => rule.allergyTerms),
  ...INGREDIENTS.filter((ingredient) => familiesOf(ingredient.key).length)
    .flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? []), ingredient.key.replace(/-/g, ' ')]),
  ...PRODUCTS.filter((product) => product.ingredients.some((key) => familiesOf(key).length)).flatMap((product) => product.names),
].flatMap((name) => normalizeText(name).split(' ')).filter((word) => word.length >= 6 && /^[a-z]+$/.test(word)));
/** Entries that mean "no allergies": "None", "Nil known", "No known drug allergy", "NKDA" (N15). */
const NO_ALLERGY = /^(?:none|nil|no|nkda|nka|n a|na|not applicable|(?:none|nil|no) known(?: drug| medicine| medication)?(?: allerg(?:y|ies))?|no(?: drug| medicine| medication)? allerg(?:y|ies))$/;
/** Allergies that aren't to a medicine: listed as not checked, without making the whole check "couldn't check". */
const NOT_A_MEDICINE = /(?<![a-z])(peanuts?|groundnuts?|nuts?|eggs?|milk|dairy|lactose|gluten|wheat|fish|shellfish|seafood|crayfish|prawns?|shrimps?|crabs?|soy|soya|sesame|latex|dust|pollen|cats?|dogs?|animals?|grass|mou?ld|bees?|wasps?|insects?|stings?|perfumes?|fragrances?|nickel|lanolin|wool|coumarin|inulin|foods?)(?![a-z])/;

/**
 * What an allergy entry refers to: medicines by name, and allergy families
 * (H-C). A family comes from the words ("penicillin", "sulpha drugs") or from
 * the classes of the medicine named ("Zinnat" is cefuroxime, a
 * cephalosporin; N3). A misspelling ("Penicilin", "Pencillin") is corrected
 * towards the families' names only, and `readAs` says how it was read, so the
 * finding can say so (N9). Null: we can't tell, so it isn't checked.
 */
function readAllergy(allergy: string): { ingredients: string[]; classRules: AllergyClassRule[]; readAs: string | null } | null {
  const attempt = (text: string) => {
    const normalized = normalizeText(text);
    const ingredients = matchIngredients(text).ingredients;
    const classRules = ALLERGY_RULES.filter((rule) => rule.allergyTerms.some((term) => wholeWord(term).test(normalized))
      || ingredients.some((key) => familiesOf(key).includes(rule)));
    return ingredients.length || classRules.length ? { ingredients, classRules } : null;
  };
  const exact = attempt(allergy);
  if (exact) return { ...exact, readAs: null };
  const corrected = correctSpelling(allergy, ALLERGY_WORDS);
  const read = corrected ? attempt(corrected) : null;
  return read ? { ...read, readAs: corrected } : null;
}

export interface CheckMedicine {
  id: string | null;
  name: string;
  strength: string | null;
  form: string | null;
  /** Doses per day, when known. */
  timesPerDay: number | null;
  /** How much each dose is, counted in `doseUnit`. */
  unitsPerDose: number;
  /** What unitsPerDose counts: tablets and the like (the default), ml, mg, or a measure we can't convert (spoons, drops). */
  doseUnit?: 'unit' | 'ml' | 'mg' | 'unknown';
  /** The dose is in spoons or drops: the medicine is a liquid, whatever its saved form (R1). */
  liquidDose?: boolean;
  /** For "only when needed" medicines: the most a day (S100a). */
  prnMaxPerDay: number | null;
  /** What prnMaxPerDay counts, as entered: "tablets", "doses", "ml". */
  prnMaxUnit?: string | null;
  origin: 'prescribed' | 'over_the_counter' | 'herbal' | 'market' | 'hospital' | 'unknown' | null;
  /** false = "Not sure what's in it" (S33, S90). */
  contentsKnown: boolean;
}

/** Stored with the finding, never refreshed from today's lab record. */
export interface LabEvidence {
  result: {
    id: string; code: string; name: string; value: number; valueText: string;
    comparator: '<' | '<=' | '>' | '>=' | null; unit: string | null; observedAt: string;
    referenceRange: { low: number | null; high: number | null; text: string | null };
    labFlag: 'low' | 'high' | 'normal' | 'abnormal' | null;
  };
  target: { code: string; name: string; low: number | null; high: number | null; unit: string | null;
    setBy: 'clinician' | 'self'; note: string | null; updatedAt: string } | null;
}

export interface CheckLab {
  evidence?: LabEvidence;
  code: string;
  value: number;
  observedAt: string;
  /**
   * Set when the result was printed as a bound (">8", "<15"): `value` is then
   * the bound. It counts as a recent test, and meets a threshold only when
   * every value it allows does (H3).
   */
  comparator?: '<' | '<=' | '>' | '>=' | null;
  /** The unit as printed, when known: read only by the egfrFromCreatinine option (creatinine). */
  unit?: string | null;
}

export interface CheckInput {
  subject: {
    kind: 'self' | 'person'; name: string | null; sex: string | null;
    /** The age to show: from the birth date or year when known (M2). */
    age: number | null;
    /**
     * The youngest and oldest they could be today, when the exact birthday
     * isn't known ("Just the year": 17 or 18). Rules for children use the
     * youngest, rules for 65 and over the oldest. Defaults to `age` for both.
     */
    ageRange?: { min: number; max: number } | null;
    /** From the health profile, when given. */
    weightKg?: number | null;
  };
  /** Medicines being taken now. A course that has ended is left out. */
  medicines: CheckMedicine[];
  /** Courses that ended in the last two weeks: not checked, but named in case they are still being taken. */
  endedMedicines?: Array<{ id: string | null; name: string; endedOn: string }>;
  allergies: string[];
  /** Preserve an explicit uncertain answer instead of equating an empty list with none. */
  allergiesStatus?: 'listed' | 'none' | 'not_sure';
  /** What each allergy was like, when recorded: read only by the allergyTiers option (CHK-6a). */
  allergyDetails?: AllergyDetail[];
  /** Listed under what_we_couldnt_check: no rule reads conditions yet (H5). */
  conditions: string[];
  conditionsStatus?: 'listed' | 'none' | 'not_sure';
  /** Pregnancy, breastfeeding or planning a pregnancy, when recorded. No rule reads it yet (H5). */
  reproductive?: 'pregnant' | 'breastfeeding' | 'planning_pregnancy' | null;
  labs: CheckLab[];
  labTargets: Record<string, { low: number | null; high: number | null }>;
  symptoms: string | null;
  emergency: EmergencyAdvice;
  language: string;
  today: string;
}

/** The Partial fields come only with an option on (options.ts, clinician-types.ts). */
export interface CheckFinding extends Partial<ClinicianFindingFields> {
  lab_evidence?: LabEvidence;
  id: string;
  kind: 'interaction' | 'allergy' | 'duplicate' | 'dose' | 'older_adult' | 'child' | 'lab';
  tier: Tier;
  ruleId: string;
  medicines: Array<{ id: string | null; name: string }>;
  title: string;
  why: string;
  what_to_do: string[];
  watch_for: string[];
  numbers: null;
  evidence_label: string;
  source_ids: CheckSourceId[];
  questions_for_doctor: string[];
  allow_stop: boolean;
  clinical: string;
  teach_back: { question: string; options: string[]; answer_index: number } | null;
}

export interface CheckResult extends Partial<ClinicianResultFields> {
  engineVersion: string;
  rulesVersion: string;
  /** True only when every rule is approved and nothing waits for a top-up review (signoff.ts). */
  rulesReviewed: boolean;
  verdict: { tier: VerdictTier; headline: string; subline: string; keep_taking_line: string | null };
  get_help_now: { show: boolean; signs: string[]; action: string; contact?: EmergencyAdvice };
  findings: CheckFinding[];
  tests_to_ask_about: Array<{ test: string; why: string; when: string; source_id: CheckSourceId; status: 'due' | 'up_to_date' | 'no_result'; last_result_at: string | null }
    & Partial<ClinicianTestFields>>;
  dose_check: Array<{ medicine: string; medicine_id: string | null; status: 'within_usual_range' | 'above_limit' | 'check_with_pharmacist' | 'not_enough_info'; plain: string;
    /** kidneyDoseLimits only. */
    kidney_limit?: KidneyLimitOutput }>;
  what_we_checked: { medicines: string[]; conditions: string[]; allergies: string[]; age: number | null; references: Array<{ id: CheckSourceId; name: string; url: string | null }> };
  what_we_couldnt_check: Array<{ item: string; medicine_id: string | null; reason: CouldntCheckReason; plain: string }>;
  for_the_doctor: { summary: string; flags: Array<{ finding_id: string; reference: string; for_discussion: string }> };
  language: string;
  language_fallback: boolean;
  wording: 'template' | 'model';
}

const TIER_RANK: Record<Tier, number> = { get_help_now: 4, today: 3, this_week: 2, good_to_know: 1 };

/** A lab result older than this is acted on the same day at most, and says how old it is (L-2). */
const DATED_RESULT_DAYS = 14;
/** A lab result older than this isn't treated as current (L-2). */
const STALE_RESULT_DAYS = 90;

/** Every matcher in the rule tables. */
const RULE_MATCHERS: Matcher[] = [
  ...INTERACTION_RULES.flatMap((rule) => [rule.a, rule.b]),
  ...ALLERGY_RULES.flatMap((rule) => [...rule.matches, ...(rule.crossReactive ?? [])]),
  ...OLDER_ADULT_RULES.map((rule) => rule.match),
  ...CHILD_RULES.map((rule) => rule.match),
  ...MONITORING_RULES.map((rule) => rule.match),
  ...LAB_RULES.flatMap((rule) => [...rule.match, ...(rule.alsoNeeds ? [rule.alsoNeeds] : [])]),
];
/** Ingredients some rule can match, or with a dose maximum to check. Any other has had nothing checked (L1, H-F). */
const HAS_RULES = new Set(INGREDIENTS.filter((ingredient) => ingredient.maxDailyMg
  || RULE_MATCHERS.some((matcher) => matches(matcher, ingredient.key, ingredient.classes))).map((ingredient) => ingredient.key));
/** A medicine's ingredients no rule can match, apart from companions checked through their partner (caffeine, clavulanic acid). */
const unruledIngredients = (med: { ingredients: Array<{ key: string }> }) =>
  med.ingredients.filter((ingredient) => !HAS_RULES.has(ingredient.key) && !INGREDIENT_BY_KEY.get(ingredient.key)?.companion);
/** A medicine counts as checked when at least one ingredient that isn't just a companion has rules. */
const isChecked = (med: { ingredients: Array<{ key: string }> }) =>
  med.ingredients.some((ingredient) => HAS_RULES.has(ingredient.key) && !INGREDIENT_BY_KEY.get(ingredient.key)?.companion);

/**
 * A finding before its id and teach-back are worked out; idSeed makes the id
 * stable for the same medicines. noKeepTaking: the finding says to call before
 * the next dose and must not be paired with "keep taking" (the doctor or clinic
 * decides the next dose), so the verdict leaves its keep-taking line out.
 */
type NewFinding = Omit<CheckFinding, 'teach_back' | 'numbers' | 'id'> & {
  idSeed: string;
  noKeepTaking?: boolean;
  /** Within a day or two, sooner than its tier (N23). */
  soon?: boolean;
  /** Medicines this finding says to stop or to call about first: no finding may say to keep taking them (H-E). Default: all of a call-first finding's medicines. */
  holds?: string[];
  /** What the finding was worked out from, for the options' clinician wording (clinician-check.ts). Never on the finding. */
  facts?: Record<string, unknown>;
};

/**
 * "Keep taking" lines are written as markers while findings are built and
 * worded at the end (H-E), once it is known which medicines a finding says to
 * stop or to call about first: those are never named as ones to keep taking.
 */
const KEEP_ALL = '\u0000keep-all';
const keepLine = (names: string[], until: boolean) => `\u0000keep:${until ? 'until' : 'plain'}:${JSON.stringify(names)}`;
/** A line left out when another finding says to stop every one of these medicines (N22). */
const unlessBothStopped = (names: string[], text: string) => `\u0000unless-stopped:${JSON.stringify(names)}\u0001${text}`;

function joinNames(names: string[]): string {
  return names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}

/** The youngest and oldest the person could be (H5, M2); null when no age is known. */
export function ageRangeOf(subject: CheckInput['subject']): { min: number; max: number } | null {
  if (subject.ageRange) return subject.ageRange;
  return subject.age === null || subject.age === undefined ? null : { min: subject.age, max: subject.age };
}
const EVIDENCE_LABEL: Record<Evidence, string> = {
  studies: 'Shown in studies', few_reports: 'Reported in a few people', theoretical: 'Expected from how the medicines work',
};

interface Resolved {
  medicine: CheckMedicine;
  /** Used on the skin, eye, ear, nose or vagina: only allergy and duplicate checks apply (LOW). */
  local: boolean;
  /** Words of the name we couldn't place: maybe more ingredients (M-2). */
  unmatchedWords: string[];
  /** Product names the name matched ("night nurse"). */
  products: string[];
  ingredients: Array<{ key: string; classes: DrugClass[] }>;
  /** The strength is the pack's total for all its ingredients (Augmentin "625 mg"): no ingredient's own. */
  packTotal: boolean;
  /** A strength was entered, but neither it nor the name could be read (gap 2): never "Add the strength", which is for one that is missing. */
  strengthUnreadable: boolean;
  /** Where the strength used was written: the strength entered, or the medicine's name ("Calpol 120mg"). */
  strengthFrom: 'strength' | 'name' | null;
  /** Each counted ingredient's dose (caffeine aside): a combination's are worked out one by one. */
  doses: Map<string, Dose>;
  /** The medicine's dose: its one ingredient's, or, for a combination, its first ingredient with a maximum (they share how often and how much). */
  dose: Dose;
}


/** A strength as quoted back: on one line, cut short after 40 characters (whole characters, so never half an emoji). */
const quoted = (text: string) => {
  const characters = Array.from(text.trim().replace(/\s+/g, ' '));
  return characters.length > 40 ? `${characters.slice(0, 40).join('').trimEnd()}...` : characters.join('');
};

/** A finding's title within its 70 characters: cut only after a whole word, and marked "...", never half a word or a number (R2). */
export function fitTitle(text: string, max = 70): string {
  if (text.length <= max) return text;
  const space = text.lastIndexOf(' ', max - 3);
  return `${(space > 0 ? text.slice(0, space) : text.slice(0, max - 3)).replace(/[\s,:;]+$/, '')}...`;
}

/**
 * A verdict's subline within 120 characters: whole sentences only (R2). A
 * first sentence longer than that is kept whole, never cut short: "The label
 * maximums for adults are 40" must never stand under the headline.
 */
export function fitSentences(text: string, max = 120): string {
  if (text.length <= max) return text;
  let end = 0;
  for (const match of text.matchAll(/[.!?](?=\s|$)/g)) {
    const at = match.index! + 1;
    if (at > max) return text.slice(0, end || at);
    end = at;
  }
  return end ? text.slice(0, end) : text;
}

function voice(input: CheckInput) {
  const self = input.subject.kind === 'self';
  const name = input.subject.name ?? 'them';
  const sex = (input.subject.sex ?? '').toLowerCase();
  const pronoun = self ? 'your' : sex === 'female' ? 'her' : sex === 'male' ? 'his' : 'their';
  return {
    self,
    name,
    possessive: pronoun,
    /** First mention: "Amina's" or "your". */
    owner: self ? 'your' : `${name}'s`,
    object: self ? 'you' : sex === 'female' ? 'her' : sex === 'male' ? 'him' : 'them',
  };
}

function fill(text: string, a: string, b = ''): string {
  return text.replace(/\{a\}/g, a).replace(/\{b\}/g, b);
}

function shortId(...parts: string[]): string {
  return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 12);
}

function teachBack(tier: Tier, action: string, v: ReturnType<typeof voice>, seed: string) {
  if (tier !== 'today' && tier !== 'this_week') return null;
  // Wrong answers that are harmless even if someone picks one (never "stop all her medicines").
  const options = [action, 'Wait for the next check-up', 'I\'m not sure'];
  const rotate = Number.parseInt(seed.slice(0, 2), 16) % 3;
  const rotated = [...options.slice(rotate), ...options.slice(0, rotate)];
  return { question: 'What is the next step?', options: rotated, answer_index: rotated.indexOf(action) };
}

/** Over a maximum (N4): call before the next dose, never "keep taking" (111 also answers in the UK). */
const overMaximumAction = (v: ReturnType<typeof voice>, input: CheckInput) =>
  `Before the next dose, call ${v.possessive} pharmacist or doctor today${input.emergency.countryCode === 'GB' ? ', or 111' : ''}.`;

function tierAction(tier: Tier, v: ReturnType<typeof voice>, input: CheckInput): string {
  switch (tier) {
    case 'get_help_now': return emergencyAction(input.emergency);
    case 'today': return `Before the next dose, call ${v.possessive} doctor or pharmacist today.`;
    case 'this_week': return `Ask ${v.possessive} pharmacist or doctor about this in the next week.`;
    default: return `Mention this to ${v.possessive} pharmacist next time.`;
  }
}

/**
 * H2: stopping is advised only for a medicine the person said they bought over
 * the counter (origin over_the_counter; null and unknown don't count, whatever
 * the catalogue says), and never for aspirin at an antiplatelet dose or an
 * unknown dose: that may be a planned heart or stroke medicine.
 */
function mayStop(med: Resolved): boolean {
  if (med.medicine.origin !== 'over_the_counter') return false;
  const aspirin = med.ingredients.find((ingredient) => ingredient.key === 'aspirin');
  return !aspirin || (aspirin.classes.includes('nsaid') && !aspirin.classes.includes('aspirin_dose_unknown'));
}

const usable = (reading: StrengthReading) => reading.kind === 'read' || reading.kind === 'total';

function resolve(medicine: CheckMedicine): Resolved {
  const matched = medicine.contentsKnown ? matchIngredients(medicine.name) : { ingredients: [] as string[], unmatchedWords: [] as string[], matchedNames: [] as string[] };
  // The strength entered, else one in the name ("Co-codamol 30/500"), as before: one medicine's, or each of a combination's (amounts.ts).
  const entered = readStrength(medicine.strength, matched);
  const reading = usable(entered) ? entered : readStrength(medicine.name, matched);
  const strengths = new Map<string, Strength>(reading.kind === 'read' ? Object.entries(reading.mg).map(([key, mg]) => [key, { per: reading.per, mg }]) : []);
  const strengthUnreadable = entered.kind === 'unreadable' && !usable(reading);
  const local = isLocal(medicine.form, medicine.name);
  const topical = local || isPatchOrSpray(medicine.form, medicine.name);
  // Each ingredient that counts towards a dose (caffeine aside), from its own strength.
  const dosed = matched.ingredients.filter((key) => key !== 'caffeine');
  const how = { combination: dosed.length > 1, unreadable: strengthUnreadable };
  const doses = new Map(dosed.map((key) => [key, topical ? TOPICAL : doseOf(medicine, strengths.get(key) ?? null, how)]));
  const first = dosed.find((key) => INGREDIENT_BY_KEY.get(key)?.maxDailyMg) ?? dosed[0];
  const dose = topical ? TOPICAL : first ? doses.get(first)! : doseOf(medicine, null, how);
  // Aspirin's dose decides whether it is an antiplatelet or also an NSAID: its own
  // amount, so a strength that belongs to another ingredient of a combination never counts.
  const aspirinDose = doses.get('aspirin');
  const aspirinStrength = strengths.get('aspirin');
  const aspirinMg = aspirinDose ? aspirinDose.perDayMg ?? aspirinDose.perDoseMg ?? (aspirinStrength?.per === 'unit' ? aspirinStrength.mg : null) : null;
  return {
    medicine,
    local,
    unmatchedWords: matched.unmatchedWords,
    products: matched.matchedNames,
    packTotal: reading.kind === 'total',
    strengthUnreadable,
    strengthFrom: usable(entered) ? 'strength' : usable(reading) ? 'name' : null,
    doses,
    dose,
    ingredients: matched.ingredients.map((key) => ({ key, classes: classesFor(key, { name: medicine.name, topical, aspirinMg }) })),
  };
}

const RED_FLAG_PATTERNS = RED_FLAGS.map((flag) => ({
  ...flag, regexes: flag.patterns.map((pattern) => new RegExp(`(?<![a-z0-9])(?:${pattern})(?![a-z0-9])`)),
}));

export const isEnglish = (language: string | null | undefined) => /^en(-|$)/i.test(language ?? 'en');

/** Every word of every medicine name we know: they count as readable in symptom text. */
const MEDICINE_WORDS: ReadonlySet<string> = new Set([
  ...INGREDIENTS.flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? [])]),
  ...PRODUCTS.flatMap((product) => product.names),
].flatMap((name) => normalizeText(name).split(/[^a-z]+/)).filter((word) => word.length >= 2));

export interface SymptomScreen {
  /** RED_FLAGS ids found, in table order. */
  ids: string[];
  /** Their plain signs, for get_help_now.signs. */
  signs: string[];
  /** false when the text could not be fully screened: not in English (by the language given, or by its words), or no Latin letters at all. */
  screened: boolean;
  /**
   * H-1.7: a word about dying, death, killing or suicide that is neither a sign nor never a sign, with no other sign: no label,
   * but the emergency line ("couldn't be sure" on the check, the banner in Ask).
   */
  unsure: boolean;
}

/**
 * RS6: emergency signs in what someone wrote (check symptoms, an Ask question).
 * Only English and common Pidgin forms are recognised, so anything else is
 * reported as not screened and the caller always shows the emergency number.
 * J1: paracetamol over the most in 24 hours is too much medicine, however it
 * is scheduled ("took 10 paracetamol a day for a week"). Ask answers a question
 * about doing it itself (`paracetamolQuestions: false`); in symptom text it is
 * a sign, so the check never says "No problems found" next to it. M2: the
 * words of intent Ask uses (intent.ts) are signs here too: self-harm, too much
 * medicine, or (M-1) feeling like dying.
 */
export function screenSymptoms(text: string | null | undefined, language: string | null | undefined = 'en',
  options: { paracetamolQuestions?: boolean } = {}): SymptomScreen {
  const raw = (text ?? '').trim();
  if (!raw) return { ids: [], signs: [], screened: true, unsure: false };
  const normalized = normalizeText(raw);
  const paracetamol = paracetamolOverLimit(raw);
  const tooMuchParacetamol = paracetamol === 'taken' || (paracetamol === 'asked' && options.paracetamolQuestions !== false);
  // As written: the intent list splits it into clauses before normalizing (H-1.2).
  const intent = intentIn(raw);
  const found = RED_FLAG_PATTERNS.filter((flag) => (flag.id === 'overdose' && (tooMuchParacetamol || intent.tooMuch)) || (flag.id === 'self-harm' && intent.selfHarm)
    || (flag.id === 'dying' && intent.dying) || flag.regexes.some((regex) => regex.test(normalized)));
  // LOW: the Hausa, Yoruba and Igbo words of intent the list reads ("Mo fe ku") count as read, so a text of only those was screened.
  const unread = withoutWordsOfIntentRead(normalized);
  const screened = /[a-z]/.test(normalized) && isEnglish(language) && (!/[a-z]/.test(unread) || readsAsEnglish(unread, MEDICINE_WORDS));
  return { ids: found.map((flag) => flag.id), signs: found.map((flag) => flag.sign), screened, unsure: intent.unsure && !found.length };
}

/** "Call 112 now, ..." as it reads mid-sentence. */
const midSentence = (sentence: string) => sentence.charAt(0).toLowerCase() + sentence.slice(1);

/**
 * Whether a result is past a threshold (H3). An exact value is a point; one
 * printed as a bound is the range it allows (">8": above 8). Past it only
 * when the whole range is; not past it when none of it is; otherwise we
 * can't tell, and the caller says so.
 */
export function crosses(lab: Pick<CheckLab, 'value' | 'comparator'>, direction: LabBound['direction'], threshold: number, inclusive: boolean): 'yes' | 'no' | 'unknown' {
  let low = lab.value; let high = lab.value; let lowOpen = false; let highOpen = false;
  if (lab.comparator === '>' || lab.comparator === '>=') { high = Infinity; lowOpen = lab.comparator === '>'; }
  if (lab.comparator === '<' || lab.comparator === '<=') { low = -Infinity; highOpen = lab.comparator === '<'; }
  if (direction === 'above') {
    const yes = inclusive ? low >= threshold : low > threshold || (low === threshold && lowOpen);
    const no = inclusive ? high < threshold || (high === threshold && highOpen) : high <= threshold;
    return yes ? 'yes' : no ? 'no' : 'unknown';
  }
  const yes = inclusive ? high <= threshold : high < threshold || (high === threshold && highOpen);
  const no = inclusive ? low > threshold || (low === threshold && lowOpen) : low >= threshold;
  return yes ? 'yes' : no ? 'no' : 'unknown';
}

/** Ingredients of a medicine a rule's matcher picks out. A local medicine (cream, eye drops) only counts for allergies. */
function hits(med: Resolved, matcher: Matcher, options: { allergy?: boolean } = {}) {
  if (med.local && !options.allergy) return [];
  if (matcher.product) return med.products.includes(normalizeText(matcher.product)) ? med.ingredients : [];
  return med.ingredients.filter((ingredient) => matches(matcher, ingredient.key, ingredient.classes));
}

/** Ingredients that count towards a dose. Caffeine in "extra" painkillers has no limit here. */
function dosedIngredients(med: Resolved) {
  return med.ingredients.filter((ingredient) => ingredient.key !== 'caffeine');
}

/** An ingredient's mg in 24 hours from this medicine: a combination's from that ingredient's own amount ("30/500" is codeine 30 mg, paracetamol 500 mg). */
function dailyMg(med: Resolved, key: string): number | null {
  return med.doses.get(key)?.perDayMg ?? null;
}

/** The ingredients of a medicine whose doses are checked: those with a label maximum. */
const limitedIngredients = (med: Resolved) => dosedIngredients(med).filter((ingredient) => INGREDIENT_BY_KEY.get(ingredient.key)?.maxDailyMg);

/** Where an ingredient's maximum comes from (M3): its own source, the NSAID labels for an NSAID, else its own product label; never another medicine's. */
function maximumSources(key: string): CheckSourceId[] {
  const ingredient = INGREDIENT_BY_KEY.get(key);
  return ingredient?.maxSources ?? (ingredient?.classes.includes('nsaid') ? ['label-nsaid'] : ['label-product']);
}

/** Signs of too much of an opioid (codeine, dihydrocodeine, tramadol), in the children's rules' words (M3). */
const OPIOID_SIGNS = ['very sleepy or hard to wake', 'slow or shallow breathing'];
const PARACETAMOL_SIGNS = ['feeling sick or being sick', 'pain under the right ribs'];
/** What to watch for with too much of these ingredients, at most 3: an opioid's breathing signs first, then paracetamol's. */
const tooMuchSigns = (keys: string[]) => [...(keys.some((key) => INGREDIENT_BY_KEY.get(key)?.classes.includes('opioid')) ? OPIOID_SIGNS : []),
  ...(keys.includes('paracetamol') ? PARACETAMOL_SIGNS : [])].slice(0, 3);

/** With no option on (the app), exactly the default check. Options are for MARMAR Context only (options.ts). */
export function runCheck(input: CheckInput, options?: CheckOptions): CheckResult {
  if (!hasOptions(options)) return analyse(input).result;
  return checkWithOptions(input, options, analyse);
}

/**
 * Medicines some finding says to stop, or to call about before the next dose:
 * nothing may say to keep taking them. The writer's guard needs them (N1).
 */
export function heldMedicines(input: CheckInput): string[] {
  return analyse(input).held;
}

/** hooks and trace come only with an option on (clinician-check.ts); without them this is the default check. */
function analyse(input: CheckInput, hooks?: EngineHooks, trace?: EngineTrace): { result: CheckResult; held: string[] } {
  const v = voice(input);
  const resolved = input.medicines.map(resolve);
  const findings: CheckFinding[] = [];
  const usedSources = new Set<CheckSourceId>();
  const noKeepTaking = new Set<string>();
  /** Medicines a finding says to stop, or to call about before the next dose. */
  const held = new Set<string>();
  /** Medicines a finding says to stop. */
  const stopped = new Set<string>();
  /** Findings to act on within a day or two. */
  const soonFindings = new Set<string>();
  const push = (finding: NewFinding) => {
    const id = shortId(finding.ruleId, finding.idSeed);
    if (findings.some((existing) => existing.id === id)) return;
    if (hooks?.allow && !hooks.allow(finding.ruleId)) {
      trace?.skipped.push({ ruleId: finding.ruleId, medicines: finding.medicines });
      return;
    }
    finding.source_ids.forEach((source) => usedSources.add(source));
    const { idSeed, noKeepTaking: callFirst, holds, soon, facts, ...rest } = finding;
    void idSeed;
    if (facts) trace?.facts.set(id, facts);
    if (soon) soonFindings.add(id);
    if (callFirst) noKeepTaking.add(id);
    for (const name of holds ?? (callFirst ? rest.medicines.map((medicine) => medicine.name) : [])) held.add(name);
    if (rest.allow_stop) for (const name of holds ?? []) stopped.add(name);
    findings.push({ ...rest, id, numbers: null, teach_back: teachBack(rest.tier, rest.what_to_do[0] ?? tierAction(rest.tier, v, input), v, id) });
  };
  const ageRange = ageRangeOf(input.subject);
  // Adult dose limits only apply when they can't be under 18 (H5).
  const adultLimits = ageRange !== null && ageRange.min >= 18;
  const keepTaking = KEEP_ALL;
  // Emergency signs (RS6) run on symptom text and override everything.
  const screen = screenSymptoms(input.symptoms, input.language);
  const signs = screen.signs;
  /** Signs from findings that need help now (a very high potassium), for the help card. */
  const helpSigns: string[] = [];

  // Interactions between two different medicines: one finding per pair, from
  // the most urgent rule that applies (ties go to the earlier rule in the table).
  const pairs = new Map<string, { rank: number; order: number; finding: NewFinding }>();
  for (let i = 0; i < resolved.length; i++) {
    for (let j = 0; j < resolved.length; j++) {
      if (i === j) continue;
      const first = resolved[i];
      const second = resolved[j];
      INTERACTION_RULES.forEach((rule, order) => {
        const aHits = hits(first, rule.a);
        const bHits = hits(second, rule.b);
        if (!aHits.length || !bHits.length) return;
        // A dose-dependent rule: skipped only when side a's daily dose is known and within the limit.
        if (rule.aOverMgPerDay !== undefined) {
          const limit = rule.aOverMgPerDay;
          if (aHits.every((hit) => { const mg = dailyMg(first, hit.key); return mg !== null && mg <= limit; })) return;
        }
        // A symmetric rule (same matcher both sides) is reported once, for i < j.
        if (JSON.stringify(rule.a) === JSON.stringify(rule.b) && i > j) return;
        // signedOffOnly: a rule that isn't signed off leaves the pair to the next rule that is.
        if (hooks?.allow && !hooks.allow(rule.id)) {
          trace?.skipped.push({ ruleId: rule.id, medicines: [first, second].map((med) => ({ id: med.medicine.id, name: med.medicine.name })) });
          return;
        }
        const a = first.medicine.name;
        const b = second.medicine.name;
        // H2: which medicine, if any, may be stopped. Never chosen by list order.
        let stop: Resolved | null = null;
        if (rule.allowStop === 'a' || rule.allowStop === 'b') {
          const side = rule.allowStop === 'a' ? first : second;
          if (mayStop(side)) stop = side;
        } else if (rule.allowStop === 'either') {
          const firstOtc = first.medicine.origin === 'over_the_counter';
          const secondOtc = second.medicine.origin === 'over_the_counter';
          const side = firstOtc && !secondOtc ? first : secondOtc && !firstOtc ? second : null;
          if (side && mayStop(side)) stop = side;
        }
        // "Stop the Ibuprofen" could be read as stopping a prescribed Brufen too: never stop while
        // another listed medicine that wasn't bought over the counter has the same ingredient (H-A).
        // Then every medicine with that ingredient is held: none is "keep taking" next to the blood thinner (N6).
        const chosen = stop;
        const sharing = chosen ? resolved.filter((other) => other !== chosen && other.medicine.origin !== 'over_the_counter'
          && other.ingredients.some((ingredient) => chosen.ingredients.some((own) => own.key === ingredient.key))) : [];
        if (sharing.length) stop = null;
        const keepName = stop === first ? b : a;
        // M-3, N5: aspirin with a blood thinner. Only a prescribed one is "keep taking both"; otherwise, at any
        // dose, call today before the next aspirin (NHS: no aspirin with warfarin unless a doctor says so).
        const aspirinPrescribed = second.medicine.origin === 'prescribed' || second.medicine.origin === 'hospital';
        const isAspirin = second.ingredients.some((ingredient) => ingredient.key === 'aspirin');
        const aspirinCall = !aspirinPrescribed && (Boolean(rule.keepBoth) || (Boolean(rule.unprescribedAspirinToday) && isAspirin));
        const tier: Tier = aspirinCall ? 'today' : rule.tier;
        const whatToDo = stop
          ? [`Stop the ${stop.medicine.name}. It was bought without a prescription, and the NHS advises against taking it with ${keepName}.`,
            `Ask ${v.possessive} pharmacist which painkiller to use instead.`, keepLine([keepName], false)]
          : aspirinCall
            // N5: a medicine saved before V2.2 has no origin, so a prescribed aspirin (after a stent) may read as unprescribed.
            ? [`Call ${v.possessive} doctor or pharmacist today, before the next ${b}.`,
              'If a doctor prescribed it, for example after a stent, don\'t miss it before you\'ve spoken to them.', keepLine([a], true)]
            : rule.keepBoth
              ? [tierAction(tier, v, input), keepLine([a, b], true)]
              : tier === 'good_to_know' && rule.advice
                ? [fill(rule.advice, a, b), `Ask ${v.possessive} pharmacist if you're not sure how to space them.`]
                // N22: "which one to keep" is left out when another finding says to stop both.
                : [tierAction(tier, v, input), ...(rule.advice ? [fill(rule.advice, a, b)] : []), ...(rule.noStopAction ? [unlessBothStopped([a, b], fill(rule.noStopAction, a, b))] : []),
                  ...(tier === 'good_to_know' ? [] : [keepTaking])];
        const holds = stop ? [stop.medicine.name] : aspirinCall ? [b]
          : sharing.length && chosen ? [chosen.medicine.name, ...sharing.map((other) => other.medicine.name)] : undefined;
        const title = tier === 'today'
          ? `Call ${v.possessive} doctor today about ${a} and ${b}`
          : tier === 'this_week' ? `Ask about ${a} with ${b} this week` : `${a} with ${b}: good to know`;
        const finding: NewFinding = { kind: 'interaction', tier, ruleId: rule.id, idSeed: [first.medicine.id ?? a, second.medicine.id ?? b].sort().join('+'),
          medicines: [{ id: first.medicine.id, name: a }, { id: second.medicine.id, name: b }],
          title: fitTitle(title), why: fill(rule.why, a, b), what_to_do: whatToDo,
          watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL[rule.evidence], source_ids: rule.sources,
          questions_for_doctor: [fill(rule.question, a, b)], allow_stop: Boolean(stop), noKeepTaking: Boolean(holds), holds,
          clinical: `${a} + ${b}: ${rule.clinical}` };
        const key = i < j ? `${i}:${j}` : `${j}:${i}`;
        const best = pairs.get(key);
        const rank = TIER_RANK[tier];
        if (!best || rank > best.rank || (rank === best.rank && order < best.order)) pairs.set(key, { rank, order, finding });
      });
    }
  }
  for (const { finding } of pairs.values()) push(finding);

  // Allergies (RS4: an allergy match is always red). One we can't read is said, never treated as checked (H-C).
  const checkedAllergies: string[] = [];
  const allergyNotes: CheckResult['what_we_couldnt_check'] = [];
  const allergyGaps = new Set<string>();
  for (const allergy of input.allergies) {
    const said = normalizeText(allergy);
    if (!said || NO_ALLERGY.test(said)) continue;
    const read = readAllergy(allergy);
    if (!read) {
      const item = `${allergy} (allergy)`;
      if (NOT_A_MEDICINE.test(said)) {
        allergyNotes.push({ item, medicine_id: null, reason: 'allergy',
          plain: `We only check allergies to medicines, so ${allergy} wasn't checked. Ask ${v.possessive} pharmacist whether any of ${v.possessive} medicines contain it.` });
      } else {
        allergyGaps.add(item);
        allergyNotes.push({ item, medicine_id: null, reason: 'allergy',
          plain: `We couldn't match the allergy "${allergy}" to a medicine we know, so ${v.possessive} medicines weren't checked against it. Show it to ${v.possessive} pharmacist.` });
      }
      continue;
    }
    checkedAllergies.push(allergy);
    const directIngredients = read.ingredients;
    const classRules = read.classRules;
    // N9: a corrected spelling is said, so a wrong reading can be spotted.
    const readAs = read.readAs ? ` We read "${allergy}" as ${read.readAs}.` : '';
    // L8: an allergy to a combination ("Nurofen Plus", "Panadol Extra") says which of its ingredients a finding goes by.
    const combination = directIngredients.length > 1;
    const who = v.self ? 'you are' : `${v.name} is`;
    const allergyWhy = (name: string, direct: string | null, family: AllergyClassRule | null) => {
      const from = family ? familyFrom(directIngredients, family) : null;
      if (combination && direct) return `${name} has ${ingredientName(direct).toLowerCase()} in it, like ${allergy}, which ${who} allergic to.`;
      if (combination && from) return `${name} is in the same family as the ${ingredientName(from).toLowerCase()} in ${allergy}, which ${who} allergic to.`;
      if (direct || family) return `${name} is in the same family as something ${who} allergic to (${allergy}).`;
      // A cross-reaction. Pharmacist to review: call-first because how severe the allergy was isn't recorded (ALLERGY_RULES notes).
      return crossTier === 'today' ? `${name} is related to ${allergy}. A few people with this allergy react to it too.`
        : `${name} is related to ${allergy}. A reaction is unlikely but possible.`;
    };
    let crossTier: Tier = 'this_week';
    for (const med of resolved) {
      const direct = med.ingredients.find((ingredient) => directIngredients.includes(ingredient.key));
      const classMatch = classRules.find((rule) => rule.matches.some((matcher) => hits(med, matcher, { allergy: true }).length));
      const cross = classMatch ? null : classRules.find((rule) => rule.crossReactive?.some((matcher) => hits(med, matcher, { allergy: true }).length));
      if (!direct && !classMatch && !cross) continue;
      const ruleTier: Tier = direct || classMatch ? 'today' : cross?.crossReactiveTier ?? 'this_week';
      crossTier = ruleTier;
      const matchKind = direct ? 'ingredient' : classMatch ? 'class' : 'cross';
      // allergyTiers (CHK-6a): the recorded reaction can make it urgent. Urgent keeps the call-first wording.
      const tier = hooks?.allergyTier ? hooks.allergyTier({ allergy, matchKind, tier: ruleTier }) : ruleTier;
      const callFirst = TIER_RANK[tier] >= TIER_RANK.today;
      const sources: CheckSourceId[] = classMatch?.sources ?? cross?.sources ?? ['nhs-anaphylaxis'];
      const matched = direct ? [direct.key] : med.ingredients.filter((ingredient) => (classMatch?.matches ?? cross?.crossReactive ?? [])
        .some((matcher) => matches(matcher, ingredient.key, ingredient.classes))).map((ingredient) => ingredient.key);
      push({ kind: 'allergy', tier, ruleId: classMatch?.id ?? cross?.id ?? 'allergy-ingredient', idSeed: `${med.medicine.id ?? med.medicine.name}|${allergy}`,
        medicines: [{ id: med.medicine.id, name: med.medicine.name }],
        title: fitTitle(callFirst ? `Call today: ${med.medicine.name} and ${v.possessive} ${allergy} allergy` : `${med.medicine.name} and ${v.possessive} ${allergy} allergy`),
        why: `${allergyWhy(med.medicine.name, direct?.key ?? null, classMatch ?? null)}${readAs}`,
        // Call before the next dose; never "don't take" a medicine that may be prescribed, and never "keep taking" beside it.
        what_to_do: callFirst
          ? [`Before the next dose, call ${v.possessive} doctor or pharmacist today about the ${allergy} allergy.`]
          : [tierAction('this_week', v, input), keepTaking],
        noKeepTaking: callFirst, facts: { allergy, matchKind, ingredients: matched.map(ingredientName), ingredientKeys: med.ingredients.map((ingredient) => ingredient.key), ruleTier, readAs: read.readAs },
        watch_for: ['swelling of the lips, face or throat', 'trouble breathing', 'a rash with feeling faint'],
        evidence_label: EVIDENCE_LABEL.studies, source_ids: sources,
        questions_for_doctor: [`Is ${med.medicine.name} OK with ${v.possessive} ${allergy} allergy?`], allow_stop: false,
        clinical: `${med.medicine.name} with documented ${allergy} allergy (${direct || classMatch ? 'same class' : 'possible cross-reactivity'}).` });
    }
  }

  // The same ingredient in more than one product, and daily totals (RS5). A
  // combination counts with its own amount of it: co-codamol's paracetamol with Panadol's.
  /** Products over an ingredient's limit only together, by ingredient: their dose lines say so (N18). */
  const overTogether = new Map<Resolved, Map<string, { sum: number; max: number; others: string[]; combination: boolean }>>();
  const byIngredient = new Map<string, Resolved[]>();
  for (const med of resolved) for (const ingredient of med.ingredients) {
    byIngredient.set(ingredient.key, [...(byIngredient.get(ingredient.key) ?? []), med]);
  }
  for (const [key, meds] of byIngredient) {
    if (meds.length < 2 || key === 'caffeine') continue;
    const name = ingredientName(key);
    const totals = meds.map((med) => dailyMg(med, key));
    const known = totals.every((total) => total !== null);
    const sum = known ? (totals as number[]).reduce((acc, value) => acc + value, 0) : null;
    const max = INGREDIENT_BY_KEY.get(key)?.maxDailyMg ?? null;
    const over = sum !== null && max !== null && sum > max;
    // With a combination among them, each dose line names the ingredient: "about 5000 mg of paracetamol a day in total".
    const combination = meds.some((med) => dosedIngredients(med).length > 1);
    if (over) {
      for (const med of meds) {
        const byKey = overTogether.get(med) ?? new Map();
        byKey.set(key, { sum: sum!, max: max!, others: meds.filter((other) => other !== med).map((other) => other.medicine.name), combination });
        overTogether.set(med, byKey);
      }
    }
    // Over the limit is same-day (RS5). Paracetamol twice with a total we can't work out is too,
    // and so is paracetamol twice for a child or someone of unknown age: the adult limit doesn't apply (H5).
    const tier: Tier = over || ((sum === null || !adultLimits) && key === 'paracetamol') ? 'today' : 'this_week';
    const list = meds.map((med) => med.medicine.name).join(' and ');
    push({ kind: 'duplicate', tier, ruleId: `duplicate-${key}`, idSeed: meds.map((med) => med.medicine.id ?? med.medicine.name).sort().join('+'),
      medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })),
      title: fitTitle(`Two medicines with ${name.toLowerCase()}`),
      why: over && max !== null
        ? `${list} both contain ${name.toLowerCase()}. Together that's about ${Math.round(sum!)} mg a day, more than the ${max} mg limit for adults.`
        : `${list} both contain ${name.toLowerCase()}, so it's easy to take too much.`,
      what_to_do: over
        ? [overMaximumAction(v, input), 'Check the labels for the same medicine name before taking both.']
        : [tierAction(tier, v, input), 'Check the labels for the same medicine name before taking both.'],
      watch_for: tooMuchSigns([key]),
      evidence_label: EVIDENCE_LABEL.studies, source_ids: key === 'paracetamol' ? ['nhs-paracetamol', 'label-paracetamol'] : maximumSources(key),
      questions_for_doctor: [`Should ${v.self ? 'I' : v.name} take both ${list}?`], allow_stop: false,
      // N4: over the maximum, no finding says to keep taking any of them.
      noKeepTaking: over || (tier === 'today' && key === 'paracetamol'), facts: { ingredient: name, total: sum, max },
      clinical: `Duplicate ingredient ${name}${sum !== null ? `, estimated ${Math.round(sum)} mg/24 h${max ? ` (adult max ${max})` : ''}` : ''}.` });
  }

  // Paracetamol near the usual adult maximum after 75, or under 50 kg: worth asking about a lower one (pharmacist note).
  const paracetamol = resolved.filter((med) => med.ingredients.some((ingredient) => ingredient.key === 'paracetamol'));
  const paracetamolDaily = paracetamol.map((med) => dailyMg(med, 'paracetamol'));
  const paracetamolTotal = paracetamol.length && paracetamolDaily.every((mg) => mg !== null) ? (paracetamolDaily as number[]).reduce((sum, mg) => sum + mg, 0) : null;
  const light = input.subject.weightKg !== null && input.subject.weightKg !== undefined && input.subject.weightKg < 50;
  const over75 = ageRange !== null && ageRange.max >= 75;
  if (trace) trace.weightReadBy = paracetamol.map((med) => ({ id: med.medicine.id, name: med.medicine.name }));
  if (paracetamolTotal !== null && paracetamolTotal > 3000 && paracetamolTotal <= 4000 && (light || over75)) {
    push({ kind: 'dose', tier: 'good_to_know', ruleId: 'paracetamol-lower-maximum', idSeed: paracetamol.map((med) => med.medicine.id ?? med.medicine.name).sort().join('+'),
      medicines: paracetamol.map((med) => ({ id: med.medicine.id, name: med.medicine.name })),
      title: 'Paracetamol: ask about a lower daily maximum',
      why: `About ${Math.round(paracetamolTotal)} mg of paracetamol a day is near the usual adult maximum. ${light ? 'Under 50 kg' : 'After 75'}, a lower maximum is often advised.`,
      what_to_do: [`Ask ${v.possessive} pharmacist whether a lower daily maximum would be better.`],
      watch_for: [], evidence_label: EVIDENCE_LABEL.studies, source_ids: ['nhs-paracetamol', 'label-paracetamol'],
      questions_for_doctor: ['Should I have a lower daily maximum of paracetamol?'], allow_stop: false,
      facts: { total: paracetamolTotal, reason: light ? 'weight_under_50_kg' : 'age_75_or_over', weightKg: input.subject.weightKg ?? null },
      clinical: `Paracetamol about ${Math.round(paracetamolTotal)} mg/24 h with ${light ? 'body weight < 50 kg' : 'age 75 or over'}: consider a lower maximum (e.g. 3 g/day).` });
  }

  // Doses against the label maximum. The label limits are for adults: for a
  // child, or someone of unknown age, a dose is never "within the usual range" (H5).
  const doseCheck: CheckResult['dose_check'] = [];
  const ageBlock = !ageRange ? 'Not checked: we need an age to check doses.' : !adultLimits ? 'Not checked: adult dose limits don\'t apply to children.' : null;
  for (const med of resolved) {
    const dosed = dosedIngredients(med);
    if (!dosed.length) continue;
    const line = (status: CheckResult['dose_check'][number]['status'], plain: string, kidney?: KidneyLimitOutput) =>
      doseCheck.push({ medicine: med.medicine.name, medicine_id: med.medicine.id, status, plain, ...(kidney ? { kidney_limit: kidney } : {}) });
    if (med.dose.problem === 'topical') {
      line('not_enough_info', DOSE_PROBLEM_PLAIN.topical);
      continue;
    }
    // Nothing in it has a label maximum, or its strength is the pack's total for all its medicines (Augmentin "625 mg").
    const limited = limitedIngredients(med);
    if (!limited.length || med.packTotal) {
      line('not_enough_info', ageBlock ?? 'We can\'t check this dose automatically.');
      continue;
    }
    // A combination is checked like any other medicine, one ingredient at a time, and its lines name the ingredient.
    const combination = dosed.length > 1;
    const parts = limited.map((ingredient) => ({ key: ingredient.key, ingredient: INGREDIENT_BY_KEY.get(ingredient.key)!, total: dailyMg(med, ingredient.key) }));
    const unknown = parts.find((part) => part.total === null);
    // kidneyDoseLimits (CHK-11): the label's kidney-adjusted maximum, for adults.
    const kidneyPart = hooks?.kidneyDose && adultLimits ? parts.find((part) => part.key === 'metformin') : undefined;
    const kidney = kidneyPart ? hooks!.kidneyDose!({ key: kidneyPart.key, total: kidneyPart.total, medicine: med.medicine.name }) : null;
    if (unknown) {
      if (kidney?.contraindicated) line(kidney.status, kidney.plain, kidney.kidney_limit);
      else line('not_enough_info', ageBlock ?? DOSE_PROBLEM_PLAIN[med.doses.get(unknown.key)?.problem ?? 'no_strength']);
      continue;
    }
    // The lower over-the-counter ceiling applies unless the medicine was prescribed (H2: never assume where it came from).
    const prescribed = med.medicine.origin === 'prescribed' || med.medicine.origin === 'hospital';
    const over: string[] = [];
    const capped: string[] = [];
    /** Each ingredient over its own maximum: one same-day finding for the medicine names them all (pharmacist question 2). */
    const tooMuch: Array<{ key: string; name: string; total: number; max: number }> = [];
    for (const { key, ingredient, total: known } of parts) {
      const total = known!;
      const maxDailyMg = ingredient.maxDailyMg!;
      const name = ingredient.name.toLowerCase();
      const of = combination ? ` of ${name}` : '';
      const together = overTogether.get(med)?.get(key);
      const otcCap = ingredient.otcMaxDailyMg && !prescribed ? ingredient.otcMaxDailyMg : null;
      if (total > maxDailyMg) {
        // More than the adult maximum is too much at any age, so this is reported whatever the age.
        over.push(`About ${Math.round(total)} mg${of} a day is more than the label's ${maxDailyMg} mg for adults.`);
        tooMuch.push({ key, name, total, max: maxDailyMg });
      } else if (together) {
        const ofAll = combination || together.combination ? ` of ${name}` : '';
        over.push(`With ${joinNames(together.others)}, about ${Math.round(together.sum)} mg${ofAll} a day in total, more than the label's ${together.max} mg for adults.`);
      } else if (otcCap && total > otcCap) {
        capped.push(`More than the ${otcCap} mg${of} a day allowed without a prescription.`);
      }
    }
    if (tooMuch.length) {
      // N4: more than the label maximum is same-day for every medicine, held until a pharmacist or doctor has been asked. One
      // finding for the medicine, however many of its ingredients are over: one regimen, one call before the next dose.
      const amounts = joinNames(tooMuch.map((part) => `${Math.round(part.total)} mg${combination ? ` of ${part.name}` : ''}`));
      const keys = tooMuch.map((part) => part.key);
      const maximums = `label maximum${tooMuch.length > 1 ? 's' : ''}`;
      const named = combination ? `${med.medicine.name}: more ${joinNames(tooMuch.map((part) => part.name))} than the ${maximums}` : `${med.medicine.name}: more than the ${maximums}`;
      push({ kind: 'dose', tier: 'today', ruleId: `dose-max-${keys.join('+')}`, idSeed: med.medicine.id ?? med.medicine.name,
        medicines: [{ id: med.medicine.id, name: med.medicine.name }],
        // Too long ("Tylenol with codeine: more paracetamol and codeine than..."): the medicine and the maximums, in fewer words (R2).
        title: fitTitle(named.length <= 70 ? named : `${med.medicine.name}: over the ${maximums}`),
        why: tooMuch.length > 1
          ? `As entered, that's about ${amounts} a day. The label maximums for adults are ${joinNames(tooMuch.map((part) => `${part.max} mg`))}.`
          : `As entered, that's about ${amounts} a day. The label maximum for adults is ${tooMuch[0].max} mg in 24 hours.`,
        what_to_do: [overMaximumAction(v, input),
          // Over both maximums, the three signs leave out paracetamol's liver sign: no signs must never read as safe (re-review, item b).
          ...(keys.length > 1 && keys.includes('paracetamol') ? [`Too much paracetamol can harm the liver before any signs appear, so make this call today even if ${
            v.self ? 'you feel' : v.object === 'her' ? 'she feels' : v.object === 'him' ? 'he feels' : 'they feel'} well.`] : []),
          'Check the dose on the label or prescription in case it was entered wrongly.'], noKeepTaking: true,
        watch_for: tooMuchSigns(keys),
        evidence_label: EVIDENCE_LABEL.studies, source_ids: [...new Set(keys.flatMap(maximumSources))],
        questions_for_doctor: [combination ? `Is ${amounts} a day from ${med.medicine.name} right?` : `Is ${amounts} a day of ${med.medicine.name} right?`],
        allow_stop: false, facts: { parts: tooMuch.map((part) => ({ ingredient: part.name, total: part.total, max: part.max })) },
        clinical: `${med.medicine.name}: ${tooMuch.map((part) => `${combination ? `${part.name} ` : ''}${Math.round(part.total)} mg/24 h exceeds adult label max ${part.max} mg`).join('; ')}.` });
    }
    if (over.length) line('above_limit', kidney && kidney.status !== 'within_usual_range' ? `${over.join(' ')} ${kidney.plain}` : over.join(' '), kidney?.kidney_limit);
    else if (kidney) line(kidney.status, kidney.plain, kidney.kidney_limit);
    else if (ageBlock) line('not_enough_info', ageBlock);
    else if (capped.length) {
      line('check_with_pharmacist', med.medicine.origin === 'over_the_counter' ? capped.join(' ')
        : `${capped.join(' ')} If a doctor didn't prescribe this dose, ask ${v.possessive} pharmacist.`);
    } else {
      // This only compares entered amounts with catalogue maxima. It does not
      // inspect the person's label, establish a minimum, or approve their dose.
      // Keep the status token for existing clients; state the narrower evidence.
      const qualification = 'This does not confirm the right dose to take.';
      line('within_usual_range', limited.length === dosed.length
        ? `The daily amount entered does not exceed the general adult maximum checked here. ${qualification}`
        : `The daily amount${limited.length > 1 ? 's' : ''} of ${joinNames(limited.map((ingredient) => ingredientName(ingredient.key).toLowerCase()))} entered ${limited.length > 1 ? 'do' : 'does'} not exceed the general adult maximum${limited.length > 1 ? 's' : ''} checked here. Other ingredients were not fully checked. ${qualification}`);
    }
  }

  // Older adults (RS8): worth reviewing, never stop. "Just the year": anyone who reaches 65 this year counts (M2).
  if (ageRange && ageRange.max >= 65) {
    for (const med of resolved) for (const rule of OLDER_ADULT_RULES) {
      if (!hits(med, rule.match).length) continue;
      push({ kind: 'older_adult', tier: rule.tier, ruleId: rule.id, idSeed: med.medicine.id ?? med.medicine.name,
        medicines: [{ id: med.medicine.id, name: med.medicine.name }],
        title: fitTitle(`${med.medicine.name}: worth reviewing after 65`), why: fill(rule.why, med.medicine.name),
        what_to_do: [tierAction(rule.tier, v, input), keepTaking], watch_for: rule.watchFor.slice(0, 3),
        evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources, questions_for_doctor: [fill(rule.question, med.medicine.name)],
        allow_stop: false, clinical: `${med.medicine.name}: ${rule.clinical}`, facts: { age: input.subject.age, ageRange } });
    }
  }

  // Children (H5): medicines that aren't for them, by the youngest they could be. Call before the next dose; never "stop", never "keep giving".
  if (ageRange) {
    for (const rule of CHILD_RULES) {
      if (ageRange.min >= rule.under) continue;
      for (const med of resolved) {
        if (!hits(med, rule.match).length) continue;
        if (rule.nameWords && !rule.nameWords.some((word) => wholeWord(word).test(normalizeText(med.medicine.name)))) continue;
        push({ kind: 'child', tier: rule.tier, ruleId: rule.id, idSeed: med.medicine.id ?? med.medicine.name,
          medicines: [{ id: med.medicine.id, name: med.medicine.name }],
          title: fitTitle(`Call ${v.possessive} doctor today about ${med.medicine.name}`), why: fill(rule.why, med.medicine.name),
          what_to_do: [tierAction(rule.tier, v, input)], watch_for: rule.watchFor.slice(0, 3),
          evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources, questions_for_doctor: [fill(rule.question, med.medicine.name)],
          allow_stop: false, noKeepTaking: true, clinical: `${med.medicine.name}, age ${input.subject.age ?? `${ageRange.min} to ${ageRange.max}`}: ${rule.clinical}`,
          facts: { age: input.subject.age, ageRange, under: rule.under } });
      }
    }
  }

  // Lab results (L4, L5, H3): only published thresholds. Each group is a
  // ladder for one test, most urgent first; only the first rule that applies is reported.
  const latestLab = new Map<string, CheckLab>();
  for (const lab of [...input.labs].sort((x, y) => x.observedAt.localeCompare(y.observedAt))) latestLab.set(lab.code, lab);
  const labNotes: CheckResult['what_we_couldnt_check'] = [];
  const bleedingSigns = screen.ids.includes('bleeding') || screen.ids.includes('stroke');
  const targetNoted = new Set<string>();
  const thresholdFor = (rule: (typeof LAB_RULES)[number]): number => {
    const { threshold } = rule.bound;
    if (typeof threshold === 'number') return threshold;
    const target = input.labTargets[rule.labCode];
    if (!target) return threshold.otherwise;
    if (targetProblem(rule.labCode, target)) {
      // A target outside the plausible range (30 for 3.0) would silence alerts: ignored, and said so.
      if (!targetNoted.has(rule.labCode)) {
        targetNoted.add(rule.labCode);
        const name = findLabTest(rule.labCode)?.name ?? rule.labCode;
        const saved = [target.low !== null ? `from ${target.low}` : '', target.high !== null ? `up to ${target.high}` : ''].filter(Boolean).join(' ');
        labNotes.push({ item: `${name} target`, medicine_id: null, reason: 'lab',
          plain: `The ${name} target saved (${saved}) doesn't look right, so the usual target was used. Ask ${v.possessive} clinic for the target and correct it.` });
      }
      return threshold.otherwise;
    }
    return (threshold.target === 'high' ? target.high : target.low) ?? threshold.otherwise;
  };
  for (const group of [...new Set(LAB_RULES.map((rule) => rule.group))]) {
    const ladder = LAB_RULES.filter((candidate) => candidate.group === group);
    const lab = latestLab.get(ladder[0].labCode);
    if (!lab) {
      const meds = trace ? resolved.filter((candidate) => ladder.some((rule) => rule.match.some((matcher) => hits(candidate, matcher).length))) : [];
      if (meds.length) trace?.missingLabs.push({ labCode: ladder[0].labCode, medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })) });
      continue;
    }
    // N16: a result outside what is possible (a potassium of 55 for 5.5) is most likely a typing slip, so it
    // never brings an emergency. M1: but on the side the rules act on it could be right (an INR of 25 does
    // happen), so it is never ignored either: it is same-day at most, first saying to check how it was entered.
    const implausible = implausibleResult(lab.code, lab.value);
    // Every rule the result could meet, most urgent first.
    const candidates: Array<{ rule: (typeof LAB_RULES)[number]; meds: Resolved[]; other?: Resolved; crossing: 'yes' | 'unknown' }> = [];
    for (const rule of ladder) {
      const meds = resolved.filter((candidate) => rule.match.some((matcher) => hits(candidate, matcher).length));
      if (!meds.length && !rule.anyMedicine) continue;
      if (hooks?.allow && !hooks.allow(rule.id)) {
        const needed = !rule.alsoNeeds || resolved.some((candidate) => !meds.includes(candidate) && hits(candidate, rule.alsoNeeds!).length);
        if (needed && crosses(lab, rule.bound.direction, thresholdFor(rule), rule.bound.inclusive) !== 'no') {
          trace?.skipped.push({ ruleId: rule.id, medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })) });
        }
        continue;
      }
      trace?.labsRead.add(lab.code);
      const other = rule.alsoNeeds ? resolved.find((candidate) => !meds.includes(candidate) && hits(candidate, rule.alsoNeeds!).length) : undefined;
      if (rule.alsoNeeds && !other) continue;
      const crossing = crosses(lab, rule.bound.direction, thresholdFor(rule), rule.bound.inclusive);
      if (crossing !== 'no') candidates.push({ rule, meds, other, crossing });
    }
    if (implausible && !candidates.some((candidate) => candidate.crossing === 'yes')) {
      if (ladder.some((rule) => rule.anyMedicine || resolved.some((candidate) => rule.match.some((matcher) => hits(candidate, matcher).length)))) {
        const name = findLabTest(lab.code)?.name ?? lab.code.toUpperCase();
        labNotes.push({ item: `${name} result`, medicine_id: null, reason: 'lab',
          plain: `The latest ${name} result (${lab.comparator ?? ''}${lab.value}) doesn't look right, so it wasn't used. Check how it was entered, or ask ${v.possessive} doctor.` });
      }
      continue;
    }
    if (!candidates.length) continue;
    const test = findLabTest(lab.code)?.name ?? lab.code.toUpperCase();
    const printed = `${lab.comparator ?? ''}${lab.value}`;
    const date = lab.observedAt.slice(0, 10);
    const against = (meds: Resolved[]) => (meds.length ? ` against ${meds.map((med) => med.medicine.name).join(' and ')}` : '');
    const ageDays = (Date.parse(`${input.today}T00:00:00Z`) - Date.parse(lab.observedAt)) / 86_400_000;
    // L-2: a result older than 3 months isn't treated as current.
    if (ageDays > STALE_RESULT_DAYS) {
      labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab',
        plain: `The latest ${test} result is from ${date}, more than 3 months ago, so it wasn't checked${against(candidates[0].meds)} as a current result. Ask ${v.possessive} doctor whether it needs repeating.` });
      continue;
    }
    if (!candidates.some((candidate) => candidate.crossing === 'yes')) {
      labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab',
        plain: `The latest ${test} result was printed as "${printed}", not as an exact number, so it couldn't be fully checked${against(candidates[0].meds)}. Ask ${v.possessive} pharmacist.` });
      continue;
    }
    // M-6: a value printed as a bound past a threshold may be past a higher one too ("INR >4" could be 9),
    // so the most urgent rule it could meet is used, and the note says why.
    const { rule, meds, other, crossing } = candidates[0];
    if (crossing === 'unknown') {
      labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab',
        plain: `The latest ${test} result was printed as "${printed}", not as an exact number, so the advice is for the highest it could be.` });
    }
    let tier = rule.tier;
    if (rule.tierWithBleeding && bleedingSigns) tier = rule.tierWithBleeding;
    const urgent = tier;
    if (rule.recentDays !== undefined && rule.olderTier && ageDays > rule.recentDays) tier = rule.olderTier;
    // L-2: older than two weeks is same-day at most, and says how old it is.
    const dated = ageDays > DATED_RESULT_DAYS;
    if (dated && TIER_RANK[tier] > TIER_RANK.today) tier = 'today';
    // M1: a result that doesn't look right is same-day at most.
    if (implausible && TIER_RANK[tier] > TIER_RANK.today) tier = 'today';
    // Too old for "call 112 now": same day, with the hospital if no one can be reached (pharmacist to confirm).
    const hospitalLine = urgent === 'get_help_now' && tier === 'today'
      ? [implausible ? 'If the result is right and no one can be reached today, go to the nearest hospital today.' : 'If they can\'t be reached today, go to the nearest hospital today.']
      : [];
    const a = meds.map((med) => med.medicine.name).join(' and ');
    const b = other?.medicine.name ?? '';
    const words = (text: string) => fill(text, a, b).replace(/\{test\}/g, test).replace(/\{possessive\}/g, v.possessive)
      .replace(/\{action\}/g, tierAction(tier, v, input)).replace(/\{emergency\}/g, tierAction('get_help_now', v, input));
    const subject = meds.length ? `${a} and the latest ${test} result` : `the latest ${test} result`;
    const title = rule.title && meds.length ? words(rule.title)
      : tier === 'get_help_now' ? `Get help now: ${subject}` : tier === 'today' ? `Call today: ${subject}` : `${subject.charAt(0).toUpperCase()}${subject.slice(1)}`;
    // With no medicine that raises it, there is no "next dose" to call before.
    const actions = meds.length ? rule.actions : [tier === 'get_help_now' ? '{emergency}' : 'Call {possessive} doctor today about this result.'];
    const lines = actions ? actions.map(words) : [tierAction(tier, v, input), keepTaking];
    for (const line of hospitalLine) if (!lines.includes(line)) lines.splice(1, 0, line);
    if (implausible) lines.unshift(`First check the ${test} result was typed in correctly: it says ${printed}. If it was typed wrongly, correct it and check again.`);
    if (tier === 'get_help_now' && lines[0] !== tierAction('get_help_now', v, input)) lines.unshift(tierAction('get_help_now', v, input));
    if (tier === 'get_help_now' && rule.sign) helpSigns.push(rule.sign);
    // N17: a bound past one threshold "could be" past the higher one; it isn't said to be.
    const threshold = thresholdFor(rule);
    const level = typeof rule.bound.threshold !== 'number' ? 'above the target'
      : rule.bound.direction === 'above' ? (rule.bound.inclusive ? `${threshold} or more` : `above ${threshold}`)
        : (rule.bound.inclusive ? `${threshold} or less` : `below ${threshold}`);
    const why = crossing === 'unknown'
      ? `The latest ${test} was printed as "${printed}", not as an exact number, so it could be ${level}. The advice is for the highest it could be.`
      : implausible
        ? `The latest ${test} result (${printed}) doesn't look right, and may have been typed wrongly. If it is right, it is ${level}. ${fill(meds.length ? rule.why : rule.whyAlone ?? rule.why, a, b)}`
        : fill(meds.length ? rule.why : rule.whyAlone ?? rule.why, a, b);
    push({ kind: 'lab', tier, ruleId: rule.id,
      ...(lab.evidence ? { lab_evidence: structuredClone(lab.evidence) } : {}), idSeed: `${meds.map((med) => med.medicine.id ?? med.medicine.name).join('+')}|${lab.code}|${lab.observedAt}`,
      medicines: [...meds, ...(other ? [other] : [])].map((med) => ({ id: med.medicine.id, name: med.medicine.name })),
      title: fitTitle(title), why: dated ? `${why} This result is from ${date}.` : why,
      what_to_do: lines,
      watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources,
      questions_for_doctor: [fill(meds.length ? rule.question : 'What should happen about my {test} result?', a, b).replace(/\{test\}/g, test)],
      allow_stop: false, noKeepTaking: rule.keepTaking === false, soon: rule.soon,
      clinical: `${rule.clinical} Latest ${lab.code} ${printed} on ${date}.`,
      facts: { code: lab.code, test, printed, observedAt: lab.observedAt, threshold, crossing, implausible, calculated: Boolean(hooks?.calculatedLab) && hooks?.calculatedLab === lab,
        unit: findLabTest(lab.code)?.unit ?? null, a, b } });
  }

  // A lab result that changes what a medicine means also changes what its dose means:
  // "within the usual range" would read as reassurance next to "the label says not to use it".
  for (const finding of findings.filter((candidate) => candidate.kind === 'lab')) {
    const lab = finding.ruleId.startsWith('lab-inr') ? 'INR' : finding.ruleId.startsWith('lab-potassium') ? 'potassium' : 'kidney';
    for (const dose of doseCheck) {
      if (dose.status !== 'within_usual_range' || !finding.medicines.some((medicine) => medicine.id === dose.medicine_id && medicine.name === dose.medicine)) continue;
      // kidneyDoseLimits: the line already compares the dose with the label's limit for this kidney result.
      if (dose.kidney_limit) continue;
      dose.status = 'check_with_pharmacist';
      dose.plain = `The usual dose may not apply with the latest ${lab} result. Ask ${v.possessive} pharmacist.`;
    }
  }

  // Tests to ask about (RS9): only rules with a source.
  const tests: CheckResult['tests_to_ask_about'] = [];
  for (const rule of MONITORING_RULES) {
    if (!resolved.some((med) => hits(med, rule.match).length)) continue;
    if (hooks?.allow && !hooks.allow(rule.id)) {
      trace?.skipped.push({ ruleId: rule.id, medicines: resolved.filter((med) => hits(med, rule.match).length).map((med) => ({ id: med.medicine.id, name: med.medicine.name })) });
      continue;
    }
    if (trace) {
      // FND-7: every medicine that needs this test, across the rules that ask for it.
      const entry = trace.monitoring.get(rule.test) ?? { medicines: [], ruleIds: [] };
      for (const med of resolved.filter((candidate) => hits(candidate, rule.match).length)) {
        if (!entry.medicines.some((known) => known.id === med.medicine.id && known.name === med.medicine.name)) entry.medicines.push({ id: med.medicine.id, name: med.medicine.name });
      }
      entry.ruleIds.push(rule.id);
      trace.monitoring.set(rule.test, entry);
    }
    if (tests.some((test) => test.test === rule.test)) continue;
    const lab = rule.labCode ? latestLab.get(rule.labCode) : undefined;
    const ageDays = lab ? (Date.parse(`${input.today}T00:00:00Z`) - Date.parse(lab.observedAt)) / 86_400_000 : null;
    const status = !lab ? 'no_result' : rule.everyDays !== null && ageDays !== null && ageDays > rule.everyDays ? 'due' : 'up_to_date';
    tests.push({ test: rule.test, why: rule.why, when: rule.when, source_id: rule.sources[0], status, last_result_at: lab?.observedAt ?? null });
    rule.sources.forEach((source) => usedSources.add(source));
  }

  // What we couldn't check (RS7): named, never skipped.
  const couldnt: CheckResult['what_we_couldnt_check'] = [];
  if (!screen.screened) {
    couldnt.push({ item: 'Symptoms', medicine_id: null, reason: 'symptoms_not_screened',
      plain: `We can only check symptoms written in English for now, so these weren't checked. ${v.self ? 'If you are' : `If ${v.name} is`} very unwell, ${midSentence(tierAction('get_help_now', v, input))}` });
  } else if (screen.unsure) {
    // H-1.7: words about dying, death or killing we couldn't be sure of: no label, but the emergency number.
    couldnt.push({ item: 'Symptoms', medicine_id: null, reason: 'symptoms_unclear',
      plain: `We couldn't be sure what some of these symptoms meant. ${v.self ? 'If you are' : `If ${v.name} is`} in danger or very unwell, ${midSentence(tierAction('get_help_now', v, input))}` });
  }
  for (const med of resolved) {
    if (med.ingredients.length) continue;
    const unknownMix = !med.medicine.contentsKnown;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: unknownMix ? 'contents_unknown' : 'not_recognised',
      plain: unknownMix
        ? `What's in ${med.medicine.name} isn't known, so it couldn't be checked against ${v.possessive} other medicines. Show it to ${v.possessive} pharmacist.`
        : `We couldn't match "${med.medicine.name}" to a medicine we know, so it wasn't checked. Show it to ${v.possessive} pharmacist.` });
  }
  for (const med of resolved) {
    if (!med.ingredients.length || !med.unmatchedWords.length) continue;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'partly_recognised',
      plain: `We recognised ${med.ingredients.filter((ingredient) => !INGREDIENT_BY_KEY.get(ingredient.key)?.companion).map((ingredient) => ingredientName(ingredient.key)).join(' and ')} in ${med.medicine.name}, but not everything in it, so it was only partly checked. Show it to ${v.possessive} pharmacist.` });
  }
  for (const med of resolved) {
    if (!med.ingredients.length) continue;
    const unruled = unruledIngredients(med);
    if (!unruled.length) continue;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'no_rules',
      plain: `We recognise ${unruled.map((ingredient) => ingredientName(ingredient.key)).join(' and ')}, but we don't have checks for how ${unruled.length > 1 ? 'they mix' : 'it mixes'} with other medicines yet. Show ${med.medicine.name} to ${v.possessive} pharmacist.` });
  }
  // Gap 2: a strength entered that can't be read, for a medicine with a daily maximum. Its dose couldn't be checked against the
  // label, which may hide a same-day finding, so it counts like a medicine we couldn't check: never "No problems found".
  for (const med of resolved) {
    if (!med.strengthUnreadable || med.dose.problem === 'topical') continue;
    if (!limitedIngredients(med).some((ingredient) => dailyMg(med, ingredient.key) === null)) continue;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'strength_unreadable',
      plain: `We couldn't read the strength "${quoted(med.medicine.strength ?? '')}" entered for ${med.medicine.name}, so we couldn't check the dose against the label. Check the strength on the pack and correct it.` });
  }
  // M1: a liquid's strength that isn't per ml ("120 mg"), for a medicine with a daily maximum: its dose in ml couldn't be
  // worked out, so, like a strength we can't read, it is never "No problems found".
  for (const med of resolved) {
    if (med.dose.problem === 'topical' || !limitedIngredients(med).some((ingredient) => med.doses.get(ingredient.key)?.problem === 'strength_not_per_ml')) continue;
    const written = med.strengthFrom === 'name' ? `in "${quoted(med.medicine.name)}"` : `"${quoted(med.medicine.strength ?? '')}" entered for ${med.medicine.name}`;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'strength_not_per_ml',
      plain: `We couldn't use the strength ${written}: for a liquid we need the strength per 5 ml, so we couldn't check the dose against the label. Check the pack for the strength per 5 ml (for example 120 mg/5 ml) and correct it.` });
  }
  // Ruling A: an adult's dose that can't be turned into mg (spoons or drops, "1 dose" of a liquid, mg of a combination, a most
  // a day with no unit), for a medicine with a maximum whose dose would otherwise be checked: like a strength we can't use, it is
  // never "No problems found". Children's doses aren't checked at all (the age line says so), and a strength or frequency that
  // is missing, or a pack total, keeps its own dose line: missing, not unusable.
  for (const med of resolved) {
    if (!adultLimits || med.dose.problem === 'topical' || !med.strengthFrom || med.packTotal) continue;
    if (!med.medicine.timesPerDay && !med.medicine.prnMaxPerDay) continue;
    const problem = limitedIngredients(med).map((ingredient) => med.doses.get(ingredient.key)?.problem).find((found) => found && DOSE_NOT_IN_MG[found]);
    if (!problem) continue;
    couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'dose_unit_unknown', plain: DOSE_NOT_IN_MG[problem]!(med.medicine.name) });
  }
  for (const ended of input.endedMedicines ?? []) {
    couldnt.push({ item: ended.name, medicine_id: ended.id, reason: 'course_ended',
      plain: `${ended.name} wasn't checked because its course ended on ${ended.endedOn}. If it's still being taken, update its end date.` });
  }
  couldnt.push(...allergyNotes);
  if (input.allergiesStatus === 'not_sure') {
    couldnt.push({ item: 'Allergies', medicine_id: null, reason: 'allergy',
      plain: `We don't have a confirmed allergy answer, so the allergy check is incomplete. Review it with ${v.possessive} pharmacist.` });
  }
  if (input.conditionsStatus === 'not_sure') {
    couldnt.push({ item: 'Health conditions', medicine_id: null, reason: 'condition',
      plain: `We don't have a confirmed health-condition answer. We don't yet check medicines against health conditions. Review ${v.possessive} health profile with ${v.possessive} pharmacist.` });
  }
  // H5: what no rule reads yet is said, never implied as checked.
  // N13: with no age, a medicine that isn't for children at some age couldn't be checked at all.
  const childRestricted = ageRange ? [] : resolved.filter((med) => CHILD_RULES.some((rule) => hits(med, rule.match).length
    && (!rule.nameWords || rule.nameWords.some((word) => wholeWord(word).test(normalizeText(med.medicine.name))))));
  if (resolved.length && !ageRange) {
    couldnt.push({ item: 'Age', medicine_id: null, reason: 'age',
      plain: childRestricted.length
        ? `We don't know ${v.owner} age, so we couldn't check whether ${joinNames(childRestricted.map((med) => med.medicine.name))} ${childRestricted.length > 1 ? 'are' : 'is'} right for ${v.object}, or any doses. Add it to the health profile.`
        : `We don't know ${v.owner} age, so doses, and medicines that aren't for children, weren't checked. Add it to the health profile.` });
  } else if (!adultLimits && resolved.some((med) => dosedIngredients(med).length && med.dose.problem !== 'topical')) {
    couldnt.push({ item: 'Doses', medicine_id: null, reason: 'age',
      plain: `Doses weren't checked: adult dose limits don't apply to children. Ask ${v.possessive} pharmacist to check each dose.` });
  }
  if (input.reproductive) {
    const [item, plain] = input.reproductive === 'pregnant'
      ? ['Pregnancy', `We don't yet check medicines in pregnancy. Ask ${v.possessive} doctor, midwife or pharmacist about each medicine.`]
      : input.reproductive === 'breastfeeding'
        ? ['Breastfeeding', `We don't yet check medicines for breastfeeding. Ask ${v.possessive} doctor or pharmacist about each medicine.`]
        : ['Planning a pregnancy', `We don't yet check medicines for someone planning a pregnancy. Ask ${v.possessive} doctor or pharmacist about each medicine.`];
    couldnt.push({ item, medicine_id: null, reason: 'pregnancy', plain });
  }
  for (const condition of input.conditions) {
    couldnt.push({ item: condition, medicine_id: null, reason: 'condition',
      plain: `We don't yet check medicines against health conditions such as ${condition}. Ask ${v.possessive} pharmacist.` });
  }
  couldnt.push(...labNotes);
  // An allergy we couldn't read may hide a same-day match, so it counts like a medicine we couldn't check,
  // and so does a missing age next to a medicine that isn't for children at some ages (N13).
  const medicineCouldnt = couldnt.filter((item) => MEDICINE_REASONS.has(item.reason) || allergyGaps.has(item.item)
    || (item.reason === 'age' && childRestricted.length > 0));
  // A condition or pregnancy recorded in the form has not been evaluated just because
  // no medicine-pair rule matched. Make that limitation visible before the details.
  const contextCouldnt = couldnt.filter((item) =>
    (resolved.length > 0 && (['age', 'pregnancy', 'condition', 'lab'].includes(item.reason)
      || (item.reason === 'allergy' && input.allergiesStatus === 'not_sure')))
    || item.reason === 'symptoms_not_screened' || item.reason === 'symptoms_unclear');
  const incomplete = medicineCouldnt.length > 0 || contextCouldnt.length > 0;
  if (medicineCouldnt.length) usedSources.add('marmar-unknown-product');

  findings.sort((x, y) => TIER_RANK[y.tier] - TIER_RANK[x.tier]);
  const top = findings.length ? findings[0].tier : undefined;
  const tier: VerdictTier = signs.length ? 'get_help_now'
    : top && top !== 'good_to_know' ? top : incomplete ? 'couldnt_check' : top ?? 'nothing_found';
  // No general "keep taking" line when any finding says to stop something or to call before the next dose (H-E).
  const holdKeepTaking = held.size > 0 || findings.some((finding) => finding.allow_stop || noKeepTaking.has(finding.id));
  const generalKeep = `Keep taking ${v.possessive} medicines until you've spoken to ${v.possessive} doctor or pharmacist.`;
  // Word the keep-taking markers: never name a medicine a finding says to stop or call about first.
  const keepNames = input.medicines.map((medicine) => medicine.name).filter((name) => !held.has(name));
  for (const finding of findings) {
    finding.what_to_do = finding.what_to_do.flatMap((line) => {
      if (line === KEEP_ALL) {
        // A rule about one pair cannot authorize continuation of every other
        // medicine when the person's history or another product is unassessed.
        if (incomplete) return [];
        if (!holdKeepTaking) return [generalKeep];
        return keepNames.length ? [`Keep taking ${v.possessive} ${joinNames(keepNames)} until you've spoken to ${v.possessive} doctor or pharmacist.`] : [];
      }
      const unless = /^\u0000unless-stopped:(.*)\u0001(.*)$/.exec(line);
      if (unless) return (JSON.parse(unless[1]) as string[]).every((name) => stopped.has(name)) ? [] : [unless[2]];
      const marker = /^\u0000keep:(until|plain):(.*)$/.exec(line);
      if (!marker) return [line];
      const names = (JSON.parse(marker[2]) as string[]).filter((name) => !held.has(name));
      if (!names.length) return [];
      return [marker[1] === 'until' ? `Keep taking ${v.possessive} ${joinNames(names)} until a doctor or pharmacist says otherwise.` : `Keep taking ${v.possessive} ${joinNames(names)}.`];
    });
    if (finding.teach_back) finding.teach_back = teachBack(finding.tier, finding.what_to_do[0] ?? tierAction(finding.tier, v, input), v, finding.id);
  }
  /**
   * What couldn't be checked, named plainly: "Herbal mix", "Part of Coartem"
   * when one of its ingredients was checked (N24), "The dose of Co-codamol"
   * when its strength couldn't be read or used (gap 2, M1), and a medicine
   * that isn't for children at some ages "without her age" (N13).
   */
  const couldntSubline = () => {
    // Reuse the existing specific limitation instead of inventing medical advice or
    // producing an empty medicine-name sentence for a person-level gap.
    if (!medicineCouldnt.length && contextCouldnt.length) return contextCouldnt[0].plain;
    const ageNames = childRestricted.map((med) => med.medicine.name);
    // A medicine that couldn't be checked at all without an age isn't also "part of" it (L7: Night Nurse).
    const items = medicineCouldnt.filter((item) => item.reason !== 'age' && !ageNames.includes(item.item));
    const parts = items.flatMap((item) => {
      // Only its dose, unless the medicine is already named for more than its dose.
      if (DOSE_REASONS.has(item.reason)) {
        return items.some((other) => !DOSE_REASONS.has(other.reason) && other.item === item.item && other.medicine_id === item.medicine_id) ? [] : [`the dose of ${item.item}`];
      }
      const med = resolved.find((candidate) => candidate.medicine.id === item.medicine_id && candidate.medicine.name === item.item);
      return [med && isChecked(med) && (item.reason === 'no_rules' || item.reason === 'partly_recognised') ? `part of ${item.item}` : item.item];
    });
    const unique = [...new Set(parts)];
    const sentence = (names: string[], tail: string) =>
      `${names.join(', ').replace(/^(?:part|the dose) of /, (start) => `${start.charAt(0).toUpperCase()}${start.slice(1)}`)} couldn't be checked${tail}.`;
    if (!unique.length) return sentence(childRestricted.map((med) => med.medicine.name), ` without ${v.possessive} age`);
    return sentence([...unique, ...childRestricted.map((med) => med.medicine.name).filter((name) => !unique.includes(name))], '');
  };
  // Listed as checked: medicines with at least one ingredient the rules know about.
  const checked = resolved.filter(isChecked).map((med) => med.medicine.name);
  const count = checked.length;
  const verdict = ((): CheckResult['verdict'] => {
    switch (tier) {
      case 'get_help_now': return { tier, headline: tierAction('get_help_now', v, input),
        subline: signs.length ? 'These signs need help straight away.' : fitSentences(findings[0].why), keep_taking_line: null };
      case 'today': return { tier, headline: `Call ${v.possessive} doctor or pharmacist today`, subline: fitSentences(findings[0].why), keep_taking_line: holdKeepTaking || incomplete ? null : generalKeep.slice(0, 100) };
      case 'this_week': return { tier, headline: findings.some((finding) => finding.tier === 'this_week' && soonFindings.has(finding.id))
        ? `Ask ${v.possessive} clinic or pharmacist in the next day or two`
        : `Ask ${v.possessive} pharmacist about ${findings.filter((f) => f.tier === 'this_week').length === 1 ? 'one thing' : 'a few things'} this week`, subline: fitSentences(findings[0].why), keep_taking_line: holdKeepTaking || incomplete ? null : generalKeep.slice(0, 100) };
      case 'good_to_know': return { tier, headline: 'Good to know', subline: fitSentences(findings[0].why), keep_taking_line: null };
      case 'couldnt_check': return { tier, headline: `We couldn't check everything`, subline: fitSentences(couldntSubline()), keep_taking_line: null };
      default: return { tier, headline: count === 0 ? 'No medicines to check yet' : 'No warnings from these limited checks',
        subline: couldnt.length ? '"Nothing found" doesn\'t mean safe: see what we checked, and what we couldn\'t.' : '"Nothing found" doesn\'t mean safe: here is exactly what we checked.',
        keep_taking_line: null };
    }
  })();

  const references = [...usedSources].map((id) => ({ id, name: CHECK_SOURCES[id].name, url: CHECK_SOURCES[id].url }));
  const flags = findings.filter((finding) => finding.tier !== 'good_to_know').map((finding) => ({ finding_id: finding.id,
    reference: finding.source_ids.map((id) => CHECK_SOURCES[id].name).join('; '), for_discussion: finding.clinical }));
  const summaryParts = [
    `${count} medicine${count === 1 ? '' : 's'} checked${input.subject.age ? `, age ${input.subject.age}` : ''}.`,
    ...flags.slice(0, 4).map((flag) => flag.for_discussion),
    couldnt.length ? `Not checked: ${couldnt.map((item) => item.item).join(', ')}.` : '',
    tests.filter((test) => test.status !== 'up_to_date').length ? `Monitoring to consider: ${tests.filter((test) => test.status !== 'up_to_date').map((test) => test.test).join(', ')}.` : '',
  ].filter(Boolean);

  const result: CheckResult = {
    engineVersion: ENGINE_VERSION,
    rulesVersion: RULES_VERSION,
    rulesReviewed: CHECK_SIGNED_OFF,
    verdict,
    get_help_now: { contact: input.emergency, show: tier === 'get_help_now', signs: [...signs, ...helpSigns].slice(0, 3), action: tierAction('get_help_now', v, input) },
    findings,
    tests_to_ask_about: tests,
    dose_check: doseCheck,
    // Conditions stay listed under what_we_couldnt_check until rules read them (H5); the field is kept, empty.
    what_we_checked: { medicines: checked, conditions: [], allergies: checkedAllergies, age: input.subject.age, references },
    what_we_couldnt_check: couldnt,
    for_the_doctor: { summary: summaryParts.join(' ').slice(0, 600), flags },
    language: 'en',
    language_fallback: !/^en(-|$)/i.test(input.language),
    wording: 'template',
  };
  return { result, held: [...held] };
}

/** Same input, same fingerprint: lets the app reuse a check offline (O1). */
export function checkFingerprint(input: CheckInput, options?: CheckOptions): string {
  // Symptom text is never stored, so it isn't hashed either: only the signs it matched, and whether it could be screened.
  const screen = screenSymptoms(input.symptoms, input.language);
  const canonical = JSON.stringify({
    s: [input.subject.kind, input.subject.age, input.subject.sex, input.subject.ageRange ?? null, input.subject.weightKg ?? null, input.reproductive ?? null],
    // Spoons or drops ("unknown-liquid") read differently from other measures we can't convert.
    m: input.medicines.map((med) => [normalizeText(med.name), med.strength, med.form, med.timesPerDay, med.unitsPerDose, med.liquidDose ? 'unknown-liquid' : med.doseUnit ?? 'unit',
      med.prnMaxPerDay, med.prnMaxUnit ?? null, med.origin, med.contentsKnown]).sort(),
    e: (input.endedMedicines ?? []).map((med) => [normalizeText(med.name), med.endedOn]).sort(),
    a: input.allergies.map(normalizeText).sort(), c: input.conditions.map(normalizeText).sort(),
    healthAnswers: [input.allergiesStatus ?? null, input.conditionsStatus ?? null],
    l: input.labs.map((lab) => [lab.code, lab.value, lab.comparator ?? null, lab.observedAt]).sort(),
    t: Object.keys(input.labTargets).sort().map((code) => [code, input.labTargets[code].low, input.labTargets[code].high]),
    y: [[...screen.ids].sort(), screen.screened, screen.unsure],
    // The emergency number and wording are in the result (N20).
    x: [input.emergency.number, input.emergency.extra ?? null, input.emergency.registryRevision ?? null, input.emergency.countryCode ?? null],
    // How old results are changes what they mean (L-2), so the day counts.
    d: input.today,
    r: RULES_VERSION,
    // A wording/coverage correction must not reuse a result from the old engine.
    engine: ENGINE_VERSION,
    // Options (Context only) change the result, and read allergy details and lab units: never on the app's fingerprint.
    ...(hasOptions(options) ? { options: fingerprintOptions(input, options) } : {}),
  });
  return createHash('sha256').update(canonical).digest('hex');
}

/** "Needs action" for care-partner alerts and lock-screen wording. */
export function needsAction(tier: VerdictTier): boolean {
  return tier === 'get_help_now' || tier === 'today';
}