marmar-context / src / domain / checks / options.ts
options.ts
Raw
import type { Tier } from './rules.js';

/**
 * Engine options for MARMAR Context (PRD v1.1, 8.5). All off unless set; with none on, runCheck gives the app exactly its
 * old result (tests/engine-default-equivalence.test.ts). Drafts: Context turns one on only after the clinical safety lead
 * signs it off (docs/engine-clinician-options.md).
 */
export interface CheckOptions {
  /** CHK-5: clinician_title and clinician_why on findings, and clinician_why on tests, from draft clinician wording. */
  clinicianWording?: boolean;
  /** CHK-6a: a medicine in a recorded allergy's class is at least `today`, and `get_help_now` when the reaction was severe or is unknown. */
  allergyTiers?: boolean;
  /** CHK-10: with creatinine and no eGFR, an eGFR calculated with CKD-EPI 2021 is used by the kidney rules and shown as calculated. */
  egfrFromCreatinine?: boolean;
  /** CHK-11: metformin's dose status uses the label's kidney-adjusted limits. */
  kidneyDoseLimits?: boolean;
  /** FND-7: each test to ask about names the medicines that need it. */
  monitoringMedicines?: boolean;
  /**
   * CHK-7: findings only from rules whose current content matches a recorded sign-off. `true` accepts every
   * recorded sign-off; a list accepts only those sign-off ids (an empty list accepts none).
   */
  signedOffOnly?: boolean | { acceptedSignOffs: readonly string[] };
  /** CHK-3, CHK-9: what was not checked: products with no rules, facts no rule read, facts missing, rules waiting for review. */
  coverage?: boolean;
  /** FND-2: the specific reference (label section or guideline recommendation, with its date) for each finding's rule. */
  specificReferences?: boolean;
}

/** Moves with any change to what these options do. Separate from ENGINE_VERSION, which the app's results carry. */
export const CLINICIAN_OPTIONS_VERSION = '0.1.0-draft';

export const OPTION_NAMES = ['clinicianWording', 'allergyTiers', 'egfrFromCreatinine', 'kidneyDoseLimits', 'monitoringMedicines',
  'signedOffOnly', 'coverage', 'specificReferences'] as const satisfies ReadonlyArray<keyof CheckOptions>;

/** Whether any option is on. With none, the engine takes exactly its default path. */
export function hasOptions(options: CheckOptions | undefined): options is CheckOptions {
  return Boolean(options) && OPTION_NAMES.some((name) => Boolean(options![name]));
}

/** The options that are on, for the result and its fingerprint. */
export function optionsInUse(options: CheckOptions): Record<string, unknown> {
  return Object.fromEntries(OPTION_NAMES.filter((name) => Boolean(options[name])).map((name) => [name, options[name]]));
}

/**
 * What a recorded allergy was like, when known (CHK-6a). `allergy` is the entry in CheckInput.allergies it describes,
 * matched without case or punctuation. Every field is optional: what is missing stays unknown.
 */
export interface AllergyDetail {
  allergy: string;
  /** As recorded: "hives", "swelling of the lips", "could not breathe". */
  reaction?: string | null;
  /** When the recorder chose one. */
  reactionKind?: 'swelling' | 'breathing' | 'blistering' | 'anaphylaxis' | 'rash' | 'hives' | 'itching' | 'stomach' | 'other' | 'unknown' | null;
  onset?: 'immediate' | 'delayed' | 'unknown' | null;
  severity?: 'mild' | 'moderate' | 'severe' | 'unknown' | null;
  year?: number | null;
}

/** How the engine read a recorded reaction (CHK-6a): the first three, `severity_severe` and `unknown` make a class match urgent. */
export type ReactionClass = 'swelling' | 'breathing' | 'blistering' | 'severity_severe' | 'not_severe' | 'unknown';

/** Hooks the options give the engine's analysis. Absent: the default path. */
export interface EngineHooks {
  /** CHK-7: whether a rule may produce a finding. A rule it refuses is recorded as skipped. */
  allow?: (ruleId: string) => boolean;
  /** CHK-6a: the tier of an allergy finding. */
  allergyTier?: (context: { allergy: string; matchKind: 'ingredient' | 'class' | 'cross'; tier: Tier }) => Tier;
  /** CHK-11: a dose line from a kidney-adjusted limit, or null for the usual line. */
  kidneyDose?: (context: { key: string; total: number | null; medicine: string }) => KidneyDoseLine | null;
  /** CHK-10: the eGFR the engine calculated, so findings that use it can say so. */
  calculatedLab?: object | null;
}

export interface KidneyDoseLine {
  status: 'within_usual_range' | 'above_limit' | 'check_with_pharmacist';
  plain: string;
  contraindicated: boolean;
  kidney_limit: KidneyLimitOutput;
}

/** On a dose_check line when kidneyDoseLimits set it. */
export interface KidneyLimitOutput {
  ingredient: string;
  egfr: number;
  egfr_comparator: '<' | '<=' | '>' | '>=' | null;
  egfr_observed_at: string;
  egfr_calculated: boolean;
  /** The label's band, as printed: "30-44". */
  band: string;
  /** null when the label lists the medicine as contraindicated in the band. */
  max_daily_mg: number | null;
  daily_mg: number | null;
  source_id: string;
  section: string;
}