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%
13.8 KB · 271 lines typescript
Raw Blame History
1import { sql } from 'drizzle-orm';2import type { Database } from '@cancerindex/database';3import { proposeDrugMerges } from './drug-duplicates.js';45export const DRUG_PIPELINE_FORMULA_VERSION = 'ci-drug-pipeline-v1';67/** Stages, least to most advanced (funnel order on /pipeline). `withdrawn` sits outside the funnel. */8export const PIPELINE_STAGES = ['phase_not_stated', 'phase1', 'phase2', 'phase3', 'phase4', 'approved', 'withdrawn'] as const;9export type PipelineStage = (typeof PIPELINE_STAGES)[number];1011/** drug_approvals.status values that count as a current market authorization. */12export const PIPELINE_APPROVED_STATUSES = ['approved', 'accelerated', 'conditional'] as const;13/** Registry statuses counted as "active" (same list as trial-intelligence / counters). */14export const PIPELINE_ACTIVE_STATUSES = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'] as const;15/** Registry phase labels → rank. EARLY_PHASE1 and PHASE1 share rank 1 (both → stage phase1); NA → 0. */16export const PHASE_RANK: Record<string, number> = { PHASE4: 4, PHASE3: 3, PHASE2: 2, PHASE1: 1, EARLY_PHASE1: 1, NA: 0 };1718export const PIPELINE_THRESHOLDS = {19  /** cancer_hierarchy traversal depth from a top-level cancer down to trial / approval cancers (same as counters). */20  maxHierarchyDepth: 12,21} as const;2223export interface DrugPipelineResult {24  rows: number;25  drugRows: number;26  cancerRows: number;27  mergeProposals: number;28  mergeCandidates: number;29  ms: number;30}3132/**33 * Highest registry phase over a set of trials (pure, unit-tested). Returns the phase label:34 * PHASE4 > PHASE3 > PHASE2 > PHASE1 ≡ EARLY_PHASE1 (PHASE1 wins the label when both occur) > NA;35 * null when no trial carries a known phase label.36 */37export function maxPhase(phaseLists: Iterable<readonly string[]>): string | null {38  let best = -1;39  let label: string | null = null;40  let sawPhase1 = false;41  for (const phases of phaseLists) {42    for (const p of phases) {43      const r = PHASE_RANK[p];44      if (r === undefined) continue;45      if (p === 'PHASE1') sawPhase1 = true;46      if (r > best) {47        best = r;48        label = p;49      }50    }51  }52  if (best < 0) return null;53  if (best === 1) return sawPhase1 ? 'PHASE1' : 'EARLY_PHASE1';54  return label;55}5657/** Phase label from a SQL-side rank (mirrors `maxPhase`). */58export function phaseLabel(rank: number | null, hasPhase1: boolean): string | null {59  if (rank == null || rank < 0) return null;60  if (rank >= 4) return 'PHASE4';61  if (rank === 3) return 'PHASE3';62  if (rank === 2) return 'PHASE2';63  if (rank === 1) return hasPhase1 ? 'PHASE1' : 'EARLY_PHASE1';64  return 'NA';65}6667export interface StageInput {68  /** Approvals with status ∈ PIPELINE_APPROVED_STATUSES (for the cancer when scoped, any cancer when not). */69  approvedLike: number;70  /** Approvals with any other status (withdrawn, superseded). */71  withdrawnLike: number;72  /** Interventional trials linking the drug (and the cancer when scoped). */73  totalTrials: number;74  /** Output of `maxPhase` over those trials. */75  maxPhase: string | null;76}7778/**79 * Stage rule (pure, unit-tested; docs/methodology/pipeline.md):80 *   approved            any approval with status approved | accelerated | conditional81 *   withdrawn           approvals exist but all are withdrawn / superseded82 *   phase4 … phase1     otherwise, by the highest registry phase among interventional trials83 *                       (PHASE2+PHASE3 → phase3, PHASE1+PHASE2 → phase2, EARLY_PHASE1 → phase1)84 *   phase_not_stated    trials exist but none states a phase (NA / empty)85 *   null                no trials and no approvals → no row86 */87export function stageFor(input: StageInput): PipelineStage | null {88  if (input.approvedLike > 0) return 'approved';89  if (input.withdrawnLike > 0) return 'withdrawn';90  if (input.totalTrials <= 0) return null;91  switch (input.maxPhase) {92    case 'PHASE4':93      return 'phase4';94    case 'PHASE3':95      return 'phase3';96    case 'PHASE2':97      return 'phase2';98    case 'PHASE1':99    case 'EARLY_PHASE1':100      return 'phase1';101    default:102      return 'phase_not_stated';103  }104}105106type AggRow = {107  drug_id: string;108  top_id: string | null;109  total_trials: string | number | null;110  active_trials: string | number | null;111  recruiting_trials: string | number | null;112  phase3_trials: string | number | null;113  phase_rank: string | number | null;114  has_phase1: boolean | null;115  first_trial_date: string | null;116  approvals: string | number | null;117  approved_like: string | number | null;118  jurisdictions: string[] | null;119  first_approval_date: string | null;120  latest_approval_date: string | null;121};122123const n =(v: string | number | null | undefined) => (v == null ? 0 : Number(v));124125/**126 * Recompute `drug_pipeline`: one row per drug (cancer_id NULL, across all cancers) and one per127 * (drug, top-level cancer). A trial reaches a top-level cancer through `trial_conditions.cancer_id`128 * and its ancestors in `cancer_hierarchy` (depth ≤ 12); an approval through `drug_approvals.cancer_id`129 * the same way (approvals without a cancer only feed the unscoped row). Counts are over130 * interventional studies (DISTINCT trials). Set-based SQL over temp tables, one transaction, the131 * stage decided by the pure `stageFor` rule. Finally, salt-form / alias duplicates among drugs are132 * *proposed* to `entity_merges` (never merged here).133 */134export async function computeDrugPipeline(db: Database): Promise<DrugPipelineResult> {135  const t0 = Date.now();136  const depth = sql.raw(String(PIPELINE_THRESHOLDS.maxHierarchyDepth));137  const activeSql = sql.raw(`ARRAY[${PIPELINE_ACTIVE_STATUSES.map((s) => `'${s}'`).join(',')}]::text[]`);138  const approvedSql = sql.raw(`ARRAY[${PIPELINE_APPROVED_STATUSES.map((s) => `'${s}'`).join(',')}]::text[]`);139  const rankCase = sql.raw(`CASE p ${Object.entries(PHASE_RANK)140    .map(([k, v]) => `WHEN '${k}' THEN ${v}`)141    .join(' ')} ELSE NULL END`);142143  const { drugRows, cancerRows } = await db.transaction(async (tx) => {144    // Top-level cancer → every descendant (itself included), across all hierarchy types.145    await tx.execute(sql`CREATE TEMP TABLE _dp_top (top_id varchar(32), cancer_id varchar(32), PRIMARY KEY (top_id, cancer_id)) ON COMMIT DROP`);146    await tx.execute(sql`147      INSERT INTO _dp_top148      WITH RECURSIVE d AS (149        SELECT id AS top_id, id AS cancer_id, 0 AS depth FROM cancers WHERE top_level AND status = 'active'150        UNION151        SELECT d.top_id, h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.cancer_id WHERE d.depth < ${depth}152      )153      SELECT DISTINCT top_id, cancer_id FROM d`);154    // Interventional trials with their activity flags and highest phase rank.155    await tx.execute(sql`156      CREATE TEMP TABLE _dp_trial ON COMMIT DROP AS157      SELECT t.id,158        t.overall_status = ANY(${activeSql}) AS active,159        t.overall_status = 'RECRUITING' AS recruiting,160        (SELECT max(${rankCase}) FROM unnest(t.phases) AS p) AS phase_rank,161        'PHASE1' = ANY(t.phases) AS has_phase1,162        'PHASE3' = ANY(t.phases) AS has_phase3,163        CASE WHEN t.start_date ~ '^\\d{4}' THEN t.start_date END AS start_date164      FROM clinical_trials t WHERE t.study_type = 'INTERVENTIONAL'`);165    await tx.execute(sql`CREATE INDEX ON _dp_trial (id)`);166    await tx.execute(sql`167      CREATE TEMP TABLE _dp_td ON COMMIT DROP AS168      SELECT DISTINCT ti.drug_id, ti.trial_id FROM trial_interventions ti JOIN _dp_trial tr ON tr.id = ti.trial_id WHERE ti.drug_id IS NOT NULL`);169    await tx.execute(sql`170      CREATE TEMP TABLE _dp_tdc ON COMMIT DROP AS171      SELECT DISTINCT td.drug_id, td.trial_id, tp.top_id172      FROM _dp_td td JOIN trial_conditions tc ON tc.trial_id = td.trial_id AND tc.cancer_id IS NOT NULL JOIN _dp_top tp ON tp.cancer_id = tc.cancer_id`);173174    const trialAgg = (scoped: boolean) => sql`175      SELECT x.drug_id, ${scoped ? sql`x.top_id` : sql`NULL::varchar`} AS top_id,176        count(*) AS total_trials,177        count(*) FILTER (WHERE tr.active) AS active_trials,178        count(*) FILTER (WHERE tr.recruiting) AS recruiting_trials,179        count(*) FILTER (WHERE tr.has_phase3) AS phase3_trials,180        max(tr.phase_rank) AS phase_rank,181        bool_or(tr.has_phase1) AS has_phase1,182        min(tr.start_date) AS first_trial_date183      FROM ${scoped ? sql`_dp_tdc` : sql`_dp_td`} x JOIN _dp_trial tr ON tr.id = x.trial_id184      GROUP BY x.drug_id${scoped ? sql`, x.top_id` : sql``}`;185    const approvalAgg = (scoped: boolean) => sql`186      SELECT a.drug_id, ${scoped ? sql`tp.top_id` : sql`NULL::varchar`} AS top_id,187        count(*) AS approvals,188        count(*) FILTER (WHERE a.status = ANY(${approvedSql})) AS approved_like,189        array_agg(DISTINCT a.jurisdiction ORDER BY a.jurisdiction) AS jurisdictions,190        min(a.approval_date) FILTER (WHERE a.status = ANY(${approvedSql}) AND a.approval_date IS NOT NULL) AS first_approval_date,191        max(a.approval_date) FILTER (WHERE a.status = ANY(${approvedSql}) AND a.approval_date IS NOT NULL) AS latest_approval_date192      FROM drug_approvals a ${scoped ? sql`JOIN _dp_top tp ON tp.cancer_id = a.cancer_id` : sql``}193      GROUP BY a.drug_id${scoped ? sql`, tp.top_id` : sql``}`;194    const combined = (scoped: boolean) => sql`195      SELECT COALESCE(t.drug_id, a.drug_id) AS drug_id, COALESCE(t.top_id, a.top_id) AS top_id,196        t.total_trials, t.active_trials, t.recruiting_trials, t.phase3_trials, t.phase_rank, t.has_phase1, t.first_trial_date,197        a.approvals, a.approved_like, a.jurisdictions, a.first_approval_date, a.latest_approval_date198      FROM (${trialAgg(scoped)}) t FULL OUTER JOIN (${approvalAgg(scoped)}) a ON a.drug_id = t.drug_id ${scoped ? sql`AND a.top_id = t.top_id` : sql``}199      WHERE EXISTS (SELECT 1 FROM drugs d WHERE d.id = COALESCE(t.drug_id, a.drug_id))`;200201    const unscoped = (await tx.execute<AggRow>(combined(false))) as unknown as AggRow[];202    const scoped = (await tx.execute<AggRow>(combined(true))) as unknown as AggRow[];203204    await tx.execute(sql`DELETE FROM drug_pipeline`);205    let drugRows = 0;206    let cancerRows = 0;207    const batch: Array<Record<string, unknown>> = [];208    const push = (r: AggRow, scope: 'all' | 'top_level_cancer') => {209      const approvedLike = n(r.approved_like);210      const approvals = n(r.approvals);211      const total = n(r.total_trials);212      const rank = r.phase_rank == null ? null : Number(r.phase_rank);213      const mp = total > 0 ? phaseLabel(rank, !!r.has_phase1) : null;214      const stage = stageFor({ approvedLike, withdrawnLike: approvals - approvedLike, totalTrials: total, maxPhase: mp });215      if (!stage) return;216      batch.push({217        drug_id: r.drug_id,218        cancer_id: r.top_id,219        stage,220        max_phase: mp,221        active_trials: n(r.active_trials),222        recruiting_trials: n(r.recruiting_trials),223        phase3_trials: n(r.phase3_trials),224        total_trials: total,225        approvals,226        // text[] inside unnest cannot carry a nested array → serialized as JSON text, expanded in the INSERT below.227        jurisdictions: JSON.stringify(r.jurisdictions ?? []),228        first_approval_date: r.first_approval_date,229        latest_approval_date: r.latest_approval_date,230        first_trial_date: r.first_trial_date,231        formula_version: DRUG_PIPELINE_FORMULA_VERSION,232        inputs: JSON.stringify({233          scope,234          approvedLike,235          withdrawnLike: approvals - approvedLike,236          phaseRank: rank,237          approvedStatuses: PIPELINE_APPROVED_STATUSES,238          activeStatuses: PIPELINE_ACTIVE_STATUSES,239          ancestorMapping: `trial_conditions.cancer_id / drug_approvals.cancer_id → top-level ancestor via cancer_hierarchy (depth ≤ ${PIPELINE_THRESHOLDS.maxHierarchyDepth})`,240          stageRule: 'approved > withdrawn > highest registry phase (PHASE4, PHASE3, PHASE2, PHASE1/EARLY_PHASE1) > phase_not_stated',241        }),242      });243      if (scope === 'all') drugRows++;244      else cancerRows++;245    };246    for (const r of unscoped) push(r, 'all');247    for (const r of scoped) if (r.top_id) push(r, 'top_level_cancer');248    // Insert in batches of 1,000 rows.249    for (let i = 0; i < batch.length; i += 1000) await flushBatch(tx, batch.slice(i, i + 1000));250    return { drugRows, cancerRows };251  });252253  const merges = await proposeDrugMerges(db);254  return { rows: drugRows + cancerRows, drugRows, cancerRows, mergeProposals: merges.inserted, mergeCandidates: merges.candidates, ms: Date.now() - t0 };255}256257async function flushBatch(tx: Pick<Database, 'execute'>, rows: Array<Record<string, unknown>>): Promise<void> {258  if (!rows.length) return;259  const col = (k: string) => sql.param(rows.map((r) => r[k]));260  await tx.execute(sql`261    INSERT INTO drug_pipeline (drug_id, cancer_id, stage, max_phase, active_trials, recruiting_trials, phase3_trials, total_trials, approvals, jurisdictions, first_approval_date, latest_approval_date, first_trial_date, formula_version, inputs)262    SELECT u.drug_id, u.cancer_id, u.stage, u.max_phase, u.active_trials, u.recruiting_trials, u.phase3_trials, u.total_trials, u.approvals,263      COALESCE(ARRAY(SELECT jsonb_array_elements_text(u.jurisdictions_json::jsonb)), '{}'::text[]), u.first_approval_date, u.latest_approval_date, u.first_trial_date, u.formula_version, u.inputs::jsonb264    FROM unnest(265      ${col('drug_id')}::varchar[], ${col('cancer_id')}::varchar[], ${col('stage')}::text[], ${col('max_phase')}::text[],266      ${col('active_trials')}::int[], ${col('recruiting_trials')}::int[], ${col('phase3_trials')}::int[], ${col('total_trials')}::int[], ${col('approvals')}::int[],267      ${col('jurisdictions')}::text[], ${col('first_approval_date')}::text[], ${col('latest_approval_date')}::text[], ${col('first_trial_date')}::text[],268      ${col('formula_version')}::text[], ${col('inputs')}::text[]269    ) AS u(drug_id, cancer_id, stage, max_phase, active_trials, recruiting_trials, phase3_trials, total_trials, approvals, jurisdictions_json, first_approval_date, latest_approval_date, first_trial_date, formula_version, inputs)`);270}271