spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Registrant-reported stop reasons → coarse categories (SPEC §10, trial intelligence).3 *4 * ClinicalTrials.gov exposes a free-text `why_stopped` for TERMINATED / WITHDRAWN / SUSPENDED5 * studies. This module maps that text to a small vocabulary using ONLY explicit keyword rules so the6 * categorisation is reproducible and auditable (docs/methodology/trial-intelligence.md lists every7 * keyword). Nothing is inferred: text that matches no rule is `other_stated`, an absent text is8 * `not_stated`. The classifier is pure and versioned (`STOP_REASON_RULES_VERSION`).9 */1011export const STOP_REASON_RULES_VERSION = 'ci-stop-reasons-v1';1213export const STOP_REASON_CATEGORIES = ['covid', 'safety', 'efficacy', 'drug_supply', 'investigator', 'enrollment', 'funding', 'sponsor_decision', 'other_stated', 'not_stated'] as const;14export type StopReasonCategory = (typeof STOP_REASON_CATEGORIES)[number];1516/** Categories that come from a keyword rule (excludes the two fall-backs). */17export type RuledCategory = Exclude<StopReasonCategory, 'other_stated' | 'not_stated'>;1819export interface StopReasonRule {20 category: RuledCategory;21 /** Human-readable keyword list (documentation / API). */22 keywords: string[];23 patterns: RegExp[];24}2526/**27 * Rules in precedence order: when a text matches several categories the FIRST matching rule wins,28 * so the more specific causes (pandemic, safety, efficacy, supply, investigator) take precedence over29 * the broader ones (enrollment, funding) and `sponsor_decision` — the broadest — comes last.30 * Every match is also reported (`matched`) so nothing is hidden by the precedence.31 */32export const STOP_REASON_RULES: readonly StopReasonRule[] = [33 { category: 'covid', keywords: ['covid', 'pandemic'], patterns: [/\bcovid/i, /\bpandemic\b/i] },34 { category: 'safety', keywords: ['safety', 'toxicity', 'adverse'], patterns: [/\bsafety\b/i, /\btoxicit/i, /\badverse\b/i] },35 { 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] },36 { category: 'drug_supply', keywords: ['supply', 'drug availability', 'manufacturing'], patterns: [/\bsupply\b/i, /\bdrug availability\b/i, /\bmanufactur/i] },37 { category: 'investigator', keywords: ['PI left', 'investigator'], patterns: [/\bPI left\b/i, /\binvestigator\b/i] },38 { category: 'enrollment', keywords: ['accrual', 'enrollment / enrolment', 'recruitment'], patterns: [/\baccru/i, /\benrol/i, /\brecruit/i] },39 { category: 'funding', keywords: ['funding', 'financial', 'budget'], patterns: [/\bfund(?:ing|s|ed)?\b/i, /\bfinanc/i, /\bbudget/i] },40 { 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] },41];4243export interface StopReasonClassification {44 category: StopReasonCategory;45 /** Every rule category the text matched, in precedence order (empty for the two fall-backs). */46 matched: RuledCategory[];47 rulesVersion: typeof STOP_REASON_RULES_VERSION;48}4950/** Classify one `why_stopped` text. Pure; never infers a reason from anything but the text. */51export function classifyStopReason(whyStopped: string | null | undefined): StopReasonClassification {52 const text = (whyStopped ?? '').trim();53 if (text === '') return { category: 'not_stated', matched: [], rulesVersion: STOP_REASON_RULES_VERSION };54 const matched: RuledCategory[] = [];55 for (const rule of STOP_REASON_RULES) if (rule.patterns.some((p) => p.test(text))) matched.push(rule.category);56 return { category: matched[0] ?? 'other_stated', matched, rulesVersion: STOP_REASON_RULES_VERSION };57}5859/** Count classifications per category (all categories present, zeros included, stable key order). */60export function stopReasonBreakdown(texts: Iterable<string | null | undefined>): Record<StopReasonCategory, number> {61 const out = Object.fromEntries(STOP_REASON_CATEGORIES.map((c) => [c, 0])) as Record<StopReasonCategory, number>;62 for (const t of texts) out[classifyStopReason(t).category] += 1;63 return out;64}6566export function isStopReasonCategory(v: string): v is StopReasonCategory {67 return (STOP_REASON_CATEGORIES as readonly string[]).includes(v);68}69