/** * Registrant-reported stop reasons → coarse categories (SPEC §10, trial intelligence). * * ClinicalTrials.gov exposes a free-text `why_stopped` for TERMINATED / WITHDRAWN / SUSPENDED * studies. This module maps that text to a small vocabulary using ONLY explicit keyword rules so the * categorisation is reproducible and auditable (docs/methodology/trial-intelligence.md lists every * keyword). Nothing is inferred: text that matches no rule is `other_stated`, an absent text is * `not_stated`. The classifier is pure and versioned (`STOP_REASON_RULES_VERSION`). */ export const STOP_REASON_RULES_VERSION = 'ci-stop-reasons-v1'; export const STOP_REASON_CATEGORIES = ['covid', 'safety', 'efficacy', 'drug_supply', 'investigator', 'enrollment', 'funding', 'sponsor_decision', 'other_stated', 'not_stated'] as const; export type StopReasonCategory = (typeof STOP_REASON_CATEGORIES)[number]; /** Categories that come from a keyword rule (excludes the two fall-backs). */ export type RuledCategory = Exclude; export interface StopReasonRule { category: RuledCategory; /** Human-readable keyword list (documentation / API). */ keywords: string[]; patterns: RegExp[]; } /** * Rules in precedence order: when a text matches several categories the FIRST matching rule wins, * so the more specific causes (pandemic, safety, efficacy, supply, investigator) take precedence over * the broader ones (enrollment, funding) and `sponsor_decision` — the broadest — comes last. * Every match is also reported (`matched`) so nothing is hidden by the precedence. */ export const STOP_REASON_RULES: readonly StopReasonRule[] = [ { category: 'covid', keywords: ['covid', 'pandemic'], patterns: [/\bcovid/i, /\bpandemic\b/i] }, { category: 'safety', keywords: ['safety', 'toxicity', 'adverse'], patterns: [/\bsafety\b/i, /\btoxicit/i, /\badverse\b/i] }, { category: 'efficacy', keywords: ['efficacy', 'futility', 'lack of benefit', 'interim analysis'], patterns: [/\befficacy\b/i, /\bfutility\b/i, /\black of (?:clinical |therapeutic )?benefit\b/i, /\binterim analys[ie]s\b/i] }, { category: 'drug_supply', keywords: ['supply', 'drug availability', 'manufacturing'], patterns: [/\bsupply\b/i, /\bdrug availability\b/i, /\bmanufactur/i] }, { category: 'investigator', keywords: ['PI left', 'investigator'], patterns: [/\bPI left\b/i, /\binvestigator\b/i] }, { category: 'enrollment', keywords: ['accrual', 'enrollment / enrolment', 'recruitment'], patterns: [/\baccru/i, /\benrol/i, /\brecruit/i] }, { category: 'funding', keywords: ['funding', 'financial', 'budget'], patterns: [/\bfund(?:ing|s|ed)?\b/i, /\bfinanc/i, /\bbudget/i] }, { category: 'sponsor_decision', keywords: ['business', 'sponsor decision', 'strategic', 'portfolio', 'company'], patterns: [/\bbusiness\b/i, /\bsponsor(?:'s|’s)? decision\b/i, /\bdecision (?:of|by) the sponsor\b/i, /\bsponsor decided\b/i, /\bstrateg/i, /\bportfolio\b/i, /\bcompany\b/i] }, ]; export interface StopReasonClassification { category: StopReasonCategory; /** Every rule category the text matched, in precedence order (empty for the two fall-backs). */ matched: RuledCategory[]; rulesVersion: typeof STOP_REASON_RULES_VERSION; } /** Classify one `why_stopped` text. Pure; never infers a reason from anything but the text. */ export function classifyStopReason(whyStopped: string | null | undefined): StopReasonClassification { const text = (whyStopped ?? '').trim(); if (text === '') return { category: 'not_stated', matched: [], rulesVersion: STOP_REASON_RULES_VERSION }; const matched: RuledCategory[] = []; for (const rule of STOP_REASON_RULES) if (rule.patterns.some((p) => p.test(text))) matched.push(rule.category); return { category: matched[0] ?? 'other_stated', matched, rulesVersion: STOP_REASON_RULES_VERSION }; } /** Count classifications per category (all categories present, zeros included, stable key order). */ export function stopReasonBreakdown(texts: Iterable): Record { const out = Object.fromEntries(STOP_REASON_CATEGORIES.map((c) => [c, 0])) as Record; for (const t of texts) out[classifyStopReason(t).category] += 1; return out; } export function isStopReasonCategory(v: string): v is StopReasonCategory { return (STOP_REASON_CATEGORIES as readonly string[]).includes(v); }