SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
4.3 KB · 69 lines typescript
Raw Blame History
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