Clinical trial intelligence: per-cancer trial metrics (growth, enrollment, sponsor/country HHI, termination share, burden-normalized), stop-reason classifier, 4 ranking snapshots, /trials/intelligence and /trials/terminated, API, CSV, cancer tab strip
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
15 changed files +2,021 −13
modified
apps/api/src/routes/intelligence.ts
+184 −5
@@ -1,10 +1,189 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 1 | 2 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { STOP_REASON_CATEGORIES, STOP_REASON_RULES, STOP_REASON_RULES_VERSION, TRIAL_INTELLIGENCE_FORMULA_VERSION, TRIAL_INTEL_THRESHOLDS, classifyStopReason, type StopReasonCategory } from '@cancerindex/ranking'; | |
| 5 | +import { paginate } from '../lib/envelope.js'; | |
| 6 | +import { descendantIds } from '../lib/descendants.js'; | |
| 7 | +import { pageQuery } from '../lib/pagination.js'; | |
| 8 | +import { resolveCancer } from '../lib/resolve.js'; | |
| 9 | +import { AnyList, AnyRecord, camel, num, ok, respond } from '../lib/respond.js'; | |
| 10 | + | |
| 11 | +/** Sortable columns of GET /trials/intelligence (whitelist → physical column). */ | |
| 12 | +const SORTABLE = { | |
| 13 | + active: 'ti.active_trials', | |
| 14 | + total: 'ti.total_trials', | |
| 15 | + recruiting: 'ti.recruiting_trials', | |
| 16 | + phase3Active: 'ti.phase3_active', | |
| 17 | + phase3Recruiting: 'ti.phase3_recruiting', | |
| 18 | + growth: 'ti.trial_growth_yoy', | |
| 19 | + new12m: 'ti.new_trials_12m', | |
| 20 | + avgEnrollment: 'ti.avg_enrollment', | |
| 21 | + medianEnrollment: 'ti.median_enrollment', | |
| 22 | + industryShare: 'ti.industry_share', | |
| 23 | + sponsorHhi: 'ti.sponsor_hhi', | |
| 24 | + distinctSponsors: 'ti.distinct_sponsors', | |
| 25 | + distinctCountries: 'ti.distinct_countries', | |
| 26 | + usShare: 'ti.us_share', | |
| 27 | + countryHhi: 'ti.country_hhi', | |
| 28 | + terminationShare: 'ti.termination_share', | |
| 29 | + trialsPer1000Deaths: 'ti.trials_per_1000_deaths', | |
| 30 | + trialsPer100kCases: 'ti.trials_per_100k_cases', | |
| 31 | + completed: 'ti.completed_trials', | |
| 32 | + terminated: 'ti.terminated_trials', | |
| 33 | + withResults: 'ti.with_results', | |
| 34 | + name: 'c.canonical_name', | |
| 35 | +} as const; | |
| 36 | +type SortKey = keyof typeof SORTABLE; | |
| 37 | +const sortKeys = Object.keys(SORTABLE) as [SortKey, ...SortKey[]]; | |
| 38 | + | |
| 39 | +const INTEL_COLUMNS = sql`ti.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, c.top_level, ti.entity_level, | |
| 40 | + ti.total_trials, ti.active_trials, ti.recruiting_trials, ti.phase1_active, ti.phase2_active, ti.phase3_active, ti.phase3_recruiting, ti.phase4_active, | |
| 41 | + ti.completed_trials, ti.terminated_trials, ti.withdrawn_trials, ti.suspended_trials, ti.with_results, | |
| 42 | + ti.new_trials_12m, ti.new_trials_prior_12m, ti.trial_growth_yoy, ti.avg_enrollment, ti.median_enrollment, ti.total_enrollment_active, | |
| 43 | + ti.distinct_sponsors, ti.industry_share, ti.sponsor_hhi, ti.top_sponsor, ti.top_sponsor_share, | |
| 44 | + ti.distinct_countries, ti.us_share, ti.top_country, ti.top_country_share, ti.country_hhi, | |
| 45 | + ti.termination_share, ti.why_stopped_breakdown, ti.trials_per_1000_deaths, ti.trials_per_100k_cases, ti.burden_geography, ti.burden_year, ti.burden_source_id, | |
| 46 | + ti.formula_version, ti.inputs, ti.updated_at AS computed_at`; | |
| 47 | + | |
| 48 | +const STOPPED_STATUSES = ['TERMINATED', 'WITHDRAWN', 'SUSPENDED']; | |
| 2 | 49 | |
| 3 | 50 | /** |
| 4 | − * Trial-intelligence routes (SPEC §10-11): `GET /trials/intelligence` (per-cancer trial metrics | |
| 5 | − * with formula version and inputs), `GET /trials/sites` (country / city aggregates for the map). | |
| 6 | − * Filled by the Trial Intelligence and Trial Map work packages. | |
| 51 | + * Clinical-trial intelligence (SPEC §10): per-cancer derived metrics with formula version and inputs, | |
| 52 | + * and registrant-reported stop reasons classified by explicit keyword rules (never inferred). | |
| 53 | + * `GET /trials/sites` (trial map) is added by the Trial Map work package. | |
| 7 | 54 | */ |
| 8 | −export const intelligenceRoutes: FastifyPluginAsyncZod = async (_app) => { | |
| 9 | − /* routes added by the trial-intelligence / trial-map work packages */ | |
| 55 | +export const intelligenceRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 56 | + app.get( | |
| 57 | + '/trials/intelligence', | |
| 58 | + { | |
| 59 | + schema: { | |
| 60 | + tags: ['trials'], | |
| 61 | + summary: 'Trial intelligence per cancer: counts, growth, enrollment, sponsor and country concentration, termination share, burden-normalized intensity (computed, formula-versioned)', | |
| 62 | + querystring: z.object({ | |
| 63 | + level: z.enum(['top', 'all']).default('top').describe('top = mutually exclusive top-level cancers; all = every malignant entity with ≥ 1 mapped trial'), | |
| 64 | + sort: z.enum(sortKeys).default('active'), | |
| 65 | + order: z.enum(['asc', 'desc']).default('desc'), | |
| 66 | + minActive: z.coerce.number().int().min(0).optional().describe('Only rows with at least this many active interventional studies'), | |
| 67 | + ...pageQuery, | |
| 68 | + }), | |
| 69 | + response: ok(AnyList, true), | |
| 70 | + }, | |
| 71 | + }, | |
| 72 | + async (req) => { | |
| 73 | + const q = req.query; | |
| 74 | + const conds = [sql`ti.entity_level = ${q.level}`, sql`c.status = 'active'`]; | |
| 75 | + if (q.minActive != null) conds.push(sql`ti.active_trials >= ${q.minActive}`); | |
| 76 | + const col = SORTABLE[q.sort]; | |
| 77 | + const dir = q.order === 'asc' ? sql.raw('ASC NULLS LAST') : sql.raw('DESC NULLS LAST'); | |
| 78 | + const rows = await app.db.execute<Record<string, unknown> & { total: string; burden_source_id: string | null }>(sql` | |
| 79 | + SELECT ${INTEL_COLUMNS}, count(*) OVER() AS total | |
| 80 | + FROM trial_intelligence ti JOIN cancers c ON c.id = ti.cancer_id | |
| 81 | + WHERE ${sql.join(conds, sql` AND `)} | |
| 82 | + ORDER BY ${sql.raw(col)} ${dir}, c.canonical_name ASC LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 83 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 84 | + const data = rows.map((r) => { | |
| 85 | + const { total: _t, ...rest } = r; | |
| 86 | + return camel(rest); | |
| 87 | + }); | |
| 88 | + const burdenSources = new Set(rows.map((r) => r.burden_source_id).filter((s): s is string => !!s)); | |
| 89 | + return respond(app, data, data.length ? ['clinicaltrials', ...burdenSources] : [], paginate(total, q.limit, q.offset)); | |
| 90 | + }, | |
| 91 | + ); | |
| 92 | + | |
| 93 | + app.get( | |
| 94 | + '/trials/intelligence/:cancer', | |
| 95 | + { | |
| 96 | + schema: { | |
| 97 | + tags: ['trials'], | |
| 98 | + summary: 'Trial intelligence for one cancer (both entity levels when present) with the registrant-reported stop-reason breakdown and the classification rules', | |
| 99 | + params: z.object({ cancer: z.string().min(1).describe('CI-CAN-… id or slug') }), | |
| 100 | + response: ok(AnyRecord), | |
| 101 | + }, | |
| 102 | + }, | |
| 103 | + async (req) => { | |
| 104 | + const { id, slug } = await resolveCancer(app.db, req.params.cancer); | |
| 105 | + const rows = await app.db.execute<Record<string, unknown> & { entity_level: string; burden_source_id: string | null; why_stopped_breakdown: Record<string, number> }>(sql` | |
| 106 | + SELECT ${INTEL_COLUMNS} FROM trial_intelligence ti JOIN cancers c ON c.id = ti.cancer_id WHERE ti.cancer_id = ${id} ORDER BY ti.entity_level`); | |
| 107 | + const levels: Record<string, unknown> = {}; | |
| 108 | + for (const r of rows) levels[r.entity_level] = camel(r); | |
| 109 | + const primary = rows.find((r) => r.entity_level === 'all') ?? rows[0]; | |
| 110 | + const data = { | |
| 111 | + cancer: { id, slug, name: (primary?.cancer_name as string | undefined) ?? null }, | |
| 112 | + available: rows.length > 0, | |
| 113 | + formulaVersion: TRIAL_INTELLIGENCE_FORMULA_VERSION, | |
| 114 | + levels, | |
| 115 | + whyStoppedBreakdown: primary?.why_stopped_breakdown ?? null, | |
| 116 | + stopReasonRules: { version: STOP_REASON_RULES_VERSION, categories: STOP_REASON_CATEGORIES, rules: STOP_REASON_RULES.map((r) => ({ category: r.category, keywords: r.keywords })), note: 'Reasons are as posted by the registrant on ClinicalTrials.gov; a category is assigned only when an explicit keyword matches, otherwise other_stated / not_stated.' }, | |
| 117 | + thresholds: TRIAL_INTEL_THRESHOLDS, | |
| 118 | + }; | |
| 119 | + const burdenSources = rows.map((r) => r.burden_source_id).filter((s): s is string => !!s); | |
| 120 | + return respond(app, data, rows.length ? ['clinicaltrials', ...burdenSources] : []); | |
| 121 | + }, | |
| 122 | + ); | |
| 123 | + | |
| 124 | + app.get( | |
| 125 | + '/trials/terminated', | |
| 126 | + { | |
| 127 | + schema: { | |
| 128 | + tags: ['trials'], | |
| 129 | + summary: 'Terminated, withdrawn and suspended studies with the registrant-reported reason (raw) and its keyword-rule category; breakdown over the filtered set', | |
| 130 | + querystring: z.object({ | |
| 131 | + cancer: z.string().optional().describe('Cancer id/slug — includes descendants'), | |
| 132 | + reason: z.enum(STOP_REASON_CATEGORIES).optional().describe('Stop-reason category (keyword rules)'), | |
| 133 | + status: z.enum(['TERMINATED', 'WITHDRAWN', 'SUSPENDED']).optional(), | |
| 134 | + since: z.coerce.number().int().min(1990).max(2100).optional().describe('First posted in this year or later'), | |
| 135 | + studyType: z.string().optional().describe('INTERVENTIONAL | OBSERVATIONAL | EXPANDED_ACCESS'), | |
| 136 | + ...pageQuery, | |
| 137 | + }), | |
| 138 | + response: ok(AnyRecord, true), | |
| 139 | + }, | |
| 140 | + }, | |
| 141 | + async (req) => { | |
| 142 | + const q = req.query; | |
| 143 | + const conds = [sql`t.overall_status = ANY(${sql.param(STOPPED_STATUSES)}::text[])`]; | |
| 144 | + if (q.status) conds.push(sql`t.overall_status = ${q.status}`); | |
| 145 | + if (q.since) conds.push(sql`t.first_posted_date >= ${`${q.since}-01-01`}`); | |
| 146 | + if (q.studyType) conds.push(sql`t.study_type = ${q.studyType.toUpperCase()}`); | |
| 147 | + let cancer: { id: string; slug: string } | null = null; | |
| 148 | + if (q.cancer) { | |
| 149 | + cancer = await resolveCancer(app.db, q.cancer); | |
| 150 | + const ids = await descendantIds(app.db, cancer.id); | |
| 151 | + conds.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id = ANY(${sql.param(ids)}::text[]))`); | |
| 152 | + } | |
| 153 | + // 1) light pass over the whole filtered set: classify every reason (the category is not stored), build the breakdown. | |
| 154 | + const light = await app.db.execute<{ id: string; why_stopped: string | null; last_update_posted_date: string | null; nct_id: string }>(sql` | |
| 155 | + SELECT t.id, t.why_stopped, t.last_update_posted_date, t.nct_id FROM clinical_trials t WHERE ${sql.join(conds, sql` AND `)} | |
| 156 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id`); | |
| 157 | + const breakdown = Object.fromEntries(STOP_REASON_CATEGORIES.map((c) => [c, 0])) as Record<StopReasonCategory, number>; | |
| 158 | + const classified = light.map((r) => { | |
| 159 | + const c = classifyStopReason(r.why_stopped); | |
| 160 | + breakdown[c.category] += 1; | |
| 161 | + return { id: r.id, category: c.category, matched: c.matched }; | |
| 162 | + }); | |
| 163 | + const filtered = q.reason ? classified.filter((r) => r.category === q.reason) : classified; | |
| 164 | + const page = filtered.slice(q.offset, q.offset + q.limit); | |
| 165 | + const byId = new Map(page.map((p) => [p.id, p])); | |
| 166 | + // 2) full columns for the page only. | |
| 167 | + const rows = page.length | |
| 168 | + ? await app.db.execute<Record<string, unknown> & { id: string }>(sql` | |
| 169 | + SELECT t.id, t.nct_id, t.brief_title, t.acronym, t.study_type, t.phases, t.overall_status, t.why_stopped, t.first_posted_date, t.last_update_posted_date, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.countries | |
| 170 | + FROM clinical_trials t WHERE t.id = ANY(${sql.param(page.map((p) => p.id))}::text[])`) | |
| 171 | + : []; | |
| 172 | + const order = new Map(page.map((p, i) => [p.id, i])); | |
| 173 | + const trials = rows | |
| 174 | + .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) | |
| 175 | + .map((r) => { | |
| 176 | + const c = byId.get(r.id)!; | |
| 177 | + return { ...camel(r), reasonCategory: c.category, reasonMatches: c.matched }; | |
| 178 | + }); | |
| 179 | + const data = { | |
| 180 | + filters: { cancer: cancer ? { id: cancer.id, slug: cancer.slug, includesDescendants: true } : null, reason: q.reason ?? null, status: q.status ?? null, since: q.since ?? null, studyType: q.studyType?.toUpperCase() ?? null }, | |
| 181 | + total: light.length, | |
| 182 | + breakdown, | |
| 183 | + rules: { version: STOP_REASON_RULES_VERSION, categories: STOP_REASON_CATEGORIES, rules: STOP_REASON_RULES.map((r) => ({ category: r.category, keywords: r.keywords })), note: 'Reasons are registrant-reported free text; categories come from explicit keyword matches only and are never inferred.' }, | |
| 184 | + trials, | |
| 185 | + }; | |
| 186 | + return respond(app, data, light.length ? ['clinicaltrials'] : [], paginate(filtered.length, q.limit, q.offset)); | |
| 187 | + }, | |
| 188 | + ); | |
| 10 | 189 | }; |
added
apps/web/src/app/api/export/trial-intelligence.csv/route.ts
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import { listTrialIntelligence } from '@/lib/queries/trial-intelligence'; | |
| 2 | +import { sourceInfoById } from '@/lib/queries/provenance'; | |
| 3 | +import { SITE_URL } from '@/lib/site'; | |
| 4 | +import { isoDate, toDate } from '@/lib/format'; | |
| 5 | +import { csvCell } from '@/lib/trial-intel'; | |
| 6 | + | |
| 7 | +export const dynamic = 'force-dynamic'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * GET /api/export/trial-intelligence.csv?level=top|all | |
| 11 | + * The full `trial_intelligence` layer for one entity level as CSV (attribution header rows first). | |
| 12 | + */ | |
| 13 | +export async function GET(req: Request) { | |
| 14 | + const url = new URL(req.url); | |
| 15 | + const levelRaw = url.searchParams.get('level') ?? 'top'; | |
| 16 | + if (levelRaw !== 'top' && levelRaw !== 'all') return new Response('invalid level (top|all)', { status: 400 }); | |
| 17 | + const level = levelRaw; | |
| 18 | + const rows = await listTrialIntelligence(level); | |
| 19 | + if (rows.length === 0) return new Response('trial intelligence not computed on this environment', { status: 404 }); | |
| 20 | + const burdenIds = [...new Set(rows.map((r) => r.burden_source_id).filter((s): s is string => !!s))]; | |
| 21 | + const src = await sourceInfoById(burdenIds); | |
| 22 | + const burdenSources = burdenIds.map((id) => src.get(id)?.name ?? id).join('; '); | |
| 23 | + const computedAt = rows.reduce<Date | null>((m, r) => { | |
| 24 | + const d = toDate(r.computed_at); | |
| 25 | + return d && (!m || d > m) ? d : m; | |
| 26 | + }, null); | |
| 27 | + const first = rows[0]!; | |
| 28 | + const th = JSON.stringify(first.inputs.thresholds ?? {}); | |
| 29 | + const windows = JSON.stringify(first.inputs.windows ?? {}); | |
| 30 | + | |
| 31 | + const header = [ | |
| 32 | + `# CancerIndex trial intelligence export — entity level: ${level}`, | |
| 33 | + `# Source: CancerIndex (${SITE_URL}) — derived data, CC BY 4.0. Underlying records: ClinicalTrials.gov (U.S. National Library of Medicine, public domain)${burdenSources ? `; burden denominators: ${burdenSources}` : ''}.`, | |
| 34 | + `# formula_version: ${first.formula_version} · study_type: INTERVENTIONAL · aggregation: entity + NCIt descendants (depth ≤ 12) · active statuses: ${(first.inputs.activeStatuses as string[] | undefined)?.join('|') ?? ''}`, | |
| 35 | + `# thresholds: ${th} · growth windows: ${windows} · computed_at: ${computedAt?.toISOString() ?? ''}`, | |
| 36 | + `# Every value is a computed metric (claim category computed_metric). Multinational studies contribute to several countries. Registrant-reported statuses; a TERMINATED status does not imply a negative result.`, | |
| 37 | + ]; | |
| 38 | + const cols = [ | |
| 39 | + 'cancer_id', 'slug', 'canonical_name', 'top_level', 'entity_level', | |
| 40 | + 'total_trials', 'active_trials', 'recruiting_trials', 'phase1_active', 'phase2_active', 'phase3_active', 'phase3_recruiting', 'phase4_active', | |
| 41 | + 'completed_trials', 'terminated_trials', 'withdrawn_trials', 'suspended_trials', 'with_results', | |
| 42 | + 'new_trials_12m', 'new_trials_prior_12m', 'trial_growth_yoy', 'avg_enrollment', 'median_enrollment', 'total_enrollment_active', | |
| 43 | + 'distinct_sponsors', 'industry_share', 'sponsor_hhi', 'top_sponsor', 'top_sponsor_share', | |
| 44 | + 'distinct_countries', 'us_share', 'top_country', 'top_country_share', 'country_hhi', | |
| 45 | + 'termination_share', 'why_stopped_breakdown_json', 'trials_per_1000_deaths', 'trials_per_100k_cases', 'burden_geography', 'burden_year', 'burden_source_id', 'burden_source', | |
| 46 | + 'formula_version', 'computed_at', 'inputs_json', | |
| 47 | + ]; | |
| 48 | + const lines = rows.map((r) => | |
| 49 | + [ | |
| 50 | + r.cancer_id, r.cancer_slug, r.cancer_name, r.top_level, r.entity_level, | |
| 51 | + r.total_trials, r.active_trials, r.recruiting_trials, r.phase1_active, r.phase2_active, r.phase3_active, r.phase3_recruiting, r.phase4_active, | |
| 52 | + r.completed_trials, r.terminated_trials, r.withdrawn_trials, r.suspended_trials, r.with_results, | |
| 53 | + r.new_trials_12m, r.new_trials_prior_12m, r.trial_growth_yoy, r.avg_enrollment, r.median_enrollment, r.total_enrollment_active, | |
| 54 | + r.distinct_sponsors, r.industry_share, r.sponsor_hhi, r.top_sponsor, r.top_sponsor_share, | |
| 55 | + r.distinct_countries, r.us_share, r.top_country, r.top_country_share, r.country_hhi, | |
| 56 | + r.termination_share, r.why_stopped_breakdown, r.trials_per_1000_deaths, r.trials_per_100k_cases, r.burden_geography, r.burden_year, r.burden_source_id, r.burden_source_slug, | |
| 57 | + r.formula_version, toDate(r.computed_at)?.toISOString() ?? '', r.inputs, | |
| 58 | + ] | |
| 59 | + .map(csvCell) | |
| 60 | + .join(','), | |
| 61 | + ); | |
| 62 | + const body = [...header, cols.join(','), ...lines].join('\n') + '\n'; | |
| 63 | + const fname = `cancerindex-trial-intelligence-${level}-${isoDate(computedAt)}.csv`; | |
| 64 | + return new Response(body, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fname}"`, 'Cache-Control': 'public, max-age=300' } }); | |
| 65 | +} | |
added
apps/web/src/app/trials/intelligence/loading.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { PageSkeleton } from '@/components/ui/skeleton'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return <PageSkeleton title="Loading clinical trial intelligence" />; | |
| 5 | +} | |
added
apps/web/src/app/trials/intelligence/page.tsx
+207 −0
@@ -0,0 +1,207 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Note } from '@/components/ui/section'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Freshness } from '@/components/ui/freshness'; | |
| 6 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 7 | +import { SourceBadge, TableProvenance } from '@/components/ui/source-badge'; | |
| 8 | +import { listTrialIntelligence, type TrialIntelRow } from '@/lib/queries/trial-intelligence'; | |
| 9 | +import { fmtInt, fmtNum, fmtPct, toDate } from '@/lib/format'; | |
| 10 | +import { fmtGrowth, intelTotals, isIntelSortKey, sortIntel, type IntelSortKey } from '@/lib/trial-intel'; | |
| 11 | +import { oneOf, str, withParams, type SP } from '@/lib/search-params'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Clinical trial intelligence', description: 'Per-cancer trial activity, growth, enrollment, sponsor and country concentration, termination share and burden-normalized intensity, computed from ClinicalTrials.gov with a versioned formula.' }; | |
| 14 | +export const revalidate = 600; | |
| 15 | + | |
| 16 | +const ALL_LIMIT = 150; | |
| 17 | + | |
| 18 | +const COLUMNS: Array<{ key: IntelSortKey; label: string; title: string; num?: boolean }> = [ | |
| 19 | + { key: 'name', label: 'Cancer', title: 'Cancer entity; figures aggregate the entity and its NCIt-hierarchy descendants' }, | |
| 20 | + { key: 'total', label: 'Total', title: 'Interventional studies mapped to the entity or a descendant (any status)', num: true }, | |
| 21 | + { key: 'active', label: 'Active', title: 'Recruiting, not yet recruiting, enrolling by invitation or active-not-recruiting interventional studies', num: true }, | |
| 22 | + { key: 'recruiting', label: 'Recruiting', title: 'Interventional studies with overall status RECRUITING', num: true }, | |
| 23 | + { key: 'phase3Recruiting', label: 'Ph III active / recruiting', title: 'Active (and recruiting) interventional studies with PHASE3 among their phases; a PHASE2|PHASE3 study counts in both phases', num: true }, | |
| 24 | + { key: 'growth', label: 'Growth YoY', title: '(studies first posted in the last 12 months − studies first posted in the preceding 12 months) / preceding; null when the preceding window has fewer than 20 studies', num: true }, | |
| 25 | + { key: 'avgEnrollment', label: 'Avg enrollment', title: 'Mean (median in title) enrollment_count over active interventional studies, as posted (anticipated or actual)', num: true }, | |
| 26 | + { key: 'industryShare', label: 'Industry share', title: 'Share of active interventional studies whose lead sponsor class is INDUSTRY', num: true }, | |
| 27 | + { key: 'sponsorHhi', label: 'Sponsor HHI · top sponsor', title: 'Herfindahl–Hirschman index of lead sponsors over active interventional studies (Σ share², 1 = one sponsor); null under 10 active studies', num: true }, | |
| 28 | + { key: 'distinctCountries', label: 'Countries · US share', title: 'Distinct countries with a site among active interventional studies; share of active studies with at least one US site', num: true }, | |
| 29 | + { key: 'terminationShare', label: 'Termination share', title: '(terminated + withdrawn) / (completed + terminated + withdrawn) over interventional studies first posted since 2010-01-01; null under 30 terminal studies', num: true }, | |
| 30 | + { key: 'trialsPer1000Deaths', label: 'Trials / 1,000 deaths', title: 'Active interventional studies per 1,000 annual deaths (US, latest year with both incidence and mortality counts from one source; deaths ≥ 100). Top-level cancers only', num: true }, | |
| 31 | +]; | |
| 32 | + | |
| 33 | +export default async function TrialIntelligencePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 34 | + const sp = await searchParams; | |
| 35 | + const level = oneOf(sp, 'level', ['top', 'all'] as const, 'top'); | |
| 36 | + const sortRaw = str(sp, 'sort', 'active'); | |
| 37 | + const sort: IntelSortKey = isIntelSortKey(sortRaw) ? sortRaw : 'active'; | |
| 38 | + const order = oneOf(sp, 'order', ['asc', 'desc'] as const, sort === 'name' ? 'asc' : 'desc'); | |
| 39 | + const fetched = await listTrialIntelligence(level, level === 'all' ? ALL_LIMIT : 10_000); | |
| 40 | + const rows = sortIntel(fetched, sort, order); | |
| 41 | + const totals = intelTotals(rows); | |
| 42 | + const current = { level, sort, order }; | |
| 43 | + const href = (o: Record<string, string | number | null | undefined>) => `/trials/intelligence${withParams(current, o)}`; | |
| 44 | + const computedAt = rows.reduce<Date | null>((m, r) => { | |
| 45 | + const d = toDate(r.computed_at); | |
| 46 | + return d && (!m || d > m) ? d : m; | |
| 47 | + }, null); | |
| 48 | + const first = rows[0]; | |
| 49 | + const th = (first?.inputs.thresholds ?? {}) as Record<string, unknown>; | |
| 50 | + const windows = (first?.inputs.windows ?? null) as { new12m: { from: string; to: string }; prior12m: { from: string; to: string } } | null; | |
| 51 | + const burdenSlugs = [...new Set(rows.map((r) => r.burden_source_slug).filter((s): s is string => !!s))]; | |
| 52 | + | |
| 53 | + const sortLink = (key: IntelSortKey, label: string, title: string, num?: boolean) => { | |
| 54 | + const active = sort === key; | |
| 55 | + const nextOrder = active ? (order === 'desc' ? 'asc' : 'desc') : key === 'name' ? 'asc' : 'desc'; | |
| 56 | + return ( | |
| 57 | + <th key={key} className={num ? 'num' : undefined} aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'} title={title}> | |
| 58 | + <Link href={href({ sort: key, order: nextOrder })} className="ci-link no-underline"> | |
| 59 | + {label} | |
| 60 | + {active ? <span aria-hidden> {order === 'asc' ? '↑' : '↓'}</span> : null} | |
| 61 | + </Link> | |
| 62 | + </th> | |
| 63 | + ); | |
| 64 | + }; | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div> | |
| 68 | + <PageHeader kicker="Clinical trials" title="Clinical trial intelligence" lede="Per-cancer measures of registered clinical research: activity, growth, enrollment, who sponsors it and where it runs, how often it stops, and how it compares with the disease burden. Every figure is computed from ClinicalTrials.gov records with a versioned formula; nothing here is an observation."> | |
| 69 | + <nav aria-label="Entity level" className="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px]"> | |
| 70 | + <span className="ci-kicker mr-1">Entities</span> | |
| 71 | + <Link href={href({ level: 'top', sort: sort, order })} className="ci-chip" aria-current={level === 'top' ? 'page' : undefined}> | |
| 72 | + Top-level cancers | |
| 73 | + </Link> | |
| 74 | + <Link href={href({ level: 'all', sort: sort, order })} className="ci-chip" aria-current={level === 'all' ? 'page' : undefined}> | |
| 75 | + All entities ({ALL_LIMIT} most active) | |
| 76 | + </Link> | |
| 77 | + <span className="mx-2 text-ink-4" aria-hidden> | |
| 78 | + · | |
| 79 | + </span> | |
| 80 | + <Link href="/trials/terminated" className="ci-link"> | |
| 81 | + Terminated studies → | |
| 82 | + </Link> | |
| 83 | + <Link href={`/api/export/trial-intelligence.csv?level=${level}`} className="ci-link"> | |
| 84 | + CSV | |
| 85 | + </Link> | |
| 86 | + </nav> | |
| 87 | + </PageHeader> | |
| 88 | + | |
| 89 | + {rows.length === 0 ? ( | |
| 90 | + <EmptyState title="Trial intelligence not yet computed" knows={[{ label: 'Trials explorer', href: '/trials' }, { label: 'Rankings', href: '/rankings' }]}> | |
| 91 | + The trial-intelligence layer is recomputed from the ClinicalTrials.gov records after each ingest (<code className="ci-mono">pnpm cix intel</code>). Nothing is shown until it has run on this environment. | |
| 92 | + </EmptyState> | |
| 93 | + ) : ( | |
| 94 | + <> | |
| 95 | + {level === 'top' ? ( | |
| 96 | + <dl className="grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 text-[13px] sm:grid-cols-3 lg:grid-cols-6"> | |
| 97 | + {[ | |
| 98 | + ['Top-level cancers', fmtInt(totals.entities), 'Mutually exclusive registry set; sums below do not double count'], | |
| 99 | + ['Interventional studies', fmtInt(totals.total), 'Sum of total interventional studies over the top-level set'], | |
| 100 | + ['Active', fmtInt(totals.active), 'Sum of active interventional studies'], | |
| 101 | + ['Recruiting', fmtInt(totals.recruiting), 'Sum of recruiting interventional studies'], | |
| 102 | + ['Phase III active', fmtInt(totals.phase3Active), 'Sum of active studies with PHASE3 among their phases'], | |
| 103 | + ['Phase III recruiting', fmtInt(totals.phase3Recruiting), 'Sum of recruiting studies with PHASE3 among their phases'], | |
| 104 | + ].map(([k, v, t]) => ( | |
| 105 | + <div key={k} title={t}> | |
| 106 | + <dt className="ci-kicker">{k}</dt> | |
| 107 | + <dd className="ci-num text-xl text-ink">{v}</dd> | |
| 108 | + </div> | |
| 109 | + ))} | |
| 110 | + </dl> | |
| 111 | + ) : ( | |
| 112 | + <Note> | |
| 113 | + Entities at this level overlap (a subtype and its parent both count the same study), so column totals are not shown. The {ALL_LIMIT} entities with the most active studies are listed; use the API (<code className="ci-mono">/api/v1/trials/intelligence?level=all</code>) or the CSV export for the full set. | |
| 114 | + </Note> | |
| 115 | + )} | |
| 116 | + | |
| 117 | + <div className="mt-4"> | |
| 118 | + <TableProvenance p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', layer: 'derived', note: 'Studies attach to a cancer through their reconciled conditions, aggregated over the entity and its NCIt-hierarchy descendants (a study mapped to "lung adenocarcinoma" also counts for "lung cancer").' }} claim={<ClaimBadge kind="computed" />}> | |
| 119 | + {fmtInt(rows.length)} entities · sorted by {COLUMNS.find((c) => c.key === sort)?.label ?? sort} ({order}) · click a header to sort | |
| 120 | + </TableProvenance> | |
| 121 | + <div className="ci-table-wrap"> | |
| 122 | + <table className="ci-table"> | |
| 123 | + <thead> | |
| 124 | + <tr>{COLUMNS.map((c) => sortLink(c.key, c.label, c.title, c.num))}</tr> | |
| 125 | + </thead> | |
| 126 | + <tbody> | |
| 127 | + {rows.map((r) => ( | |
| 128 | + <IntelRow key={r.cancer_id} r={r} /> | |
| 129 | + ))} | |
| 130 | + </tbody> | |
| 131 | + </table> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <div className="mt-4 border-t border-rule pt-3 text-[12.5px] text-ink-3"> | |
| 136 | + <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 137 | + <ClaimBadge kind="computed" /> | |
| 138 | + <span> | |
| 139 | + formula <span className="ci-mono">{first?.formula_version}</span> | |
| 140 | + </span> | |
| 141 | + <span>· interventional studies only · aggregation over NCIt descendants (depth ≤ {String(th.maxHierarchyDepth ?? 12)})</span> | |
| 142 | + <span>· active = {(first?.inputs.activeStatuses as string[] | undefined)?.join(', ')}</span> | |
| 143 | + </p> | |
| 144 | + <p className="mt-1"> | |
| 145 | + Thresholds: growth requires ≥ {String(th.growthMinPriorTrials ?? 20)} studies in the prior window{windows ? ` (windows ${windows.new12m.from} → ${windows.new12m.to} vs ${windows.prior12m.from} → ${windows.prior12m.to})` : ''}; sponsor HHI requires ≥ {String(th.hhiMinActiveTrials ?? 10)} active studies; termination share requires ≥ {String(th.terminationMinTerminalTrials ?? 30)} terminal studies first posted since {String(th.terminationSince ?? '2010-01-01')}; burden ratios require ≥ {String(th.burdenMinDeaths ?? 100)} annual deaths ({String(th.burdenGeography ?? 'USA')}, latest year with both counts from one source{burdenSlugs.length ? `: ${burdenSlugs.join(', ')}` : ''}). | |
| 146 | + A multinational study contributes to every country it lists, so country shares can sum above 100%.{' '} | |
| 147 | + <Link className="ci-link" href="/methodology#trial-intelligence"> | |
| 148 | + Methodology | |
| 149 | + </Link> | |
| 150 | + </p> | |
| 151 | + <Freshness dataUpdatedAt={computedAt} extra="computed by CancerIndex from ClinicalTrials.gov records" /> | |
| 152 | + </div> | |
| 153 | + </> | |
| 154 | + )} | |
| 155 | + </div> | |
| 156 | + ); | |
| 157 | +} | |
| 158 | + | |
| 159 | +function IntelRow({ r }: { r: TrialIntelRow }) { | |
| 160 | + return ( | |
| 161 | + <tr> | |
| 162 | + <td> | |
| 163 | + <Link className="ci-link" href={`/cancer/${r.cancer_slug}/trials`}> | |
| 164 | + {r.cancer_name} | |
| 165 | + </Link> | |
| 166 | + </td> | |
| 167 | + <td className="num">{fmtInt(r.total_trials)}</td> | |
| 168 | + <td className="num font-medium">{fmtInt(r.active_trials)}</td> | |
| 169 | + <td className="num">{fmtInt(r.recruiting_trials)}</td> | |
| 170 | + <td className="num" title={`${fmtInt(r.phase3_active)} active Phase III, ${fmtInt(r.phase3_recruiting)} recruiting`}> | |
| 171 | + {fmtInt(r.phase3_active)} <span className="text-ink-3">/ {fmtInt(r.phase3_recruiting)}</span> | |
| 172 | + </td> | |
| 173 | + <td className={`num ${r.trial_growth_yoy != null && r.trial_growth_yoy > 0 ? 'text-ok' : r.trial_growth_yoy != null && r.trial_growth_yoy < 0 ? 'text-danger' : ''}`} title={`${fmtInt(r.new_trials_12m)} first posted in the last 12 months vs ${fmtInt(r.new_trials_prior_12m)} in the preceding 12 months${r.trial_growth_yoy == null ? ' — below the 20-study threshold, not computed' : ''}`}> | |
| 174 | + {fmtGrowth(r.trial_growth_yoy)} | |
| 175 | + </td> | |
| 176 | + <td className="num" title={r.avg_enrollment != null ? `mean ${fmtNum(r.avg_enrollment, 1)} · median ${fmtNum(r.median_enrollment, 0)} · total ${fmtInt(r.total_enrollment_active)} participants across active studies` : 'No enrollment counts posted'}> | |
| 177 | + {fmtNum(r.avg_enrollment, 0)} | |
| 178 | + </td> | |
| 179 | + <td className="num">{fmtPct(r.industry_share, 0)}</td> | |
| 180 | + <td className="num" title={r.sponsor_hhi == null ? 'Fewer than 10 active studies — not computed' : `${fmtInt(r.distinct_sponsors)} distinct lead sponsors; top sponsor ${r.top_sponsor ?? '—'} holds ${fmtPct(r.top_sponsor_share, 1)} of active studies`}> | |
| 181 | + {fmtNum(r.sponsor_hhi, 3)} | |
| 182 | + {r.top_sponsor ? <span className="block max-w-[14rem] truncate text-[11.5px] text-ink-3">{r.top_sponsor}</span> : null} | |
| 183 | + </td> | |
| 184 | + <td className="num" title={r.top_country ? `top country ${r.top_country} (${fmtPct(r.top_country_share, 0)} of active studies); country HHI ${fmtNum(r.country_hhi, 3)}` : undefined}> | |
| 185 | + {fmtInt(r.distinct_countries)} <span className="text-ink-3">· {fmtPct(r.us_share, 0)}</span> | |
| 186 | + </td> | |
| 187 | + <td className="num" title={r.termination_share == null ? 'Fewer than 30 terminal studies since 2010 — not computed' : `${fmtInt(r.terminated_trials)} terminated, ${fmtInt(r.withdrawn_trials)} withdrawn, ${fmtInt(r.completed_trials)} completed (all years)`}> | |
| 188 | + {fmtPct(r.termination_share, 1)} | |
| 189 | + </td> | |
| 190 | + <td className="num"> | |
| 191 | + {r.trials_per_1000_deaths != null ? ( | |
| 192 | + <span className="inline-flex flex-wrap items-baseline justify-end gap-x-1.5" title={`${fmtInt(r.active_trials)} active studies / (${fmtInt((r.inputs.burden as { deaths?: number } | undefined)?.deaths)} deaths / 1,000) · also ${fmtNum(r.trials_per_100k_cases, 1)} per 100,000 new cases`}> | |
| 193 | + {fmtNum(r.trials_per_1000_deaths, 1)} | |
| 194 | + <span className="text-[11px] text-ink-3"> | |
| 195 | + {r.burden_geography} {r.burden_year} | |
| 196 | + </span> | |
| 197 | + {r.burden_source_slug ? <SourceBadge compact p={{ sourceSlug: r.burden_source_slug }} title={`Deaths and incidence: ${r.burden_source_slug}, ${r.burden_geography} ${r.burden_year}, all sexes, all ages`} /> : null} | |
| 198 | + </span> | |
| 199 | + ) : ( | |
| 200 | + <span className="text-ink-4" title={r.top_level ? 'No US mortality and incidence counts from one source for this entity' : 'Burden normalization is computed for top-level cancers only'}> | |
| 201 | + — | |
| 202 | + </span> | |
| 203 | + )} | |
| 204 | + </td> | |
| 205 | + </tr> | |
| 206 | + ); | |
| 207 | +} | |
added
apps/web/src/app/trials/terminated/page.tsx
+225 −0
@@ -0,0 +1,225 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { STOP_REASON_CATEGORIES, STOP_REASON_RULES, STOP_REASON_RULES_VERSION, isStopReasonCategory, type StopReasonCategory } from '@cancerindex/ranking'; | |
| 4 | +import { PageHeader, Note } from '@/components/ui/section'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { Freshness } from '@/components/ui/freshness'; | |
| 7 | +import { Pager } from '@/components/ui/pager'; | |
| 8 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; | |
| 9 | +import { TableProvenance } from '@/components/ui/source-badge'; | |
| 10 | +import { BarChart } from '@/components/charts/bar-chart'; | |
| 11 | +import { listTerminated, stoppedYears, STOPPED_STATUSES } from '@/lib/queries/trial-intelligence'; | |
| 12 | +import { getCancerBySlug, getDescendantIds } from '@/lib/queries/cancers'; | |
| 13 | +import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format'; | |
| 14 | +import { reasonLabel } from '@/lib/trial-intel'; | |
| 15 | +import { pageInfo } from '@/lib/pagination'; | |
| 16 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 17 | + | |
| 18 | +export const metadata: Metadata = { title: 'Terminated, withdrawn and suspended trials', description: 'Oncology studies that stopped early, with the reason as posted by the registrant on ClinicalTrials.gov and a keyword-rule category. Reasons are never inferred.' }; | |
| 19 | +export const revalidate = 600; | |
| 20 | + | |
| 21 | +const PAGE_SIZE = 50; | |
| 22 | + | |
| 23 | +export default async function TerminatedTrialsPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 24 | + const sp = await searchParams; | |
| 25 | + const cancerSlug = str(sp, 'cancer'); | |
| 26 | + const reasonRaw = str(sp, 'reason'); | |
| 27 | + const reason: StopReasonCategory | '' = isStopReasonCategory(reasonRaw) ? reasonRaw : ''; | |
| 28 | + const status = str(sp, 'status'); | |
| 29 | + const since = int(sp, 'since', 0, 1990, 2100) || null; | |
| 30 | + const page = int(sp, 'page', 1, 1, 100_000); | |
| 31 | + const cancer = cancerSlug ? await getCancerBySlug(cancerSlug) : null; | |
| 32 | + const cancerIds = cancer ? await getDescendantIds(cancer.id) : null; | |
| 33 | + const [years, { rows, total, stopped, breakdown }] = await Promise.all([stoppedYears(), listTerminated({ cancerIds, reason, status, since, page, pageSize: PAGE_SIZE })]); | |
| 34 | + const info = pageInfo(page, PAGE_SIZE, total); | |
| 35 | + const current = { cancer: cancerSlug, reason, status, since: since ?? '' }; | |
| 36 | + const href = (o: Record<string, string | number | null | undefined>) => `/trials/terminated${withParams(current, o)}`; | |
| 37 | + const chart = STOP_REASON_CATEGORIES.map((c) => ({ label: reasonLabel(c), value: breakdown[c], href: href({ reason: c, page: '' }), muted: c === 'not_stated' || c === 'other_stated' })).filter((d) => d.value > 0).sort((a, b) => b.value - a.value); | |
| 38 | + const stated = stopped - breakdown.not_stated; | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <div> | |
| 42 | + <PageHeader kicker="Clinical trials · failure tracking" title="Terminated, withdrawn and suspended studies" lede="Oncology studies whose overall status is TERMINATED, WITHDRAWN or SUSPENDED on ClinicalTrials.gov, with the stop reason exactly as the registrant posted it. A category is attached only when an explicit keyword rule matches the text; CancerIndex never infers why a study stopped."> | |
| 43 | + <nav className="mt-3 flex flex-wrap gap-3 text-[12.5px]"> | |
| 44 | + <Link href="/trials/intelligence" className="ci-link"> | |
| 45 | + ← Trial intelligence | |
| 46 | + </Link> | |
| 47 | + <Link href="/trials" className="ci-link"> | |
| 48 | + All trials | |
| 49 | + </Link> | |
| 50 | + </nav> | |
| 51 | + </PageHeader> | |
| 52 | + | |
| 53 | + <form method="get" action="/trials/terminated" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[1.4fr_1.4fr_1fr_1fr_auto]" role="search" aria-label="Filter stopped studies"> | |
| 54 | + <label className="flex flex-col gap-1"> | |
| 55 | + <span className="ci-kicker">Cancer (slug, includes descendants)</span> | |
| 56 | + <input name="cancer" defaultValue={cancerSlug} placeholder="e.g. glioblastoma" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 57 | + </label> | |
| 58 | + <label className="flex flex-col gap-1"> | |
| 59 | + <span className="ci-kicker">Reason category</span> | |
| 60 | + <select name="reason" defaultValue={reason} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 61 | + <option value="">Any</option> | |
| 62 | + {STOP_REASON_CATEGORIES.map((c) => ( | |
| 63 | + <option key={c} value={c}> | |
| 64 | + {reasonLabel(c)} ({fmtInt(breakdown[c])}) | |
| 65 | + </option> | |
| 66 | + ))} | |
| 67 | + </select> | |
| 68 | + </label> | |
| 69 | + <label className="flex flex-col gap-1"> | |
| 70 | + <span className="ci-kicker">Status</span> | |
| 71 | + <select name="status" defaultValue={status} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 72 | + <option value="">Any</option> | |
| 73 | + {STOPPED_STATUSES.map((s) => ( | |
| 74 | + <option key={s} value={s}> | |
| 75 | + {humanize(s)} | |
| 76 | + </option> | |
| 77 | + ))} | |
| 78 | + </select> | |
| 79 | + </label> | |
| 80 | + <label className="flex flex-col gap-1"> | |
| 81 | + <span className="ci-kicker">First posted since</span> | |
| 82 | + <select name="since" defaultValue={since ?? ''} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 83 | + <option value="">Any year</option> | |
| 84 | + {years.map((y) => ( | |
| 85 | + <option key={y} value={y}> | |
| 86 | + {y} | |
| 87 | + </option> | |
| 88 | + ))} | |
| 89 | + </select> | |
| 90 | + </label> | |
| 91 | + <div className="flex items-end"> | |
| 92 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 93 | + Apply | |
| 94 | + </button> | |
| 95 | + </div> | |
| 96 | + </form> | |
| 97 | + | |
| 98 | + <p className="mt-3 text-[13px] text-ink-2" role="status"> | |
| 99 | + <span className="ci-num font-medium text-ink">{fmtInt(stopped)}</span> stopped studies{cancer ? <> mapped to <span className="font-medium">{cancer.canonical_name}</span> and descendants</> : null} | |
| 100 | + {since ? ` first posted since ${since}` : ''} | |
| 101 | + {status ? ` with status ${humanize(status)}` : ''} | |
| 102 | + {cancerSlug && !cancer ? <span className="text-warn"> — unknown cancer slug "{cancerSlug}" (ignored)</span> : null} | |
| 103 | + {stopped > 0 ? ( | |
| 104 | + <> | |
| 105 | + {' '} | |
| 106 | + · <span className="ci-num">{fmtPct(stopped ? stated / stopped : null, 0)}</span> state a reason | |
| 107 | + {reason ? ( | |
| 108 | + <> | |
| 109 | + {' '} | |
| 110 | + · showing <span className="ci-num">{fmtInt(total)}</span> in category <span className="font-medium">{reasonLabel(reason)}</span> | |
| 111 | + </> | |
| 112 | + ) : null} | |
| 113 | + </> | |
| 114 | + ) : null} | |
| 115 | + </p> | |
| 116 | + | |
| 117 | + {stopped === 0 ? ( | |
| 118 | + <div className="mt-3"> | |
| 119 | + <EmptyState title="No stopped study matches these filters" knows={[{ label: 'Trial intelligence', href: '/trials/intelligence' }, { label: 'Trials explorer', href: '/trials' }]}> | |
| 120 | + Relax a filter or clear the cancer slug. | |
| 121 | + </EmptyState> | |
| 122 | + </div> | |
| 123 | + ) : ( | |
| 124 | + <> | |
| 125 | + <section aria-labelledby="breakdown-title" className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]"> | |
| 126 | + <div className="min-w-0"> | |
| 127 | + <h2 id="breakdown-title" className="text-lg"> | |
| 128 | + Stated reasons by category | |
| 129 | + </h2> | |
| 130 | + <p className="mb-2 text-[12.5px] text-ink-3"> | |
| 131 | + Over the {fmtInt(stopped)} studies matching the cancer, status and year filters (the category filter does not change this chart). Each study is counted once, in the first matching category; greyed bars are studies whose text matched no rule or was empty. | |
| 132 | + </p> | |
| 133 | + <BarChart data={chart} unit="count" maxBars={10} ariaLabel="Stopped studies by registrant-reported reason category" /> | |
| 134 | + <p className="mt-1 flex flex-wrap items-center gap-2 text-[11.5px] text-ink-3"> | |
| 135 | + <ClaimBadge kind="computed" /> | |
| 136 | + <span> | |
| 137 | + rules <span className="ci-mono">{STOP_REASON_RULES_VERSION}</span> · keyword matching only | |
| 138 | + </span> | |
| 139 | + </p> | |
| 140 | + </div> | |
| 141 | + <div className="min-w-0 text-[12.5px]"> | |
| 142 | + <h3 className="text-base">Classification rules</h3> | |
| 143 | + <p className="mb-1.5 text-ink-3">A category is assigned when one of its keywords appears in the posted text (case-insensitive, whole words). Rules are tested in this order and the first match wins; every match is kept in the API (<code className="ci-mono">reasonMatches</code>).</p> | |
| 144 | + <ol className="list-decimal space-y-0.5 pl-5 text-ink-2"> | |
| 145 | + {STOP_REASON_RULES.map((r) => ( | |
| 146 | + <li key={r.category}> | |
| 147 | + <span className="font-medium">{reasonLabel(r.category)}</span> — <span className="ci-mono text-ink-3">{r.keywords.join(' · ')}</span> | |
| 148 | + </li> | |
| 149 | + ))} | |
| 150 | + <li> | |
| 151 | + <span className="font-medium">{reasonLabel('other_stated')}</span> — text present, no keyword matched | |
| 152 | + </li> | |
| 153 | + <li> | |
| 154 | + <span className="font-medium">{reasonLabel('not_stated')}</span> — no text posted | |
| 155 | + </li> | |
| 156 | + </ol> | |
| 157 | + </div> | |
| 158 | + </section> | |
| 159 | + | |
| 160 | + <div className="mt-5"> | |
| 161 | + <TableProvenance p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', layer: 'normalized', note: 'Status, dates, sponsor and "why stopped" are shown as posted by the registrant. Only the category column is computed.' }} claim={<ClaimBadge kind="observed" />}> | |
| 162 | + Showing {fmtInt(info.from)}–{fmtInt(info.to)} of {fmtInt(total)} studies, most recently updated first | |
| 163 | + </TableProvenance> | |
| 164 | + {rows.length === 0 ? ( | |
| 165 | + <EmptyState compact title="No study in this category for these filters" /> | |
| 166 | + ) : ( | |
| 167 | + <div className="ci-table-wrap"> | |
| 168 | + <table className="ci-table"> | |
| 169 | + <thead> | |
| 170 | + <tr> | |
| 171 | + <th>NCT</th> | |
| 172 | + <th>Title</th> | |
| 173 | + <th>Phase</th> | |
| 174 | + <th>Status</th> | |
| 175 | + <th>First posted</th> | |
| 176 | + <th>Lead sponsor</th> | |
| 177 | + <th>Why stopped (as posted)</th> | |
| 178 | + <th>Category</th> | |
| 179 | + </tr> | |
| 180 | + </thead> | |
| 181 | + <tbody> | |
| 182 | + {rows.map((t) => ( | |
| 183 | + <tr key={t.id}> | |
| 184 | + <td className="ci-mono whitespace-nowrap"> | |
| 185 | + <Link className="ci-link" href={`/trial/${t.nct_id}`}> | |
| 186 | + {t.nct_id} | |
| 187 | + </Link> | |
| 188 | + </td> | |
| 189 | + <td className="max-w-[26rem]" title={t.brief_title}> | |
| 190 | + {truncate(t.brief_title, 90)} | |
| 191 | + {t.acronym ? <span className="ml-1 text-ink-3">({t.acronym})</span> : null} | |
| 192 | + </td> | |
| 193 | + <td className="whitespace-nowrap">{t.phases.length ? t.phases.map(phaseLabel).join(' / ') : '—'}</td> | |
| 194 | + <td> | |
| 195 | + <StatusBadge status={t.overall_status} /> | |
| 196 | + </td> | |
| 197 | + <td className="whitespace-nowrap">{fmtDate(t.first_posted_date)}</td> | |
| 198 | + <td className="max-w-[14rem] truncate" title={`${t.lead_sponsor ?? ''}${t.lead_sponsor_class ? ` (${humanize(t.lead_sponsor_class)})` : ''}`}> | |
| 199 | + {t.lead_sponsor ?? '—'} | |
| 200 | + </td> | |
| 201 | + <td className="max-w-[22rem]" title={t.why_stopped ?? 'No reason posted'}> | |
| 202 | + {t.why_stopped ? truncate(t.why_stopped, 100) : <span className="text-ink-4">not stated</span>} | |
| 203 | + </td> | |
| 204 | + <td> | |
| 205 | + <Badge tone={t.reason_category === 'not_stated' ? 'outline' : t.reason_category === 'other_stated' ? 'neutral' : 'accent'} title={t.reason_matches.length > 1 ? `also matched: ${t.reason_matches.slice(1).map(reasonLabel).join(', ')}` : t.reason_category === 'not_stated' ? 'No text posted by the registrant' : t.reason_category === 'other_stated' ? 'Text posted, no keyword rule matched' : `Keyword rule: ${reasonLabel(t.reason_category)}`}> | |
| 206 | + {reasonLabel(t.reason_category)} | |
| 207 | + </Badge> | |
| 208 | + </td> | |
| 209 | + </tr> | |
| 210 | + ))} | |
| 211 | + </tbody> | |
| 212 | + </table> | |
| 213 | + </div> | |
| 214 | + )} | |
| 215 | + <Pager page={info.page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} label="Stopped study pages" noun="studies" /> | |
| 216 | + <Note> | |
| 217 | + Stop reasons are registrant-reported free text and are displayed verbatim (truncated; hover for the full text). Categories come from explicit keyword rules and are never inferred from the study design, sponsor or outcome; a study that stopped for several reasons is filed under the first rule that matched. A TERMINATED status does not imply a negative result. | |
| 218 | + </Note> | |
| 219 | + <Freshness dataUpdatedAt={rows.reduce<Date | string | null>((m, t) => (m == null || String(t.updated_at) > String(m) ? t.updated_at : m), null)} sourceUpdatedAt={rows[0]?.last_update_posted_date ?? null} extra="source: clinicaltrials" /> | |
| 220 | + </div> | |
| 221 | + </> | |
| 222 | + )} | |
| 223 | + </div> | |
| 224 | + ); | |
| 225 | +} | |
modified
apps/web/src/components/cancer/tabs/trials.tsx
+52 −3
@@ -4,15 +4,63 @@ import { EmptyState } from '@/components/ui/empty-state'; | ||
| 4 | 4 | import { Freshness } from '@/components/ui/freshness'; |
| 5 | 5 | import { Pager } from '@/components/ui/pager'; |
| 6 | 6 | import { TrialTable } from '@/components/data/trial-list'; |
| 7 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 8 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 7 | 9 | import { listTrialRows, trialFacets, TRIAL_PAGE_SIZE } from '@/lib/queries/trials'; |
| 8 | −import { fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 10 | +import { trialIntelligenceFor, type TrialIntelRow } from '@/lib/queries/trial-intelligence'; | |
| 11 | +import { fmtInt, fmtNum, fmtPct, humanize, phaseLabel } from '@/lib/format'; | |
| 12 | +import { fmtGrowth } from '@/lib/trial-intel'; | |
| 9 | 13 | import { pageInfo } from '@/lib/pagination'; |
| 10 | 14 | import { withParams } from '@/lib/search-params'; |
| 11 | 15 | import type { CancerBundle } from '../load'; |
| 12 | 16 | |
| 17 | +/** | |
| 18 | + * Intelligence strip on top of the trials tab: figures from `trial_intelligence` (level "all" for this | |
| 19 | + * entity, falling back to "top"). Rendered only when a row exists — never a fake zero. | |
| 20 | + */ | |
| 21 | +function IntelStrip({ r, slug }: { r: TrialIntelRow; slug: string }) { | |
| 22 | + const th = (r.inputs.thresholds ?? {}) as Record<string, unknown>; | |
| 23 | + const burden = r.inputs.burden as { deaths?: number } | undefined; | |
| 24 | + const items: Array<{ k: string; v: React.ReactNode; title: string }> = [ | |
| 25 | + { k: 'Active', v: fmtInt(r.active_trials), title: `Interventional studies with status ${(r.inputs.activeStatuses as string[] | undefined)?.join(', ') ?? 'active'} mapped to this entity or a descendant` }, | |
| 26 | + { k: 'Recruiting', v: fmtInt(r.recruiting_trials), title: 'Interventional studies with overall status RECRUITING' }, | |
| 27 | + { k: 'Ph III recruiting', v: fmtInt(r.phase3_recruiting), title: `Recruiting interventional studies with PHASE3 among their phases (${fmtInt(r.phase3_active)} active Phase III)` }, | |
| 28 | + { k: 'Growth YoY', v: fmtGrowth(r.trial_growth_yoy), title: `(${fmtInt(r.new_trials_12m)} first posted in the last 12 months − ${fmtInt(r.new_trials_prior_12m)} in the preceding 12 months) / ${fmtInt(r.new_trials_prior_12m)}${r.trial_growth_yoy == null ? ` — not computed under ${String(th.growthMinPriorTrials ?? 20)} studies in the preceding window` : ''}` }, | |
| 29 | + { k: 'Industry share', v: fmtPct(r.industry_share, 0), title: 'Share of active interventional studies whose lead sponsor class is INDUSTRY' }, | |
| 30 | + { k: 'Top sponsor', v: r.top_sponsor ? <span className="block max-w-[12rem] truncate">{r.top_sponsor}</span> : '—', title: r.top_sponsor ? `${r.top_sponsor} leads ${fmtPct(r.top_sponsor_share, 1)} of active studies · ${fmtInt(r.distinct_sponsors)} distinct lead sponsors · sponsor HHI ${fmtNum(r.sponsor_hhi, 3)}` : 'No active study with a lead sponsor' }, | |
| 31 | + { k: 'Termination share', v: fmtPct(r.termination_share, 1), title: `(terminated + withdrawn) / (completed + terminated + withdrawn), interventional studies first posted since ${String(th.terminationSince ?? '2010-01-01')}${r.termination_share == null ? ` — not computed under ${String(th.terminationMinTerminalTrials ?? 30)} terminal studies` : ''}` }, | |
| 32 | + ]; | |
| 33 | + if (r.trials_per_1000_deaths != null) { | |
| 34 | + items.push({ k: 'Trials / 1,000 deaths', v: <>{fmtNum(r.trials_per_1000_deaths, 1)} <span className="text-[11px] text-ink-3">{r.burden_geography} {r.burden_year}</span> {r.burden_source_slug ? <SourceBadge compact p={{ sourceSlug: r.burden_source_slug }} title={`Deaths: ${r.burden_source_slug}, ${r.burden_geography} ${r.burden_year}, all sexes, all ages`} /> : null}</>, title: `${fmtInt(r.active_trials)} active interventional studies / (${fmtInt(burden?.deaths)} annual deaths / 1,000) · also ${fmtNum(r.trials_per_100k_cases, 1)} per 100,000 new cases` }); | |
| 35 | + } | |
| 36 | + return ( | |
| 37 | + <div className="mb-4 border-y border-rule py-3"> | |
| 38 | + <dl className="grid grid-cols-2 gap-x-5 gap-y-2.5 text-[13px] sm:grid-cols-4"> | |
| 39 | + {items.map((i) => ( | |
| 40 | + <div key={i.k} title={i.title} className="min-w-0"> | |
| 41 | + <dt className="ci-kicker">{i.k}</dt> | |
| 42 | + <dd className="ci-num text-lg text-ink">{i.v}</dd> | |
| 43 | + </div> | |
| 44 | + ))} | |
| 45 | + </dl> | |
| 46 | + <p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3"> | |
| 47 | + <ClaimBadge kind="computed" /> | |
| 48 | + <span> | |
| 49 | + formula <span className="ci-mono">{r.formula_version}</span> · interventional studies · entity + NCIt descendants · hover a figure for its formula | |
| 50 | + </span> | |
| 51 | + <Link className="ci-link" href="/trials/intelligence"> | |
| 52 | + Compare cancers | |
| 53 | + </Link> | |
| 54 | + <Link className="ci-link" href={`/trials/terminated?cancer=${encodeURIComponent(slug)}`}> | |
| 55 | + Terminated studies ({fmtInt(r.terminated_trials + r.withdrawn_trials + r.suspended_trials)}) | |
| 56 | + </Link> | |
| 57 | + </p> | |
| 58 | + </div> | |
| 59 | + ); | |
| 60 | +} | |
| 61 | + | |
| 13 | 62 | export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; status: string; phase: string; page: number }) { |
| 14 | − const facets = await trialFacets(b.descendants); | |
| 15 | − const { rows, total } = await listTrialRows({ q: '', status, phase, country: '', cancerIds: b.descendants, page, pageSize: TRIAL_PAGE_SIZE }); | |
| 63 | + const [facets, { rows, total }, intel] = await Promise.all([trialFacets(b.descendants), listTrialRows({ q: '', status, phase, country: '', cancerIds: b.descendants, page, pageSize: TRIAL_PAGE_SIZE }), trialIntelligenceFor(b.cancer.id)]); | |
| 16 | 64 | const info = pageInfo(page, TRIAL_PAGE_SIZE, total); |
| 17 | 65 | const base = `/cancer/${b.cancer.slug}/trials`; |
| 18 | 66 | const href = (o: Record<string, string | number | null | undefined>) => `${base}${withParams({ status, phase }, o)}`; |
@@ -32,6 +80,7 @@ export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; s | ||
| 32 | 80 | |
| 33 | 81 | return ( |
| 34 | 82 | <Section id="trials" kicker="Clinical trials" title="Registered studies" description={`${fmtInt(anyTrials)} studies whose conditions map to this entity or one of its descendants. Status and phase are as posted on ClinicalTrials.gov. ${TRIAL_PAGE_SIZE} studies per page.`}> |
| 83 | + {intel.primary ? <IntelStrip r={intel.primary} slug={b.cancer.slug} /> : null} | |
| 35 | 84 | <div className="mb-3 flex flex-wrap gap-4 text-[12.5px]"> |
| 36 | 85 | <nav aria-label="Filter by status" className="flex flex-wrap items-center gap-1.5"> |
| 37 | 86 | <span className="ci-kicker mr-1">Status</span> |
added
apps/web/src/components/home/trial-intel-module.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 6 | +import { topPhase3Recruiting } from '@/lib/queries/trial-intelligence'; | |
| 7 | +import { fmtInt, fmtPct, toDate } from '@/lib/format'; | |
| 8 | +import { fmtGrowth } from '@/lib/trial-intel'; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Home module "Clinical trial intelligence" (SPEC §10): the top-level cancers with the most recruiting | |
| 12 | + * Phase III interventional studies, with registration growth and industry share. Every figure comes from | |
| 13 | + * `trial_intelligence` (computed, formula-versioned); nothing is shown until the layer has been computed. | |
| 14 | + */ | |
| 15 | +export async function TrialIntelModule({ limit = 8 }: { limit?: number }) { | |
| 16 | + const rows = await topPhase3Recruiting(limit); | |
| 17 | + const computedAt = rows.reduce<Date | null>((m, r) => { | |
| 18 | + const d = toDate(r.computed_at); | |
| 19 | + return d && (!m || d > m) ? d : m; | |
| 20 | + }, null); | |
| 21 | + return ( | |
| 22 | + <Section | |
| 23 | + id="trial-intel" | |
| 24 | + kicker="Clinical trials" | |
| 25 | + title="Clinical trial intelligence" | |
| 26 | + description="Top-level cancers with the most recruiting Phase III interventional studies, with the year-over-year change in registrations. Counts aggregate a cancer and its NCIt descendants; statuses and phases are as posted on ClinicalTrials.gov." | |
| 27 | + actions={ | |
| 28 | + <Link href="/trials/intelligence" className="ci-link"> | |
| 29 | + All measures → | |
| 30 | + </Link> | |
| 31 | + } | |
| 32 | + > | |
| 33 | + {rows.length === 0 ? ( | |
| 34 | + <EmptyState title="Trial intelligence not yet computed">The per-cancer trial measures are derived from ClinicalTrials.gov records after each ingest. Nothing is shown until that computation has run.</EmptyState> | |
| 35 | + ) : ( | |
| 36 | + <> | |
| 37 | + <div className="ci-table-wrap"> | |
| 38 | + <table className="ci-table"> | |
| 39 | + <thead> | |
| 40 | + <tr> | |
| 41 | + <th className="num">#</th> | |
| 42 | + <th>Cancer</th> | |
| 43 | + <th className="num" title="Recruiting interventional studies with PHASE3 among their phases"> | |
| 44 | + Ph III recruiting | |
| 45 | + </th> | |
| 46 | + <th className="num" title="Active interventional studies (recruiting, not yet recruiting, enrolling by invitation, active not recruiting)"> | |
| 47 | + Active | |
| 48 | + </th> | |
| 49 | + <th className="num" title="(studies first posted in the last 12 months − preceding 12 months) / preceding; null under 20 studies in the preceding window"> | |
| 50 | + Growth YoY | |
| 51 | + </th> | |
| 52 | + <th className="num" title="Share of active studies led by an industry sponsor"> | |
| 53 | + Industry | |
| 54 | + </th> | |
| 55 | + </tr> | |
| 56 | + </thead> | |
| 57 | + <tbody> | |
| 58 | + {rows.map((r, i) => ( | |
| 59 | + <tr key={r.cancer_id}> | |
| 60 | + <td className="num">{i + 1}</td> | |
| 61 | + <td> | |
| 62 | + <Link className="ci-link" href={`/cancer/${r.cancer_slug}/trials`}> | |
| 63 | + {r.cancer_name} | |
| 64 | + </Link> | |
| 65 | + </td> | |
| 66 | + <td className="num font-medium">{fmtInt(r.phase3_recruiting)}</td> | |
| 67 | + <td className="num">{fmtInt(r.active_trials)}</td> | |
| 68 | + <td className={`num ${r.trial_growth_yoy != null && r.trial_growth_yoy > 0 ? 'text-ok' : r.trial_growth_yoy != null && r.trial_growth_yoy < 0 ? 'text-danger' : ''}`} title={`${fmtInt(r.new_trials_12m)} first posted in the last 12 months vs ${fmtInt(r.new_trials_prior_12m)} in the preceding 12 months`}> | |
| 69 | + {fmtGrowth(r.trial_growth_yoy)} | |
| 70 | + </td> | |
| 71 | + <td className="num">{fmtPct(r.industry_share, 0)}</td> | |
| 72 | + </tr> | |
| 73 | + ))} | |
| 74 | + </tbody> | |
| 75 | + </table> | |
| 76 | + </div> | |
| 77 | + <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3"> | |
| 78 | + <ClaimBadge kind="computed" /> | |
| 79 | + <span> | |
| 80 | + formula <span className="ci-mono">{rows[0]?.formula_version}</span> · interventional studies · descendants included | |
| 81 | + </span> | |
| 82 | + <span>source: clinicaltrials</span> | |
| 83 | + <Link className="ci-link" href="/trials/terminated"> | |
| 84 | + Terminated studies | |
| 85 | + </Link> | |
| 86 | + </p> | |
| 87 | + <Freshness dataUpdatedAt={computedAt} extra="computed by CancerIndex from ClinicalTrials.gov records" /> | |
| 88 | + </> | |
| 89 | + )} | |
| 90 | + </Section> | |
| 91 | + ); | |
| 92 | +} | |
added
apps/web/src/lib/queries/trial-intelligence.ts
+160 −0
@@ -0,0 +1,160 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import { STOP_REASON_CATEGORIES, classifyStopReason, type StopReasonCategory } from '@cancerindex/ranking'; | |
| 4 | + | |
| 5 | +/** One `trial_intelligence` row joined with its cancer (snake_case as returned by the driver). */ | |
| 6 | +export interface TrialIntelRow { | |
| 7 | + cancer_id: string; | |
| 8 | + cancer_slug: string; | |
| 9 | + cancer_name: string; | |
| 10 | + top_level: boolean; | |
| 11 | + entity_level: 'top' | 'all'; | |
| 12 | + total_trials: number; | |
| 13 | + active_trials: number; | |
| 14 | + recruiting_trials: number; | |
| 15 | + phase1_active: number; | |
| 16 | + phase2_active: number; | |
| 17 | + phase3_active: number; | |
| 18 | + phase3_recruiting: number; | |
| 19 | + phase4_active: number; | |
| 20 | + completed_trials: number; | |
| 21 | + terminated_trials: number; | |
| 22 | + withdrawn_trials: number; | |
| 23 | + suspended_trials: number; | |
| 24 | + with_results: number; | |
| 25 | + new_trials_12m: number; | |
| 26 | + new_trials_prior_12m: number; | |
| 27 | + trial_growth_yoy: number | null; | |
| 28 | + avg_enrollment: number | null; | |
| 29 | + median_enrollment: number | null; | |
| 30 | + total_enrollment_active: number | null; | |
| 31 | + distinct_sponsors: number; | |
| 32 | + industry_share: number | null; | |
| 33 | + sponsor_hhi: number | null; | |
| 34 | + top_sponsor: string | null; | |
| 35 | + top_sponsor_share: number | null; | |
| 36 | + distinct_countries: number; | |
| 37 | + us_share: number | null; | |
| 38 | + top_country: string | null; | |
| 39 | + top_country_share: number | null; | |
| 40 | + country_hhi: number | null; | |
| 41 | + termination_share: number | null; | |
| 42 | + why_stopped_breakdown: Record<string, number>; | |
| 43 | + trials_per_1000_deaths: number | null; | |
| 44 | + trials_per_100k_cases: number | null; | |
| 45 | + burden_geography: string | null; | |
| 46 | + burden_year: number | null; | |
| 47 | + burden_source_id: string | null; | |
| 48 | + burden_source_slug: string | null; | |
| 49 | + formula_version: string; | |
| 50 | + inputs: Record<string, unknown>; | |
| 51 | + computed_at: Date | string; | |
| 52 | +} | |
| 53 | + | |
| 54 | +const COLS = sql`ti.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, c.top_level, ti.entity_level, | |
| 55 | + ti.total_trials, ti.active_trials, ti.recruiting_trials, ti.phase1_active, ti.phase2_active, ti.phase3_active, ti.phase3_recruiting, ti.phase4_active, | |
| 56 | + ti.completed_trials, ti.terminated_trials, ti.withdrawn_trials, ti.suspended_trials, ti.with_results, | |
| 57 | + ti.new_trials_12m, ti.new_trials_prior_12m, ti.trial_growth_yoy, ti.avg_enrollment, ti.median_enrollment, ti.total_enrollment_active, | |
| 58 | + ti.distinct_sponsors, ti.industry_share, ti.sponsor_hhi, ti.top_sponsor, ti.top_sponsor_share, | |
| 59 | + ti.distinct_countries, ti.us_share, ti.top_country, ti.top_country_share, ti.country_hhi, | |
| 60 | + ti.termination_share, ti.why_stopped_breakdown, ti.trials_per_1000_deaths, ti.trials_per_100k_cases, ti.burden_geography, ti.burden_year, ti.burden_source_id, | |
| 61 | + s.slug AS burden_source_slug, ti.formula_version, ti.inputs, ti.updated_at AS computed_at`; | |
| 62 | +const FROM = sql`FROM trial_intelligence ti JOIN cancers c ON c.id = ti.cancer_id LEFT JOIN sources s ON s.id = ti.burden_source_id`; | |
| 63 | + | |
| 64 | +/** Rows for one entity level, most active first (`limit` caps the "all" level to the most active entities). */ | |
| 65 | +export async function listTrialIntelligence(level: 'top' | 'all', limit = 10_000): Promise<TrialIntelRow[]> { | |
| 66 | + return safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.entity_level = ${level} AND c.status = 'active' ORDER BY ti.active_trials DESC, c.canonical_name LIMIT ${limit}`), [] as TrialIntelRow[]); | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Both levels for one cancer (a top-level cancer has two rows with identical figures). */ | |
| 70 | +export async function trialIntelligenceFor(cancerId: string): Promise<{ all: TrialIntelRow | null; top: TrialIntelRow | null; primary: TrialIntelRow | null }> { | |
| 71 | + const rows = await safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.cancer_id = ${cancerId}`), [] as TrialIntelRow[]); | |
| 72 | + const all = rows.find((r) => r.entity_level === 'all') ?? null; | |
| 73 | + const top = rows.find((r) => r.entity_level === 'top') ?? null; | |
| 74 | + return { all, top, primary: all ?? top }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Home module: top-level cancers with the most recruiting Phase III studies. */ | |
| 78 | +export async function topPhase3Recruiting(limit = 8): Promise<TrialIntelRow[]> { | |
| 79 | + return safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.entity_level = 'top' AND c.status = 'active' AND ti.phase3_recruiting > 0 ORDER BY ti.phase3_recruiting DESC, ti.recruiting_trials DESC, c.canonical_name LIMIT ${limit}`), [] as TrialIntelRow[]); | |
| 80 | +} | |
| 81 | + | |
| 82 | +export const STOPPED_STATUSES = ['TERMINATED', 'WITHDRAWN', 'SUSPENDED'] as const; | |
| 83 | + | |
| 84 | +export interface TerminatedFilters { | |
| 85 | + cancerIds: string[] | null; | |
| 86 | + reason: StopReasonCategory | ''; | |
| 87 | + status: string; | |
| 88 | + since: number | null; | |
| 89 | + page: number; | |
| 90 | + pageSize: number; | |
| 91 | +} | |
| 92 | + | |
| 93 | +export interface TerminatedRow { | |
| 94 | + id: string; | |
| 95 | + nct_id: string; | |
| 96 | + brief_title: string; | |
| 97 | + acronym: string | null; | |
| 98 | + study_type: string | null; | |
| 99 | + phases: string[]; | |
| 100 | + overall_status: string | null; | |
| 101 | + why_stopped: string | null; | |
| 102 | + first_posted_date: string | null; | |
| 103 | + last_update_posted_date: string | null; | |
| 104 | + enrollment_count: number | null; | |
| 105 | + lead_sponsor: string | null; | |
| 106 | + lead_sponsor_class: string | null; | |
| 107 | + updated_at: Date | string; | |
| 108 | + reason_category: StopReasonCategory; | |
| 109 | + reason_matches: string[]; | |
| 110 | +} | |
| 111 | + | |
| 112 | +function stoppedWhere(f: TerminatedFilters) { | |
| 113 | + const parts = [sql`t.overall_status IN ('TERMINATED','WITHDRAWN','SUSPENDED')`]; | |
| 114 | + if (f.status && (STOPPED_STATUSES as readonly string[]).includes(f.status)) parts.push(sql`t.overall_status = ${f.status}`); | |
| 115 | + if (f.since) parts.push(sql`t.first_posted_date >= ${`${f.since}-01-01`}`); | |
| 116 | + if (f.cancerIds) parts.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id = ANY(${sql.param(f.cancerIds)}::text[]))`); | |
| 117 | + return sql.join(parts, sql` AND `); | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** | |
| 121 | + * Terminated / withdrawn / suspended studies with their registrant-reported reason classified by | |
| 122 | + * keyword rules. The category is not stored, so a light pass classifies the whole filtered set (id + | |
| 123 | + * text) to build the breakdown and apply the reason filter; full columns are fetched for the page only. | |
| 124 | + */ | |
| 125 | +export async function listTerminated(f: TerminatedFilters): Promise<{ rows: TerminatedRow[]; total: number; stopped: number; breakdown: Record<StopReasonCategory, number> }> { | |
| 126 | + const breakdown = Object.fromEntries(STOP_REASON_CATEGORIES.map((c) => [c, 0])) as Record<StopReasonCategory, number>; | |
| 127 | + if (f.cancerIds && f.cancerIds.length === 0) return { rows: [], total: 0, stopped: 0, breakdown }; | |
| 128 | + const light = await safe(() => run<{ id: string; why_stopped: string | null }>(sql`SELECT t.id, t.why_stopped FROM clinical_trials t WHERE ${stoppedWhere(f)} ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id`), [] as Array<{ id: string; why_stopped: string | null }>); | |
| 129 | + const classified = light.map((r) => { | |
| 130 | + const c = classifyStopReason(r.why_stopped); | |
| 131 | + breakdown[c.category] += 1; | |
| 132 | + return { id: r.id, category: c.category, matched: c.matched }; | |
| 133 | + }); | |
| 134 | + const filtered = f.reason ? classified.filter((r) => r.category === f.reason) : classified; | |
| 135 | + const offset = (Math.max(1, f.page) - 1) * f.pageSize; | |
| 136 | + const page = filtered.slice(offset, offset + f.pageSize); | |
| 137 | + if (page.length === 0) return { rows: [], total: filtered.length, stopped: light.length, breakdown }; | |
| 138 | + const full = await safe( | |
| 139 | + () => | |
| 140 | + run<Omit<TerminatedRow, 'reason_category' | 'reason_matches'>>(sql` | |
| 141 | + SELECT t.id, t.nct_id, t.brief_title, t.acronym, t.study_type, t.phases, t.overall_status, t.why_stopped, t.first_posted_date, t.last_update_posted_date, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.updated_at | |
| 142 | + FROM clinical_trials t WHERE t.id = ANY(${sql.param(page.map((p) => p.id))}::text[])`), | |
| 143 | + [] as Array<Omit<TerminatedRow, 'reason_category' | 'reason_matches'>>, | |
| 144 | + ); | |
| 145 | + const order = new Map(page.map((p, i) => [p.id, i])); | |
| 146 | + const byId = new Map(page.map((p) => [p.id, p])); | |
| 147 | + const rows = full | |
| 148 | + .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) | |
| 149 | + .map((r) => { | |
| 150 | + const c = byId.get(r.id)!; | |
| 151 | + return { ...r, reason_category: c.category, reason_matches: c.matched } satisfies TerminatedRow; | |
| 152 | + }); | |
| 153 | + return { rows, total: filtered.length, stopped: light.length, breakdown }; | |
| 154 | +} | |
| 155 | + | |
| 156 | +/** Distinct first-posted years among stopped studies (for the year filter). */ | |
| 157 | +export async function stoppedYears(): Promise<number[]> { | |
| 158 | + const rows = await safe(() => run<{ y: number }>(sql`SELECT DISTINCT left(first_posted_date, 4)::int AS y FROM clinical_trials WHERE overall_status IN ('TERMINATED','WITHDRAWN','SUSPENDED') AND first_posted_date ~ '^\\d{4}' ORDER BY 1 DESC`), [] as Array<{ y: number }>); | |
| 159 | + return rows.map((r) => Number(r.y)).filter((y) => Number.isFinite(y)); | |
| 160 | +} | |
added
apps/web/src/lib/trial-intel.test.ts
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { csvCell, fmtGrowth, intelTotals, isIntelSortKey, reasonLabel, sortIntel, type IntelSortable } from './trial-intel'; | |
| 3 | + | |
| 4 | +const row = (cancer_name: string, p: Partial<IntelSortable> = {}): IntelSortable => ({ | |
| 5 | + cancer_name, | |
| 6 | + total_trials: 0, | |
| 7 | + active_trials: 0, | |
| 8 | + recruiting_trials: 0, | |
| 9 | + phase3_active: 0, | |
| 10 | + phase3_recruiting: 0, | |
| 11 | + trial_growth_yoy: null, | |
| 12 | + avg_enrollment: null, | |
| 13 | + industry_share: null, | |
| 14 | + sponsor_hhi: null, | |
| 15 | + distinct_countries: 0, | |
| 16 | + us_share: null, | |
| 17 | + termination_share: null, | |
| 18 | + trials_per_1000_deaths: null, | |
| 19 | + ...p, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe('sortIntel', () => { | |
| 23 | + const rows = [row('B', { active_trials: 10, trial_growth_yoy: 0.2 }), row('A', { active_trials: 30, trial_growth_yoy: null }), row('C', { active_trials: 20, trial_growth_yoy: -0.1 })]; | |
| 24 | + it('sorts numerically desc/asc', () => { | |
| 25 | + expect(sortIntel(rows, 'active', 'desc').map((r) => r.cancer_name)).toEqual(['A', 'C', 'B']); | |
| 26 | + expect(sortIntel(rows, 'active', 'asc').map((r) => r.cancer_name)).toEqual(['B', 'C', 'A']); | |
| 27 | + }); | |
| 28 | + it('puts nulls last in both directions', () => { | |
| 29 | + expect(sortIntel(rows, 'growth', 'desc').map((r) => r.cancer_name)).toEqual(['B', 'C', 'A']); | |
| 30 | + expect(sortIntel(rows, 'growth', 'asc').map((r) => r.cancer_name)).toEqual(['C', 'B', 'A']); | |
| 31 | + }); | |
| 32 | + it('breaks ties by cancer name and sorts by name', () => { | |
| 33 | + const tied = [row('Zeta', { active_trials: 5 }), row('Alpha', { active_trials: 5 })]; | |
| 34 | + expect(sortIntel(tied, 'active', 'desc').map((r) => r.cancer_name)).toEqual(['Alpha', 'Zeta']); | |
| 35 | + expect(sortIntel(rows, 'name', 'asc').map((r) => r.cancer_name)).toEqual(['A', 'B', 'C']); | |
| 36 | + expect(sortIntel(rows, 'name', 'desc').map((r) => r.cancer_name)).toEqual(['C', 'B', 'A']); | |
| 37 | + }); | |
| 38 | + it('does not mutate the input', () => { | |
| 39 | + const copy = [...rows]; | |
| 40 | + sortIntel(rows, 'active', 'desc'); | |
| 41 | + expect(rows).toEqual(copy); | |
| 42 | + }); | |
| 43 | +}); | |
| 44 | + | |
| 45 | +describe('isIntelSortKey', () => { | |
| 46 | + it('whitelists keys', () => { | |
| 47 | + expect(isIntelSortKey('active')).toBe(true); | |
| 48 | + expect(isIntelSortKey('trialsPer1000Deaths')).toBe(true); | |
| 49 | + expect(isIntelSortKey('drop table')).toBe(false); | |
| 50 | + }); | |
| 51 | +}); | |
| 52 | + | |
| 53 | +describe('intelTotals', () => { | |
| 54 | + it('sums the count columns', () => { | |
| 55 | + const t = intelTotals([row('A', { total_trials: 10, active_trials: 4, recruiting_trials: 2, phase3_active: 1, phase3_recruiting: 1 }), row('B', { total_trials: 5, active_trials: 1, recruiting_trials: 1, phase3_active: 0, phase3_recruiting: 0 })]); | |
| 56 | + expect(t).toEqual({ entities: 2, total: 15, active: 5, recruiting: 3, phase3Active: 1, phase3Recruiting: 1 }); | |
| 57 | + }); | |
| 58 | + it('is zero for no rows', () => { | |
| 59 | + expect(intelTotals([]).entities).toBe(0); | |
| 60 | + }); | |
| 61 | +}); | |
| 62 | + | |
| 63 | +describe('csvCell', () => { | |
| 64 | + it('quotes commas, quotes and newlines; empties nulls; serialises objects', () => { | |
| 65 | + expect(csvCell('a,b')).toBe('"a,b"'); | |
| 66 | + expect(csvCell('say "hi"')).toBe('"say ""hi"""'); | |
| 67 | + expect(csvCell('x\ny')).toBe('"x\ny"'); | |
| 68 | + expect(csvCell(null)).toBe(''); | |
| 69 | + expect(csvCell(3.5)).toBe('3.5'); | |
| 70 | + expect(csvCell({ a: 1 })).toBe('"{""a"":1}"'); | |
| 71 | + }); | |
| 72 | +}); | |
| 73 | + | |
| 74 | +describe('fmtGrowth', () => { | |
| 75 | + it('formats signed percentages', () => { | |
| 76 | + expect(fmtGrowth(0.215)).toBe('+21.5%'); | |
| 77 | + expect(fmtGrowth(-0.036)).toBe('−3.6%'); | |
| 78 | + expect(fmtGrowth(0)).toBe('0%'); | |
| 79 | + expect(fmtGrowth(null)).toBe('—'); | |
| 80 | + expect(fmtGrowth(Number.NaN)).toBe('—'); | |
| 81 | + }); | |
| 82 | +}); | |
| 83 | + | |
| 84 | +describe('reasonLabel', () => { | |
| 85 | + it('labels known categories and falls back to a humanised key', () => { | |
| 86 | + expect(reasonLabel('enrollment')).toBe('Enrollment / accrual'); | |
| 87 | + expect(reasonLabel('not_stated')).toBe('Not stated'); | |
| 88 | + expect(reasonLabel('some_other')).toBe('some other'); | |
| 89 | + }); | |
| 90 | +}); | |
added
apps/web/src/lib/trial-intel.ts
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +/** Pure helpers for the trial-intelligence pages (sorting whitelist, CSV cells). Shared by server components, route handlers and tests. */ | |
| 2 | + | |
| 3 | +export const INTEL_SORT_KEYS = ['active', 'total', 'recruiting', 'phase3Active', 'phase3Recruiting', 'growth', 'avgEnrollment', 'industryShare', 'sponsorHhi', 'distinctCountries', 'usShare', 'terminationShare', 'trialsPer1000Deaths', 'name'] as const; | |
| 4 | +export type IntelSortKey = (typeof INTEL_SORT_KEYS)[number]; | |
| 5 | + | |
| 6 | +/** Minimal shape the sorter needs (snake_case, as returned by the query layer). */ | |
| 7 | +export interface IntelSortable { | |
| 8 | + cancer_name: string; | |
| 9 | + total_trials: number; | |
| 10 | + active_trials: number; | |
| 11 | + recruiting_trials: number; | |
| 12 | + phase3_active: number; | |
| 13 | + phase3_recruiting: number; | |
| 14 | + trial_growth_yoy: number | null; | |
| 15 | + avg_enrollment: number | null; | |
| 16 | + industry_share: number | null; | |
| 17 | + sponsor_hhi: number | null; | |
| 18 | + distinct_countries: number; | |
| 19 | + us_share: number | null; | |
| 20 | + termination_share: number | null; | |
| 21 | + trials_per_1000_deaths: number | null; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const FIELD: Record<IntelSortKey, keyof IntelSortable> = { | |
| 25 | + active: 'active_trials', | |
| 26 | + total: 'total_trials', | |
| 27 | + recruiting: 'recruiting_trials', | |
| 28 | + phase3Active: 'phase3_active', | |
| 29 | + phase3Recruiting: 'phase3_recruiting', | |
| 30 | + growth: 'trial_growth_yoy', | |
| 31 | + avgEnrollment: 'avg_enrollment', | |
| 32 | + industryShare: 'industry_share', | |
| 33 | + sponsorHhi: 'sponsor_hhi', | |
| 34 | + distinctCountries: 'distinct_countries', | |
| 35 | + usShare: 'us_share', | |
| 36 | + terminationShare: 'termination_share', | |
| 37 | + trialsPer1000Deaths: 'trials_per_1000_deaths', | |
| 38 | + name: 'cancer_name', | |
| 39 | +}; | |
| 40 | + | |
| 41 | +export function isIntelSortKey(v: string): v is IntelSortKey { | |
| 42 | + return (INTEL_SORT_KEYS as readonly string[]).includes(v); | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * Sort rows by a whitelisted key. Nulls always go last (whatever the direction) so "unknown" never | |
| 47 | + * ranks above a real value; ties fall back to the cancer name for a deterministic order. | |
| 48 | + */ | |
| 49 | +export function sortIntel<T extends IntelSortable>(rows: readonly T[], key: IntelSortKey, order: 'asc' | 'desc'): T[] { | |
| 50 | + const f = FIELD[key]; | |
| 51 | + const dir = order === 'asc' ? 1 : -1; | |
| 52 | + return [...rows].sort((a, b) => { | |
| 53 | + const va = a[f]; | |
| 54 | + const vb = b[f]; | |
| 55 | + if (va == null && vb == null) return a.cancer_name.localeCompare(b.cancer_name); | |
| 56 | + if (va == null) return 1; | |
| 57 | + if (vb == null) return -1; | |
| 58 | + if (typeof va === 'string' || typeof vb === 'string') return dir * String(va).localeCompare(String(vb)) || a.cancer_name.localeCompare(b.cancer_name); | |
| 59 | + return dir * ((va as number) - (vb as number)) || a.cancer_name.localeCompare(b.cancer_name); | |
| 60 | + }); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Column totals for the header strip (only meaningful for the mutually exclusive top-level set). */ | |
| 64 | +export function intelTotals<T extends IntelSortable>(rows: readonly T[]): { entities: number; total: number; active: number; recruiting: number; phase3Active: number; phase3Recruiting: number } { | |
| 65 | + return rows.reduce( | |
| 66 | + (s, r) => ({ entities: s.entities + 1, total: s.total + r.total_trials, active: s.active + r.active_trials, recruiting: s.recruiting + r.recruiting_trials, phase3Active: s.phase3Active + r.phase3_active, phase3Recruiting: s.phase3Recruiting + r.phase3_recruiting }), | |
| 67 | + { entities: 0, total: 0, active: 0, recruiting: 0, phase3Active: 0, phase3Recruiting: 0 }, | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** RFC 4180 cell: quote when the value contains a comma, quote or newline; null → empty. */ | |
| 72 | +export function csvCell(v: unknown): string { | |
| 73 | + const s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); | |
| 74 | + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Signed percentage for growth values: "+21.5%", "−3.6%"; em dash when null. */ | |
| 78 | +export function fmtGrowth(v: number | null | undefined, digits = 1): string { | |
| 79 | + if (v == null || !Number.isFinite(v)) return '—'; | |
| 80 | + const pct = v * 100; | |
| 81 | + const s = new Intl.NumberFormat('en-US', { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(Math.abs(pct)); | |
| 82 | + return `${pct > 0 ? '+' : pct < 0 ? '−' : ''}${s}%`; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** Human label for a stop-reason category. */ | |
| 86 | +export function reasonLabel(cat: string): string { | |
| 87 | + const map: Record<string, string> = { | |
| 88 | + enrollment: 'Enrollment / accrual', | |
| 89 | + funding: 'Funding', | |
| 90 | + sponsor_decision: 'Sponsor / business decision', | |
| 91 | + safety: 'Safety / toxicity', | |
| 92 | + efficacy: 'Efficacy / futility', | |
| 93 | + drug_supply: 'Drug supply', | |
| 94 | + investigator: 'Investigator', | |
| 95 | + covid: 'COVID-19 / pandemic', | |
| 96 | + other_stated: 'Other (stated, no rule matched)', | |
| 97 | + not_stated: 'Not stated', | |
| 98 | + }; | |
| 99 | + return map[cat] ?? cat.replace(/_/g, ' '); | |
| 100 | +} | |
added
docs/methodology/trial-intelligence.md
+192 −0
@@ -0,0 +1,192 @@ | ||
| 1 | +# Methodology — Clinical trial intelligence | |
| 2 | + | |
| 3 | +Formula version **`ci-trial-intel-v1`** · stop-reason rules **`ci-stop-reasons-v1`** · code | |
| 4 | +`packages/ranking/src/trial-intelligence.ts`, `packages/ranking/src/trial-stop-reasons.ts` · table | |
| 5 | +`trial_intelligence` · recomputed by `pnpm cix intel` after each ClinicalTrials.gov ingest. | |
| 6 | + | |
| 7 | +Every value described here is a **computed metric** (claim category `computed_metric`). Nothing in | |
| 8 | +this layer is an observation: statuses, phases, dates, sponsors, countries, enrollment counts and | |
| 9 | +"why stopped" texts are taken as posted by the registrant on ClinicalTrials.gov, then counted. Each | |
| 10 | +row stores its `formula_version` and an `inputs` JSON (reference day, windows, thresholds, active | |
| 11 | +statuses, denominators, burden observation ids) so any figure can be traced back to its inputs. | |
| 12 | + | |
| 13 | +## 1. Scope and aggregation | |
| 14 | + | |
| 15 | +### 1.1 Which studies attach to a cancer | |
| 16 | + | |
| 17 | +A study is attached to a cancer when one of its reconciled conditions (`trial_conditions.cancer_id`) | |
| 18 | +points to the cancer **or to any of its descendants** in the NCIt-derived hierarchy | |
| 19 | +(`cancer_hierarchy`, recursive traversal, depth ≤ 12). Each study is counted **once per cancer** | |
| 20 | +(`DISTINCT`), even when several of its conditions map to different descendants. This is the same | |
| 21 | +traversal used by `entity_counters` and the ranking engine, so `active_trials` here equals | |
| 22 | +`entity_counters.active_trial_count`. | |
| 23 | + | |
| 24 | +**Caveat (descendant aggregation).** A study registered against a broad condition | |
| 25 | +("solid tumors", "lymphoma") is attributed to the broad entity only, never pushed down to subtypes. | |
| 26 | +Conversely a study registered against a subtype ("lung adenocarcinoma") also counts for every | |
| 27 | +ancestor ("non-small cell lung carcinoma", "lung cancer"). Figures for a parent are therefore not | |
| 28 | +the sum of its children, and entities at level `all` overlap: adding them double counts. | |
| 29 | + | |
| 30 | +### 1.2 Entity levels | |
| 31 | + | |
| 32 | +| `entity_level` | Rows | Purpose | | |
| 33 | +|---|---|---| | |
| 34 | +| `top` | every active cancer with `top_level = true` (36 today), including true zeros | mutually exclusive set — totals may be summed; rankings | | |
| 35 | +| `all` | every active malignant entity with ≥ 1 mapped study of any study type | subtype pages, exploration; overlapping | | |
| 36 | + | |
| 37 | +A top-level cancer has one row per level with identical figures. | |
| 38 | + | |
| 39 | +### 1.3 Study type and statuses | |
| 40 | + | |
| 41 | +Unless stated otherwise, every count uses **`study_type = 'INTERVENTIONAL'`** (observational and | |
| 42 | +expanded-access records are excluded). *Active* means overall status ∈ {`RECRUITING`, | |
| 43 | +`NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`} — the same list as | |
| 44 | +`entity_counters`. `UNKNOWN` status is neither active nor terminal. | |
| 45 | + | |
| 46 | +## 2. Counts | |
| 47 | + | |
| 48 | +| Column | Definition | | |
| 49 | +|---|---| | |
| 50 | +| `total_trials` | interventional studies mapped to the entity or a descendant, any status | | |
| 51 | +| `active_trials` | status ∈ active set | | |
| 52 | +| `recruiting_trials` | status = `RECRUITING` | | |
| 53 | +| `phase1_active` … `phase4_active` | active studies with `PHASEn` ∈ `phases`. A `PHASE2\|PHASE3` study counts in **both** phase 2 and phase 3. `EARLY_PHASE1` is **not** counted as phase 1. | | |
| 54 | +| `phase3_recruiting` | status = `RECRUITING` and `PHASE3` ∈ `phases` | | |
| 55 | +| `completed_trials`, `terminated_trials`, `withdrawn_trials`, `suspended_trials` | by overall status | | |
| 56 | +| `with_results` | `has_results = true` | | |
| 57 | + | |
| 58 | +## 3. Growth | |
| 59 | + | |
| 60 | +Registration date = `first_posted_date`. With `asOf` = the day of computation (stored in | |
| 61 | +`inputs.asOf`) and calendar-month arithmetic (day clamped to the month end): | |
| 62 | + | |
| 63 | +``` | |
| 64 | +new_trials_12m = studies with first_posted_date ∈ [asOf − 12 months, asOf) | |
| 65 | +new_trials_prior_12m = studies with first_posted_date ∈ [asOf − 24 months, asOf − 12 months) | |
| 66 | +trial_growth_yoy = (new_trials_12m − new_trials_prior_12m) / new_trials_prior_12m | |
| 67 | +``` | |
| 68 | + | |
| 69 | +`trial_growth_yoy` is **null when `new_trials_prior_12m` < 20** (`inputs.thresholds.growthMinPriorTrials`) | |
| 70 | +so small denominators do not produce spurious growth. The exact window bounds are stored in | |
| 71 | +`inputs.windows`. Because the reference day moves, two runs on different days are not comparable | |
| 72 | +window for window; the ranking snapshot keeps the run's windows in each row's inputs. | |
| 73 | + | |
| 74 | +## 4. Enrollment | |
| 75 | + | |
| 76 | +Over active interventional studies: `avg_enrollment` = mean of `enrollment_count`, | |
| 77 | +`median_enrollment` = `percentile_cont(0.5)`, `total_enrollment_active` = sum. `enrollment_count` is | |
| 78 | +the registrant's figure and may be *anticipated* rather than *actual* (`enrollment_type`); studies | |
| 79 | +without a count are ignored by the mean and median. | |
| 80 | + | |
| 81 | +## 5. Sponsors (active interventional studies) | |
| 82 | + | |
| 83 | +* `distinct_sponsors` — distinct `lead_sponsor` strings (no normalisation: "NCI" and "National | |
| 84 | + Cancer Institute (NCI)" are two sponsors). | |
| 85 | +* `industry_share` = studies with `lead_sponsor_class = 'INDUSTRY'` / active studies. | |
| 86 | +* `sponsor_hhi` — Herfindahl–Hirschman index of lead sponsors: | |
| 87 | + `HHI = Σ_s (n_s / N)²` where `n_s` = active studies led by sponsor *s* and `N` = active studies | |
| 88 | + with a lead sponsor (`inputs.denominators.sponsor`). Range (0, 1]; 1 = a single sponsor; 1/k for | |
| 89 | + *k* equal sponsors. **Null when active studies < 10** (`hhiMinActiveTrials`). | |
| 90 | +* `top_sponsor`, `top_sponsor_share` — the sponsor with the most active studies (ties broken | |
| 91 | + alphabetically) and `n_top / N`. | |
| 92 | + | |
| 93 | +## 6. Geography (active interventional studies) | |
| 94 | + | |
| 95 | +Based on the study-level `countries` array (distinct countries with at least one site). | |
| 96 | + | |
| 97 | +* `distinct_countries` — countries appearing in at least one active study. | |
| 98 | +* `us_share` = studies with `'United States' ∈ countries` / active studies. | |
| 99 | +* `top_country`, `top_country_share` — most frequent country and its share of active studies. | |
| 100 | +* `country_hhi` — HHI over **trial–country pairs**: `Σ_c (p_c / P)²` where `p_c` = active studies | |
| 101 | + listing country *c* and `P` = Σ p_c (`inputs.denominators.countryPairs`). | |
| 102 | + | |
| 103 | +**Caveat.** A multinational study contributes one pair to *every* country it lists, so country shares | |
| 104 | +can sum above 100 % and `country_hhi` measures the concentration of site presence, not of studies. | |
| 105 | + | |
| 106 | +## 7. Failures | |
| 107 | + | |
| 108 | +### 7.1 Termination share | |
| 109 | + | |
| 110 | +Over interventional studies with `first_posted_date ≥ 2010-01-01` (`terminationSince`): | |
| 111 | + | |
| 112 | +``` | |
| 113 | +termination_share = (terminated + withdrawn) / (completed + terminated + withdrawn) | |
| 114 | +``` | |
| 115 | + | |
| 116 | +Null when the denominator (terminal studies, `inputs.denominators.terminal`) is **< 30** | |
| 117 | +(`terminationMinTerminalTrials`). Suspended, active and unknown-status studies are not terminal | |
| 118 | +and are excluded from both numerator and denominator. Statuses are registrant-reported; a | |
| 119 | +`TERMINATED` status does not imply a negative result (many studies stop for accrual reasons). | |
| 120 | + | |
| 121 | +### 7.2 Stop-reason classification (`why_stopped_breakdown`) | |
| 122 | + | |
| 123 | +For interventional studies with status `TERMINATED`, `WITHDRAWN` or `SUSPENDED`, the free-text | |
| 124 | +`why_stopped` is mapped to one category using **explicit keyword rules only** (case-insensitive, | |
| 125 | +whole-word or word-prefix matches). Rules are tested in the order below; **the first match wins** | |
| 126 | +so specific causes take precedence over broad ones, and `sponsor_decision` — the broadest — is | |
| 127 | +tested last. The API also returns every matched category (`reasonMatches`). Nothing is inferred | |
| 128 | +from the design, sponsor or outcome of a study. | |
| 129 | + | |
| 130 | +| Order | Category | Keywords / patterns | | |
| 131 | +|---|---|---| | |
| 132 | +| 1 | `covid` | `covid…`, `pandemic` | | |
| 133 | +| 2 | `safety` | `safety`, `toxicit…`, `adverse` | | |
| 134 | +| 3 | `efficacy` | `efficacy`, `futility`, `lack of [clinical/therapeutic] benefit`, `interim analysis/analyses` | | |
| 135 | +| 4 | `drug_supply` | `supply`, `drug availability`, `manufactur…` | | |
| 136 | +| 5 | `investigator` | `PI left`, `investigator` | | |
| 137 | +| 6 | `enrollment` | `accru…` (accrual), `enrol…` (enrollment/enrolment), `recruit…` (recruitment) | | |
| 138 | +| 7 | `funding` | `fund`/`funds`/`funded`/`funding`, `financ…`, `budget…` | | |
| 139 | +| 8 | `sponsor_decision` | `business`, `sponsor decision` / `sponsor's decision` / `decision of/by the sponsor` / `sponsor decided`, `strateg…`, `portfolio`, `company` | | |
| 140 | +| — | `other_stated` | text present, no rule matched | | |
| 141 | +| — | `not_stated` | `why_stopped` null or blank | | |
| 142 | + | |
| 143 | +The breakdown is a JSON object `{ category: count }` over the entity's stopped interventional | |
| 144 | +studies; categories with zero studies are omitted. `inputs.stopReasonRulesVersion` records the rule | |
| 145 | +set. The public pages `/trials/terminated` and the API route `/v1/trials/terminated` apply the same | |
| 146 | +rules to any filtered set of studies (all study types there, with `studyType` as a filter). | |
| 147 | + | |
| 148 | +## 8. Burden-normalized intensity (top-level cancers only) | |
| 149 | + | |
| 150 | +``` | |
| 151 | +trials_per_1000_deaths = active_trials / (deaths / 1 000) | |
| 152 | +trials_per_100k_cases = active_trials / (incidence / 100 000) | |
| 153 | +``` | |
| 154 | + | |
| 155 | +`deaths` and `incidence` come from `epidemiology_observations` for geography **USA** | |
| 156 | +(`burden_geography`), `sex = 'all'`, `age_group = 'all'`, metrics `mortality_count` and | |
| 157 | +`incidence_count`, **from the same `source_id` and the same year**. The latest year where both | |
| 158 | +counts exist is used (`burden_year`, `burden_source_id`); ties are broken by source id then | |
| 159 | +observation id so the choice is deterministic. **Deaths must be ≥ 100** (`burdenMinDeaths`), | |
| 160 | +otherwise both ratios are null. The observation ids used are stored in `inputs.burden`. | |
| 161 | + | |
| 162 | +Level `all` rows carry the burden ratios only when the entity is itself top-level; subtypes have no | |
| 163 | +site-level burden counts in the US registries and get null (shown as "—", never zero). | |
| 164 | + | |
| 165 | +**Caveats.** Trial counts are worldwide while the burden is US; the ratio therefore measures | |
| 166 | +worldwide research intensity relative to US burden, not US research per US death. Trial counts | |
| 167 | +depend on how registrants phrase conditions; burden counts depend on the registry's site definition | |
| 168 | +(`site_definition` on the observation). | |
| 169 | + | |
| 170 | +## 9. Rankings derived from this layer | |
| 171 | + | |
| 172 | +`rankTrialIntelligence` (called at the end of `computeTrialIntelligence`) persists snapshots for | |
| 173 | +scope `geo=WORLD | sex=all | age=all | year=latest | level=top|all`: | |
| 174 | + | |
| 175 | +| Metric slug | Value | Eligibility | Direction | | |
| 176 | +|---|---|---|---| | |
| 177 | +| `phase3_recruiting_trials` | `phase3_recruiting` | > 0 | descending | | |
| 178 | +| `trial_growth_yoy` | `trial_growth_yoy` | non-null (prior window ≥ 20) | descending | | |
| 179 | +| `trial_termination_share` | `termination_share` | non-null (terminal ≥ 30) | descending — rank 1 = highest share (`higher_is_worse` is display information) | | |
| 180 | +| `sponsor_concentration` | `sponsor_hhi` | non-null (active ≥ 10) | descending | | |
| 181 | + | |
| 182 | +Each ranking row's `inputs` holds the counts and windows behind its value. A metric is skipped when | |
| 183 | +fewer than three entities are eligible. | |
| 184 | + | |
| 185 | +## 10. Reproducibility | |
| 186 | + | |
| 187 | +The computation is a single SQL transaction: descendants temp table → cancer × study map → | |
| 188 | +per-cancer aggregates → delete + insert of both levels. Given the same database state and the same | |
| 189 | +`asOf` day it is deterministic (~14 s on the reference database: 126 195 studies, 187 523 condition | |
| 190 | +mappings, 9 510 cancers → 36 `top` rows + ~2 200 `all` rows). Pure formula helpers | |
| 191 | +(`growthWindows`, `growthYoy`, `hhi`, `terminationShare`, `burdenNormalized`, `classifyStopReason`) | |
| 192 | +are unit-tested in `packages/ranking/src/*.test.ts`. | |
added
packages/ranking/src/trial-intelligence.test.ts
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { TRIAL_INTEL_ACTIVE_STATUSES, TRIAL_INTEL_THRESHOLDS, TRIAL_INTELLIGENCE_FORMULA_VERSION, burdenNormalized, growthWindows, growthYoy, hhi, terminationShare } from './trial-intelligence.js'; | |
| 3 | + | |
| 4 | +describe('growthWindows', () => { | |
| 5 | + it('builds two adjacent, non-overlapping 12-month windows ending on asOf (exclusive)', () => { | |
| 6 | + const w = growthWindows('2026-09-11'); | |
| 7 | + expect(w).toEqual({ asOf: '2026-09-11', new12m: { from: '2025-09-11', to: '2026-09-11' }, prior12m: { from: '2024-09-11', to: '2025-09-11' } }); | |
| 8 | + expect(w.prior12m.to).toBe(w.new12m.from); | |
| 9 | + }); | |
| 10 | + it('clamps month-end days (leap day, 31st)', () => { | |
| 11 | + expect(growthWindows('2024-02-29').new12m.from).toBe('2023-02-28'); | |
| 12 | + expect(growthWindows('2025-03-31').prior12m.from).toBe('2023-03-31'); | |
| 13 | + expect(growthWindows('2025-05-31').new12m.from).toBe('2024-05-31'); | |
| 14 | + expect(growthWindows('2025-12-31').new12m.from).toBe('2024-12-31'); | |
| 15 | + }); | |
| 16 | + it('rejects malformed dates', () => { | |
| 17 | + expect(() => growthWindows('2026-9-1')).toThrow(); | |
| 18 | + expect(() => growthWindows('nope')).toThrow(); | |
| 19 | + }); | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe('growthYoy', () => { | |
| 23 | + it('computes (new − prior) / prior', () => { | |
| 24 | + expect(growthYoy(120, 100)).toBeCloseTo(0.2); | |
| 25 | + expect(growthYoy(80, 100)).toBeCloseTo(-0.2); | |
| 26 | + expect(growthYoy(100, 100)).toBe(0); | |
| 27 | + }); | |
| 28 | + it('is null under the prior-window threshold (default 20)', () => { | |
| 29 | + expect(growthYoy(50, 19)).toBeNull(); | |
| 30 | + expect(growthYoy(50, 20)).toBeCloseTo(1.5); | |
| 31 | + expect(growthYoy(5, 0)).toBeNull(); | |
| 32 | + expect(growthYoy(5, 2, 2)).toBeCloseTo(1.5); | |
| 33 | + }); | |
| 34 | + it('threshold matches the persisted constant', () => { | |
| 35 | + expect(TRIAL_INTEL_THRESHOLDS.growthMinPriorTrials).toBe(20); | |
| 36 | + }); | |
| 37 | +}); | |
| 38 | + | |
| 39 | +describe('hhi', () => { | |
| 40 | + it('is 1 for a single holder and 1/n for n equal holders', () => { | |
| 41 | + expect(hhi([42])).toBe(1); | |
| 42 | + expect(hhi([10, 10, 10, 10])).toBeCloseTo(0.25); | |
| 43 | + }); | |
| 44 | + it('is Σ share² for an uneven distribution', () => { | |
| 45 | + // shares 0.5, 0.3, 0.2 → 0.25 + 0.09 + 0.04 | |
| 46 | + expect(hhi([50, 30, 20])).toBeCloseTo(0.38); | |
| 47 | + }); | |
| 48 | + it('is null when empty or all zero, ignores negative / non-finite counts', () => { | |
| 49 | + expect(hhi([])).toBeNull(); | |
| 50 | + expect(hhi([0, 0])).toBeNull(); | |
| 51 | + expect(hhi([5, -1, Number.NaN, 5])).toBeCloseTo(0.5); | |
| 52 | + }); | |
| 53 | +}); | |
| 54 | + | |
| 55 | +describe('terminationShare', () => { | |
| 56 | + it('computes (terminated + withdrawn) / (completed + terminated + withdrawn)', () => { | |
| 57 | + expect(terminationShare(70, 20, 10)).toBeCloseTo(0.3); | |
| 58 | + expect(terminationShare(100, 0, 0)).toBe(0); | |
| 59 | + }); | |
| 60 | + it('is null when the terminal denominator is under 30', () => { | |
| 61 | + expect(terminationShare(20, 5, 4)).toBeNull(); | |
| 62 | + expect(terminationShare(20, 5, 5)).toBeCloseTo(1 / 3); | |
| 63 | + expect(terminationShare(0, 0, 0)).toBeNull(); | |
| 64 | + }); | |
| 65 | + it('threshold matches the persisted constant', () => { | |
| 66 | + expect(TRIAL_INTEL_THRESHOLDS.terminationMinTerminalTrials).toBe(30); | |
| 67 | + }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +describe('burdenNormalized', () => { | |
| 71 | + it('computes trials per 1,000 deaths and per 100,000 cases', () => { | |
| 72 | + const r = burdenNormalized(500, 125_000, 250_000); | |
| 73 | + expect(r.per1000Deaths).toBeCloseTo(4); | |
| 74 | + expect(r.per100kCases).toBeCloseTo(200); | |
| 75 | + }); | |
| 76 | + it('is null when deaths are missing or under 100', () => { | |
| 77 | + expect(burdenNormalized(10, null, 1000)).toEqual({ per1000Deaths: null, per100kCases: null }); | |
| 78 | + expect(burdenNormalized(10, 99, 1000)).toEqual({ per1000Deaths: null, per100kCases: null }); | |
| 79 | + expect(burdenNormalized(10, 100, 1000).per1000Deaths).toBeCloseTo(100); | |
| 80 | + }); | |
| 81 | + it('leaves the incidence ratio null when incidence is missing or zero', () => { | |
| 82 | + expect(burdenNormalized(10, 1000, null).per100kCases).toBeNull(); | |
| 83 | + expect(burdenNormalized(10, 1000, 0).per100kCases).toBeNull(); | |
| 84 | + }); | |
| 85 | + it('zero active trials gives zero, never null (a true zero)', () => { | |
| 86 | + expect(burdenNormalized(0, 1000, 2000)).toEqual({ per1000Deaths: 0, per100kCases: 0 }); | |
| 87 | + }); | |
| 88 | +}); | |
| 89 | + | |
| 90 | +describe('constants', () => { | |
| 91 | + it('formula version and active statuses match entity_counters', () => { | |
| 92 | + expect(TRIAL_INTELLIGENCE_FORMULA_VERSION).toBe('ci-trial-intel-v1'); | |
| 93 | + expect([...TRIAL_INTEL_ACTIVE_STATUSES]).toEqual(['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING']); | |
| 94 | + expect(TRIAL_INTEL_THRESHOLDS.burdenGeography).toBe('USA'); | |
| 95 | + expect(TRIAL_INTEL_THRESHOLDS.terminationSince).toBe('2010-01-01'); | |
| 96 | + }); | |
| 97 | +}); | |
modified
packages/ranking/src/trial-intelligence.ts
+363 −5
@@ -1,15 +1,373 @@ | ||
| 1 | −import type { Database } from '@cancerindex/database'; | |
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import { type Database, metricDefinitions } from '@cancerindex/database'; | |
| 3 | +import { persistSnapshot, type RankingResult, type Scope } from './engine.js'; | |
| 4 | +import type { RankInput } from './rank.js'; | |
| 5 | +import { STOP_REASON_RULES_VERSION, classifyStopReason } from './trial-stop-reasons.js'; | |
| 6 | + | |
| 7 | +// The stop-reason classifier is part of this work package's public surface (API + web reuse it). | |
| 8 | +export * from './trial-stop-reasons.js'; | |
| 2 | 9 | |
| 3 | 10 | export const TRIAL_INTELLIGENCE_FORMULA_VERSION = 'ci-trial-intel-v1'; |
| 4 | 11 | |
| 12 | +/** Registry statuses counted as "active" (same list as counters.ts so numbers match entity_counters). */ | |
| 13 | +export const TRIAL_INTEL_ACTIVE_STATUSES = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'] as const; | |
| 14 | +// Inline literal array: drizzle expands a JS array parameter into a ($1,$2,…) tuple, which breaks ANY(). | |
| 15 | +const ACTIVE_SQL = `ARRAY[${TRIAL_INTEL_ACTIVE_STATUSES.map((s) => `'${s}'`).join(',')}]::text[]`; | |
| 16 | + | |
| 17 | +/** Every threshold used by the formulas — persisted in `inputs.thresholds` of each row. */ | |
| 18 | +export const TRIAL_INTEL_THRESHOLDS = { | |
| 19 | + /** trial_growth_yoy is null when fewer studies were first posted in the prior 12-month window. */ | |
| 20 | + growthMinPriorTrials: 20, | |
| 21 | + /** sponsor_hhi is null when fewer active interventional studies. */ | |
| 22 | + hhiMinActiveTrials: 10, | |
| 23 | + /** termination_share is null when fewer terminal studies (completed + terminated + withdrawn). */ | |
| 24 | + terminationMinTerminalTrials: 30, | |
| 25 | + /** termination_share only considers studies first posted on/after this date. */ | |
| 26 | + terminationSince: '2010-01-01', | |
| 27 | + /** burden-normalized ratios require at least this many annual deaths. */ | |
| 28 | + burdenMinDeaths: 100, | |
| 29 | + /** NCIt-hierarchy traversal depth for descendants (same as counters.ts). */ | |
| 30 | + maxHierarchyDepth: 12, | |
| 31 | + /** Burden scope for trials_per_1000_deaths / trials_per_100k_cases. */ | |
| 32 | + burdenGeography: 'USA', | |
| 33 | +} as const; | |
| 34 | + | |
| 5 | 35 | export interface TrialIntelligenceResult { |
| 6 | 36 | rows: number; |
| 37 | + topRows: number; | |
| 38 | + allRows: number; | |
| 39 | + stopReasonsClassified: number; | |
| 40 | + rankings: RankingResult[]; | |
| 41 | + ms: number; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * Growth windows as ISO dates for a reference day (pure, unit-tested): | |
| 46 | + * new_trials_12m ← first_posted_date ∈ [asOf − 12 months, asOf) | |
| 47 | + * new_trials_prior_12m ← first_posted_date ∈ [asOf − 24 months, asOf − 12 months) | |
| 48 | + */ | |
| 49 | +export function growthWindows(asOf: string): { asOf: string; new12m: { from: string; to: string }; prior12m: { from: string; to: string } } { | |
| 50 | + const d = new Date(`${asOf}T00:00:00Z`); | |
| 51 | + if (!/^\d{4}-\d{2}-\d{2}$/.test(asOf) || Number.isNaN(d.getTime())) throw new Error(`growthWindows: invalid asOf date "${asOf}"`); | |
| 52 | + const minus = (months: number) => { | |
| 53 | + const x = new Date(d); | |
| 54 | + const day = x.getUTCDate(); | |
| 55 | + x.setUTCDate(1); | |
| 56 | + x.setUTCMonth(x.getUTCMonth() - months); | |
| 57 | + const last = new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth() + 1, 0)).getUTCDate(); | |
| 58 | + x.setUTCDate(Math.min(day, last)); | |
| 59 | + return x.toISOString().slice(0, 10); | |
| 60 | + }; | |
| 61 | + const m12 = minus(12); | |
| 62 | + const m24 = minus(24); | |
| 63 | + return { asOf, new12m: { from: m12, to: asOf }, prior12m: { from: m24, to: m12 } }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** (new − prior) / prior, null under the eligibility threshold (pure, unit-tested). */ | |
| 67 | +export function growthYoy(new12: number, prior12: number, minPrior: number = TRIAL_INTEL_THRESHOLDS.growthMinPriorTrials): number | null { | |
| 68 | + if (!Number.isFinite(new12) || !Number.isFinite(prior12) || prior12 < minPrior || prior12 <= 0) return null; | |
| 69 | + return (new12 - prior12) / prior12; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Herfindahl–Hirschman index of a count distribution: Σ (nᵢ / Σn)² ∈ (0, 1]; null when empty (pure, unit-tested). */ | |
| 73 | +export function hhi(counts: Iterable<number>): number | null { | |
| 74 | + let total = 0; | |
| 75 | + let sq = 0; | |
| 76 | + for (const n of counts) { | |
| 77 | + if (!Number.isFinite(n) || n < 0) continue; | |
| 78 | + total += n; | |
| 79 | + sq += n * n; | |
| 80 | + } | |
| 81 | + return total > 0 ? sq / (total * total) : null; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** (terminated + withdrawn) / (completed + terminated + withdrawn); null under the threshold (pure, unit-tested). */ | |
| 85 | +export function terminationShare(completed: number, terminated: number, withdrawn: number, minTerminal: number = TRIAL_INTEL_THRESHOLDS.terminationMinTerminalTrials): number | null { | |
| 86 | + const denom = completed + terminated + withdrawn; | |
| 87 | + if (denom < minTerminal || denom <= 0) return null; | |
| 88 | + return (terminated + withdrawn) / denom; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** active / (deaths / 1000) and active / (incidence / 100 000); null when deaths are under the threshold (pure, unit-tested). */ | |
| 92 | +export function burdenNormalized(active: number, deaths: number | null, incidence: number | null, minDeaths: number = TRIAL_INTEL_THRESHOLDS.burdenMinDeaths): { per1000Deaths: number | null; per100kCases: number | null } { | |
| 93 | + if (deaths == null || !Number.isFinite(deaths) || deaths < minDeaths) return { per1000Deaths: null, per100kCases: null }; | |
| 94 | + return { | |
| 95 | + per1000Deaths: active / (deaths / 1000), | |
| 96 | + per100kCases: incidence != null && Number.isFinite(incidence) && incidence > 0 ? active / (incidence / 100_000) : null, | |
| 97 | + }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +/** | |
| 101 | + * Recompute `trial_intelligence` for every top-level cancer (entity_level = 'top') and every active | |
| 102 | + * malignant entity with ≥ 1 mapped trial (entity_level = 'all'). Trials attach to a cancer through | |
| 103 | + * `trial_conditions.cancer_id` over its NCIt-hierarchy descendants (depth ≤ 12, DISTINCT trials) — | |
| 104 | + * exactly like entity_counters. Counts are over interventional studies. Set-based SQL over temp | |
| 105 | + * tables; one transaction; deterministic for a given `asOf` day. Rankings for the four | |
| 106 | + * trial-intelligence metrics are refreshed at the end. | |
| 107 | + */ | |
| 108 | +export async function computeTrialIntelligence(db: Database, opts: { asOf?: string } = {}): Promise<TrialIntelligenceResult> { | |
| 109 | + const t0 = Date.now(); | |
| 110 | + const asOf = opts.asOf ?? new Date().toISOString().slice(0, 10); | |
| 111 | + const windows = growthWindows(asOf); | |
| 112 | + const th = TRIAL_INTEL_THRESHOLDS; | |
| 113 | + | |
| 114 | + // 1. Classify registrant-reported stop reasons in TypeScript (pure rules), to be joined in SQL. | |
| 115 | + const stopped = await db.execute<{ id: string; why_stopped: string | null }>(sql` | |
| 116 | + SELECT id, why_stopped FROM clinical_trials WHERE overall_status IN ('TERMINATED','WITHDRAWN','SUSPENDED')`); | |
| 117 | + const stopIds: string[] = []; | |
| 118 | + const stopCats: string[] = []; | |
| 119 | + for (const r of stopped) { | |
| 120 | + stopIds.push(r.id); | |
| 121 | + stopCats.push(classifyStopReason(r.why_stopped).category); | |
| 122 | + } | |
| 123 | + | |
| 124 | + const { top, all } = await db.transaction(async (tx) => { | |
| 125 | + await tx.execute(sql`CREATE TEMP TABLE _ti_desc (ancestor varchar(32), descendant varchar(32)) ON COMMIT DROP`); | |
| 126 | + await tx.execute(sql` | |
| 127 | + INSERT INTO _ti_desc | |
| 128 | + WITH RECURSIVE d AS ( | |
| 129 | + SELECT id AS ancestor, id AS descendant, 0 AS depth FROM cancers WHERE status = 'active' | |
| 130 | + UNION | |
| 131 | + SELECT d.ancestor, h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.descendant | |
| 132 | + WHERE d.depth < ${sql.raw(String(th.maxHierarchyDepth))} | |
| 133 | + ) | |
| 134 | + SELECT DISTINCT ancestor, descendant FROM d`); | |
| 135 | + await tx.execute(sql`CREATE INDEX ON _ti_desc (descendant)`); | |
| 136 | + | |
| 137 | + await tx.execute(sql`CREATE TEMP TABLE _ti_stop (trial_id varchar(32) PRIMARY KEY, category text NOT NULL) ON COMMIT DROP`); | |
| 138 | + const chunk = 20_000; | |
| 139 | + for (let i = 0; i < stopIds.length; i += chunk) { | |
| 140 | + await tx.execute(sql`INSERT INTO _ti_stop (trial_id, category) SELECT * FROM unnest(${sql.param(stopIds.slice(i, i + chunk))}::text[], ${sql.param(stopCats.slice(i, i + chunk))}::text[])`); | |
| 141 | + } | |
| 142 | + | |
| 143 | + // 2. cancer × trial map (DISTINCT over descendants) with the trial fields the formulas need. | |
| 144 | + await tx.execute(sql` | |
| 145 | + CREATE TEMP TABLE _ti_map ON COMMIT DROP AS | |
| 146 | + SELECT m.cancer_id, t.id AS trial_id, | |
| 147 | + t.study_type = 'INTERVENTIONAL' AS interventional, | |
| 148 | + t.overall_status AS status, | |
| 149 | + t.overall_status = ANY(${sql.raw(ACTIVE_SQL)}) AS active, | |
| 150 | + t.phases, t.first_posted_date::date AS first_posted, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.countries, t.has_results | |
| 151 | + FROM (SELECT DISTINCT d.ancestor AS cancer_id, tc.trial_id FROM trial_conditions tc JOIN _ti_desc d ON d.descendant = tc.cancer_id WHERE tc.cancer_id IS NOT NULL) m | |
| 152 | + JOIN clinical_trials t ON t.id = m.trial_id`); | |
| 153 | + await tx.execute(sql`CREATE INDEX ON _ti_map (cancer_id)`); | |
| 154 | + | |
| 155 | + // 3. Per-cancer aggregates (computed once, inserted for each entity level the cancer belongs to). | |
| 156 | + await tx.execute(sql` | |
| 157 | + CREATE TEMP TABLE _ti_agg ON COMMIT DROP AS | |
| 158 | + WITH base AS ( | |
| 159 | + SELECT cancer_id, | |
| 160 | + count(*) AS mapped_any_type, | |
| 161 | + count(*) FILTER (WHERE interventional) AS total_trials, | |
| 162 | + count(*) FILTER (WHERE interventional AND active) AS active_trials, | |
| 163 | + count(*) FILTER (WHERE interventional AND status = 'RECRUITING') AS recruiting_trials, | |
| 164 | + count(*) FILTER (WHERE interventional AND active AND 'PHASE1' = ANY(phases)) AS phase1_active, | |
| 165 | + count(*) FILTER (WHERE interventional AND active AND 'PHASE2' = ANY(phases)) AS phase2_active, | |
| 166 | + count(*) FILTER (WHERE interventional AND active AND 'PHASE3' = ANY(phases)) AS phase3_active, | |
| 167 | + count(*) FILTER (WHERE interventional AND status = 'RECRUITING' AND 'PHASE3' = ANY(phases)) AS phase3_recruiting, | |
| 168 | + count(*) FILTER (WHERE interventional AND active AND 'PHASE4' = ANY(phases)) AS phase4_active, | |
| 169 | + count(*) FILTER (WHERE interventional AND status = 'COMPLETED') AS completed_trials, | |
| 170 | + count(*) FILTER (WHERE interventional AND status = 'TERMINATED') AS terminated_trials, | |
| 171 | + count(*) FILTER (WHERE interventional AND status = 'WITHDRAWN') AS withdrawn_trials, | |
| 172 | + count(*) FILTER (WHERE interventional AND status = 'SUSPENDED') AS suspended_trials, | |
| 173 | + count(*) FILTER (WHERE interventional AND has_results) AS with_results, | |
| 174 | + count(*) FILTER (WHERE interventional AND first_posted >= ${windows.new12m.from}::date AND first_posted < ${windows.new12m.to}::date) AS new_trials_12m, | |
| 175 | + count(*) FILTER (WHERE interventional AND first_posted >= ${windows.prior12m.from}::date AND first_posted < ${windows.prior12m.to}::date) AS new_trials_prior_12m, | |
| 176 | + avg(enrollment_count) FILTER (WHERE interventional AND active) AS avg_enrollment, | |
| 177 | + percentile_cont(0.5) WITHIN GROUP (ORDER BY enrollment_count) FILTER (WHERE interventional AND active AND enrollment_count IS NOT NULL) AS median_enrollment, | |
| 178 | + sum(enrollment_count) FILTER (WHERE interventional AND active) AS total_enrollment_active, | |
| 179 | + count(*) FILTER (WHERE interventional AND active AND lead_sponsor_class = 'INDUSTRY') AS industry_active, | |
| 180 | + count(*) FILTER (WHERE interventional AND active AND 'United States' = ANY(countries)) AS us_active, | |
| 181 | + count(*) FILTER (WHERE interventional AND first_posted >= ${th.terminationSince}::date AND status = 'COMPLETED') AS term_completed, | |
| 182 | + count(*) FILTER (WHERE interventional AND first_posted >= ${th.terminationSince}::date AND status = 'TERMINATED') AS term_terminated, | |
| 183 | + count(*) FILTER (WHERE interventional AND first_posted >= ${th.terminationSince}::date AND status = 'WITHDRAWN') AS term_withdrawn | |
| 184 | + FROM _ti_map GROUP BY cancer_id | |
| 185 | + ), | |
| 186 | + sp AS ( | |
| 187 | + SELECT cancer_id, lead_sponsor, count(*) AS n FROM _ti_map WHERE interventional AND active AND lead_sponsor IS NOT NULL GROUP BY 1, 2 | |
| 188 | + ), | |
| 189 | + spa AS ( | |
| 190 | + SELECT cancer_id, count(*) AS distinct_sponsors, sum(n) AS sponsor_denominator, sum(n * n)::double precision / (sum(n) * sum(n)) AS sponsor_hhi, | |
| 191 | + (array_agg(lead_sponsor ORDER BY n DESC, lead_sponsor))[1] AS top_sponsor, max(n) AS top_sponsor_n | |
| 192 | + FROM sp GROUP BY cancer_id | |
| 193 | + ), | |
| 194 | + co AS ( | |
| 195 | + SELECT cancer_id, c AS country, count(*) AS n FROM _ti_map, unnest(countries) c WHERE interventional AND active GROUP BY 1, 2 | |
| 196 | + ), | |
| 197 | + coa AS ( | |
| 198 | + SELECT cancer_id, count(*) AS distinct_countries, sum(n) AS country_pairs, sum(n * n)::double precision / (sum(n) * sum(n)) AS country_hhi, | |
| 199 | + (array_agg(country ORDER BY n DESC, country))[1] AS top_country, max(n) AS top_country_n | |
| 200 | + FROM co GROUP BY cancer_id | |
| 201 | + ), | |
| 202 | + ws AS ( | |
| 203 | + SELECT m.cancer_id, s.category, count(*) AS n FROM _ti_map m JOIN _ti_stop s ON s.trial_id = m.trial_id | |
| 204 | + WHERE m.interventional AND m.status IN ('TERMINATED','WITHDRAWN','SUSPENDED') GROUP BY 1, 2 | |
| 205 | + ), | |
| 206 | + wsa AS ( | |
| 207 | + SELECT cancer_id, jsonb_object_agg(category, n ORDER BY category) AS why_stopped_breakdown FROM ws GROUP BY cancer_id | |
| 208 | + ), | |
| 209 | + bur AS ( | |
| 210 | + SELECT DISTINCT ON (x.cancer_id) x.* FROM ( | |
| 211 | + SELECT m.cancer_id, m.year, m.source_id, m.value AS deaths, i.value AS incidence, m.id AS mortality_obs_id, i.id AS incidence_obs_id | |
| 212 | + FROM epidemiology_observations m | |
| 213 | + JOIN geographies g ON g.id = m.geography_id AND g.iso3 = ${th.burdenGeography} | |
| 214 | + JOIN epidemiology_observations i ON i.cancer_id = m.cancer_id AND i.geography_id = m.geography_id AND i.year = m.year AND i.source_id = m.source_id | |
| 215 | + AND i.sex = m.sex AND i.age_group = m.age_group AND i.metric = 'incidence_count' | |
| 216 | + WHERE m.metric = 'mortality_count' AND m.sex = 'all' AND m.age_group = 'all' AND m.value >= ${sql.raw(String(th.burdenMinDeaths))} | |
| 217 | + ) x ORDER BY x.cancer_id, x.year DESC, x.source_id, x.mortality_obs_id, x.incidence_obs_id | |
| 218 | + ) | |
| 219 | + SELECT c.id AS cancer_id, c.top_level, c.malignant, | |
| 220 | + b.mapped_any_type, b.total_trials, b.active_trials, b.recruiting_trials, b.phase1_active, b.phase2_active, b.phase3_active, b.phase3_recruiting, b.phase4_active, | |
| 221 | + b.completed_trials, b.terminated_trials, b.withdrawn_trials, b.suspended_trials, b.with_results, | |
| 222 | + b.new_trials_12m, b.new_trials_prior_12m, | |
| 223 | + CASE WHEN b.new_trials_prior_12m >= ${sql.raw(String(th.growthMinPriorTrials))} THEN (b.new_trials_12m - b.new_trials_prior_12m)::double precision / b.new_trials_prior_12m END AS trial_growth_yoy, | |
| 224 | + b.avg_enrollment, b.median_enrollment, b.total_enrollment_active, | |
| 225 | + COALESCE(s.distinct_sponsors, 0) AS distinct_sponsors, | |
| 226 | + CASE WHEN b.active_trials > 0 THEN b.industry_active::double precision / b.active_trials END AS industry_share, | |
| 227 | + CASE WHEN b.active_trials >= ${sql.raw(String(th.hhiMinActiveTrials))} THEN s.sponsor_hhi END AS sponsor_hhi, | |
| 228 | + s.top_sponsor, | |
| 229 | + CASE WHEN s.sponsor_denominator > 0 THEN s.top_sponsor_n::double precision / s.sponsor_denominator END AS top_sponsor_share, | |
| 230 | + COALESCE(s.sponsor_denominator, 0) AS sponsor_denominator, | |
| 231 | + COALESCE(g.distinct_countries, 0) AS distinct_countries, | |
| 232 | + CASE WHEN b.active_trials > 0 THEN b.us_active::double precision / b.active_trials END AS us_share, | |
| 233 | + g.top_country, | |
| 234 | + CASE WHEN b.active_trials > 0 THEN g.top_country_n::double precision / b.active_trials END AS top_country_share, | |
| 235 | + g.country_hhi, | |
| 236 | + COALESCE(g.country_pairs, 0) AS country_pairs, | |
| 237 | + b.term_completed, b.term_terminated, b.term_withdrawn, | |
| 238 | + CASE WHEN (b.term_completed + b.term_terminated + b.term_withdrawn) >= ${sql.raw(String(th.terminationMinTerminalTrials))} | |
| 239 | + THEN (b.term_terminated + b.term_withdrawn)::double precision / (b.term_completed + b.term_terminated + b.term_withdrawn) END AS termination_share, | |
| 240 | + COALESCE(w.why_stopped_breakdown, '{}'::jsonb) AS why_stopped_breakdown, | |
| 241 | + CASE WHEN c.top_level THEN b.active_trials::double precision / (r.deaths / 1000) END AS trials_per_1000_deaths, | |
| 242 | + CASE WHEN c.top_level AND r.incidence > 0 THEN b.active_trials::double precision / (r.incidence / 100000) END AS trials_per_100k_cases, | |
| 243 | + CASE WHEN c.top_level AND r.cancer_id IS NOT NULL THEN ${th.burdenGeography}::text END AS burden_geography, | |
| 244 | + CASE WHEN c.top_level THEN r.year END AS burden_year, | |
| 245 | + CASE WHEN c.top_level THEN r.source_id END AS burden_source_id, | |
| 246 | + CASE WHEN c.top_level THEN r.deaths END AS burden_deaths, | |
| 247 | + CASE WHEN c.top_level THEN r.incidence END AS burden_incidence, | |
| 248 | + CASE WHEN c.top_level THEN r.mortality_obs_id END AS mortality_obs_id, | |
| 249 | + CASE WHEN c.top_level THEN r.incidence_obs_id END AS incidence_obs_id | |
| 250 | + FROM cancers c | |
| 251 | + JOIN base b ON b.cancer_id = c.id | |
| 252 | + LEFT JOIN spa s ON s.cancer_id = c.id | |
| 253 | + LEFT JOIN coa g ON g.cancer_id = c.id | |
| 254 | + LEFT JOIN wsa w ON w.cancer_id = c.id | |
| 255 | + LEFT JOIN bur r ON r.cancer_id = c.id | |
| 256 | + WHERE c.status = 'active'`); | |
| 257 | + | |
| 258 | + const inputsJson = sql`jsonb_strip_nulls(jsonb_build_object( | |
| 259 | + 'asOf', ${asOf}::text, | |
| 260 | + 'windows', ${JSON.stringify({ new12m: windows.new12m, prior12m: windows.prior12m })}::jsonb, | |
| 261 | + 'thresholds', ${JSON.stringify(th)}::jsonb, | |
| 262 | + 'activeStatuses', ${JSON.stringify(TRIAL_INTEL_ACTIVE_STATUSES)}::jsonb, | |
| 263 | + 'stopReasonRulesVersion', ${STOP_REASON_RULES_VERSION}::text, | |
| 264 | + 'aggregation', 'descendants', | |
| 265 | + 'studyType', 'INTERVENTIONAL', | |
| 266 | + 'mappedTrialsAnyType', a.mapped_any_type, | |
| 267 | + 'denominators', jsonb_build_object('sponsor', a.sponsor_denominator, 'countryPairs', a.country_pairs, 'terminal', a.term_completed + a.term_terminated + a.term_withdrawn, 'terminalCompleted', a.term_completed, 'terminalTerminated', a.term_terminated, 'terminalWithdrawn', a.term_withdrawn), | |
| 268 | + 'burden', CASE WHEN a.burden_source_id IS NOT NULL THEN jsonb_build_object('geography', a.burden_geography, 'year', a.burden_year, 'sourceId', a.burden_source_id, 'deaths', a.burden_deaths, 'incidence', a.burden_incidence, 'mortalityObservationId', a.mortality_obs_id, 'incidenceObservationId', a.incidence_obs_id, 'sex', 'all', 'ageGroup', 'all') END | |
| 269 | + ))`; | |
| 270 | + | |
| 271 | + const insertFor = (level: 'top' | 'all') => sql` | |
| 272 | + INSERT INTO trial_intelligence (cancer_id, entity_level, total_trials, active_trials, recruiting_trials, phase1_active, phase2_active, phase3_active, phase3_recruiting, phase4_active, | |
| 273 | + completed_trials, terminated_trials, withdrawn_trials, suspended_trials, with_results, new_trials_12m, new_trials_prior_12m, trial_growth_yoy, | |
| 274 | + avg_enrollment, median_enrollment, total_enrollment_active, distinct_sponsors, industry_share, sponsor_hhi, top_sponsor, top_sponsor_share, | |
| 275 | + distinct_countries, us_share, top_country, top_country_share, country_hhi, termination_share, why_stopped_breakdown, | |
| 276 | + trials_per_1000_deaths, trials_per_100k_cases, burden_geography, burden_year, burden_source_id, formula_version, inputs, updated_at) | |
| 277 | + SELECT a.cancer_id, ${level}, a.total_trials, a.active_trials, a.recruiting_trials, a.phase1_active, a.phase2_active, a.phase3_active, a.phase3_recruiting, a.phase4_active, | |
| 278 | + a.completed_trials, a.terminated_trials, a.withdrawn_trials, a.suspended_trials, a.with_results, a.new_trials_12m, a.new_trials_prior_12m, a.trial_growth_yoy, | |
| 279 | + a.avg_enrollment, a.median_enrollment, a.total_enrollment_active, a.distinct_sponsors, a.industry_share, a.sponsor_hhi, a.top_sponsor, a.top_sponsor_share, | |
| 280 | + a.distinct_countries, a.us_share, a.top_country, a.top_country_share, a.country_hhi, a.termination_share, a.why_stopped_breakdown, | |
| 281 | + a.trials_per_1000_deaths, a.trials_per_100k_cases, a.burden_geography, a.burden_year, a.burden_source_id, ${TRIAL_INTELLIGENCE_FORMULA_VERSION}, ${inputsJson}, now() | |
| 282 | + FROM _ti_agg a | |
| 283 | + ${level === 'top' ? sql`WHERE a.top_level` : sql`WHERE a.malignant AND a.mapped_any_type > 0`}`; | |
| 284 | + | |
| 285 | + await tx.execute(sql`DELETE FROM trial_intelligence`); | |
| 286 | + // Top-level cancers without any mapped trial still get a row (true zeros) so the top-level table is complete. | |
| 287 | + await tx.execute(sql` | |
| 288 | + INSERT INTO _ti_agg (cancer_id, top_level, malignant, mapped_any_type, total_trials, active_trials, recruiting_trials, phase1_active, phase2_active, phase3_active, phase3_recruiting, phase4_active, | |
| 289 | + completed_trials, terminated_trials, withdrawn_trials, suspended_trials, with_results, new_trials_12m, new_trials_prior_12m, distinct_sponsors, sponsor_denominator, distinct_countries, country_pairs, | |
| 290 | + term_completed, term_terminated, term_withdrawn, why_stopped_breakdown) | |
| 291 | + SELECT c.id, true, c.malignant, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, '{}'::jsonb | |
| 292 | + FROM cancers c WHERE c.status = 'active' AND c.top_level AND NOT EXISTS (SELECT 1 FROM _ti_agg a WHERE a.cancer_id = c.id)`); | |
| 293 | + const topRows = await tx.execute<{ n: string }>(sql`WITH ins AS (${insertFor('top')} RETURNING 1) SELECT count(*)::text AS n FROM ins`); | |
| 294 | + const allRows = await tx.execute<{ n: string }>(sql`WITH ins AS (${insertFor('all')} RETURNING 1) SELECT count(*)::text AS n FROM ins`); | |
| 295 | + return { top: Number(topRows[0]?.n ?? 0), all: Number(allRows[0]?.n ?? 0) }; | |
| 296 | + }); | |
| 297 | + | |
| 298 | + const rankings = await rankTrialIntelligence(db); | |
| 299 | + return { rows: top + all, topRows: top, allRows: all, stopReasonsClassified: stopIds.length, rankings, ms: Date.now() - t0 }; | |
| 300 | +} | |
| 301 | + | |
| 302 | +interface IntelRow extends Record<string, unknown> { | |
| 303 | + cancer_id: string; | |
| 304 | + entity_level: 'top' | 'all'; | |
| 305 | + phase3_recruiting: number; | |
| 306 | + active_trials: number; | |
| 307 | + new_trials_12m: number; | |
| 308 | + new_trials_prior_12m: number; | |
| 309 | + trial_growth_yoy: number | null; | |
| 310 | + termination_share: number | null; | |
| 311 | + sponsor_hhi: number | null; | |
| 312 | + distinct_sponsors: number; | |
| 313 | + top_sponsor: string | null; | |
| 314 | + top_sponsor_share: number | null; | |
| 315 | + inputs: Record<string, unknown>; | |
| 7 | 316 | } |
| 8 | 317 | |
| 9 | 318 | /** |
| 10 | − * STUB — implemented by the Clinical Trial Intelligence work package. | |
| 11 | − * Recomputes `trial_intelligence` (per cancer × entity level). | |
| 319 | + * Ranking snapshots for the four trial-intelligence metrics (WORLD, all sexes/ages, latest, per entity | |
| 320 | + * level). Only eligible entities are ranked: non-null value, count metrics > 0. `trial_termination_share` | |
| 321 | + * ranks descending too (rank 1 = highest share; `higher_is_worse` is display information). | |
| 12 | 322 | */ |
| 13 | −export async function computeTrialIntelligence(_db: Database): Promise<TrialIntelligenceResult> { | |
| 14 | − return { rows: 0 }; | |
| 323 | +export async function rankTrialIntelligence(db: Database): Promise<RankingResult[]> { | |
| 324 | + const slugs = ['phase3_recruiting_trials', 'trial_growth_yoy', 'trial_termination_share', 'sponsor_concentration']; | |
| 325 | + const defs = await db.select().from(metricDefinitions); | |
| 326 | + const byslug = new Map(defs.filter((d) => slugs.includes(d.slug)).map((d) => [d.slug, d])); | |
| 327 | + const rows = await db.execute<IntelRow>(sql` | |
| 328 | + SELECT ti.cancer_id, ti.entity_level, ti.phase3_recruiting, ti.active_trials, ti.new_trials_12m, ti.new_trials_prior_12m, ti.trial_growth_yoy, ti.termination_share, ti.sponsor_hhi, | |
| 329 | + ti.distinct_sponsors, ti.top_sponsor, ti.top_sponsor_share, ti.inputs | |
| 330 | + FROM trial_intelligence ti JOIN cancers c ON c.id = ti.cancer_id WHERE c.status = 'active' AND ti.formula_version = ${TRIAL_INTELLIGENCE_FORMULA_VERSION}`); | |
| 331 | + const out: RankingResult[] = []; | |
| 332 | + for (const level of ['top', 'all'] as const) { | |
| 333 | + const scope: Scope = { geography: 'WORLD', sex: 'all', ageGroup: 'all', year: null, entityLevel: level }; | |
| 334 | + const lv = rows.filter((r) => r.entity_level === level); | |
| 335 | + const common = (r: IntelRow) => ({ formulaVersion: TRIAL_INTELLIGENCE_FORMULA_VERSION, asOf: r.inputs.asOf, aggregation: 'descendants', studyType: 'INTERVENTIONAL', activeStatuses: r.inputs.activeStatuses }); | |
| 336 | + const metrics: Array<{ slug: string; items: RankInput[] }> = [ | |
| 337 | + { | |
| 338 | + slug: 'phase3_recruiting_trials', | |
| 339 | + items: lv.filter((r) => Number(r.phase3_recruiting) > 0).map((r) => ({ id: r.cancer_id, value: Number(r.phase3_recruiting), confidence: 'HIGH' as const, inputs: { ...common(r), phase3Recruiting: Number(r.phase3_recruiting), activeTrials: Number(r.active_trials) } })), | |
| 340 | + }, | |
| 341 | + { | |
| 342 | + slug: 'trial_growth_yoy', | |
| 343 | + items: lv | |
| 344 | + .filter((r) => r.trial_growth_yoy != null && Number.isFinite(Number(r.trial_growth_yoy))) | |
| 345 | + .map((r) => ({ id: r.cancer_id, value: Number(r.trial_growth_yoy), confidence: (Number(r.new_trials_prior_12m) >= 100 ? 'HIGH' : 'MEDIUM') as 'HIGH' | 'MEDIUM', inputs: { ...common(r), newTrials12m: Number(r.new_trials_12m), newTrialsPrior12m: Number(r.new_trials_prior_12m), windows: r.inputs.windows, minPriorTrials: TRIAL_INTEL_THRESHOLDS.growthMinPriorTrials } })), | |
| 346 | + }, | |
| 347 | + { | |
| 348 | + slug: 'trial_termination_share', | |
| 349 | + items: lv | |
| 350 | + .filter((r) => r.termination_share != null && Number.isFinite(Number(r.termination_share))) | |
| 351 | + .map((r) => { | |
| 352 | + const d = (r.inputs.denominators ?? {}) as Record<string, number>; | |
| 353 | + return { id: r.cancer_id, value: Number(r.termination_share), confidence: ((d.terminal ?? 0) >= 100 ? 'HIGH' : 'MEDIUM') as 'HIGH' | 'MEDIUM', inputs: { ...common(r), completed: d.terminalCompleted, terminated: d.terminalTerminated, withdrawn: d.terminalWithdrawn, terminal: d.terminal, since: TRIAL_INTEL_THRESHOLDS.terminationSince, minTerminalTrials: TRIAL_INTEL_THRESHOLDS.terminationMinTerminalTrials } }; | |
| 354 | + }), | |
| 355 | + }, | |
| 356 | + { | |
| 357 | + slug: 'sponsor_concentration', | |
| 358 | + items: lv | |
| 359 | + .filter((r) => r.sponsor_hhi != null && Number.isFinite(Number(r.sponsor_hhi))) | |
| 360 | + .map((r) => { | |
| 361 | + const d = (r.inputs.denominators ?? {}) as Record<string, number>; | |
| 362 | + return { id: r.cancer_id, value: Number(r.sponsor_hhi), confidence: (Number(r.active_trials) >= 50 ? 'HIGH' : 'MEDIUM') as 'HIGH' | 'MEDIUM', inputs: { ...common(r), activeTrials: Number(r.active_trials), activeWithSponsor: d.sponsor, distinctSponsors: Number(r.distinct_sponsors), topSponsor: r.top_sponsor, topSponsorShare: r.top_sponsor_share == null ? null : Number(r.top_sponsor_share), minActiveTrials: TRIAL_INTEL_THRESHOLDS.hhiMinActiveTrials } }; | |
| 363 | + }), | |
| 364 | + }, | |
| 365 | + ]; | |
| 366 | + for (const m of metrics) { | |
| 367 | + const def = byslug.get(m.slug); | |
| 368 | + if (!def || m.items.length < 3) continue; | |
| 369 | + out.push(await persistSnapshot(db, def, scope, m.items, { descending: true, sourceIds: def.sourceSlugs.length ? def.sourceSlugs : ['clinicaltrials'] })); | |
| 370 | + } | |
| 371 | + } | |
| 372 | + return out; | |
| 15 | 373 | } |
added
packages/ranking/src/trial-stop-reasons.test.ts
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { STOP_REASON_CATEGORIES, STOP_REASON_RULES, classifyStopReason, isStopReasonCategory, stopReasonBreakdown } from './trial-stop-reasons.js'; | |
| 3 | + | |
| 4 | +describe('classifyStopReason', () => { | |
| 5 | + it('returns not_stated for null, undefined and blank text', () => { | |
| 6 | + for (const v of [null, undefined, '', ' ', '\n']) { | |
| 7 | + const r = classifyStopReason(v); | |
| 8 | + expect(r.category).toBe('not_stated'); | |
| 9 | + expect(r.matched).toEqual([]); | |
| 10 | + } | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it('returns other_stated when text is present but no rule matches (never infers)', () => { | |
| 14 | + const r = classifyStopReason('Sponsor is focusing on studies which can enable registration of duvelisib'); | |
| 15 | + expect(r.category).toBe('other_stated'); | |
| 16 | + expect(r.matched).toEqual([]); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it.each([ | |
| 20 | + ['slow accrual', 'enrollment'], | |
| 21 | + ['Poor recruitment', 'enrollment'], | |
| 22 | + ['low enrollment', 'enrollment'], | |
| 23 | + ['Low enrolment (UK spelling)', 'enrollment'], | |
| 24 | + ['No funding', 'funding'], | |
| 25 | + ['Costs for antibody production rose; supplemental funding request not approved.', 'funding'], | |
| 26 | + ['Financial constraints', 'funding'], | |
| 27 | + ['Budget cuts', 'funding'], | |
| 28 | + ['Business decision', 'sponsor_decision'], | |
| 29 | + ["Sponsor's decision", 'sponsor_decision'], | |
| 30 | + ['Decision of the sponsor', 'sponsor_decision'], | |
| 31 | + ['Change in development strategy', 'sponsor_decision'], | |
| 32 | + ['Portfolio prioritization', 'sponsor_decision'], | |
| 33 | + ['Company reorganisation', 'sponsor_decision'], | |
| 34 | + ['Terminated due to unacceptable toxicity', 'safety'], | |
| 35 | + ['Safety concerns', 'safety'], | |
| 36 | + ['Serious adverse events', 'safety'], | |
| 37 | + ['Lack of efficacy', 'efficacy'], | |
| 38 | + ['Stopped for futility', 'efficacy'], | |
| 39 | + ['Lack of clinical benefit', 'efficacy'], | |
| 40 | + ['Stopped after interim analysis', 'efficacy'], | |
| 41 | + ['Drug supply issues', 'drug_supply'], | |
| 42 | + ['Drug availability', 'drug_supply'], | |
| 43 | + ['Drug manufacturing process and procedure review', 'drug_supply'], | |
| 44 | + ['PI left the institution', 'investigator'], | |
| 45 | + ['Investigator relocated', 'investigator'], | |
| 46 | + ['COVID-19 pandemic', 'covid'], | |
| 47 | + ['Site closed during the pandemic', 'covid'], | |
| 48 | + ])('classifies %j as %s', (text, expected) => { | |
| 49 | + expect(classifyStopReason(text).category).toBe(expected); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it('is case-insensitive', () => { | |
| 53 | + expect(classifyStopReason('SLOW ACCRUAL').category).toBe('enrollment'); | |
| 54 | + expect(classifyStopReason('covid').category).toBe('covid'); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('uses word boundaries so unrelated words do not match', () => { | |
| 58 | + // "refund" must not match funding; "companion" must not match company; "recruit" inside "unrecruitable" must not match. | |
| 59 | + expect(classifyStopReason('refund issued to participants').category).toBe('other_stated'); | |
| 60 | + expect(classifyStopReason('companion diagnostic unavailable').category).toBe('other_stated'); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it('applies precedence and reports every matched category', () => { | |
| 64 | + const r = classifyStopReason('slow enrollment and lack of funding'); | |
| 65 | + expect(r.category).toBe('enrollment'); | |
| 66 | + expect(r.matched).toEqual(['enrollment', 'funding']); | |
| 67 | + const s = classifyStopReason('Enrollment halted for safety reasons'); | |
| 68 | + expect(s.category).toBe('safety'); | |
| 69 | + expect(s.matched).toEqual(['safety', 'enrollment']); | |
| 70 | + const t = classifyStopReason('Business decision: slow accrual'); | |
| 71 | + expect(t.category).toBe('enrollment'); | |
| 72 | + expect(t.matched).toEqual(['enrollment', 'sponsor_decision']); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it('is deterministic and carries the rules version', () => { | |
| 76 | + const a = classifyStopReason('slow accrual'); | |
| 77 | + const b = classifyStopReason('slow accrual'); | |
| 78 | + expect(a).toEqual(b); | |
| 79 | + expect(a.rulesVersion).toBe('ci-stop-reasons-v1'); | |
| 80 | + }); | |
| 81 | +}); | |
| 82 | + | |
| 83 | +describe('rules table', () => { | |
| 84 | + it('lists every ruled category exactly once, in precedence order, with the broadest last', () => { | |
| 85 | + const cats = STOP_REASON_RULES.map((r) => r.category); | |
| 86 | + expect(new Set(cats).size).toBe(cats.length); | |
| 87 | + expect(cats[cats.length - 1]).toBe('sponsor_decision'); | |
| 88 | + expect(STOP_REASON_CATEGORIES.slice(0, cats.length)).toEqual(cats); | |
| 89 | + }); | |
| 90 | + it('documents at least one keyword per pattern group', () => { | |
| 91 | + for (const r of STOP_REASON_RULES) { | |
| 92 | + expect(r.keywords.length).toBeGreaterThan(0); | |
| 93 | + expect(r.patterns.length).toBeGreaterThan(0); | |
| 94 | + } | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe('stopReasonBreakdown', () => { | |
| 99 | + it('counts every category with zeros and sums to the input size', () => { | |
| 100 | + const b = stopReasonBreakdown(['slow accrual', null, '', 'No funding', 'unknown reason', 'toxicity']); | |
| 101 | + expect(Object.keys(b)).toEqual([...STOP_REASON_CATEGORIES]); | |
| 102 | + expect(b.enrollment).toBe(1); | |
| 103 | + expect(b.not_stated).toBe(2); | |
| 104 | + expect(b.funding).toBe(1); | |
| 105 | + expect(b.other_stated).toBe(1); | |
| 106 | + expect(b.safety).toBe(1); | |
| 107 | + expect(Object.values(b).reduce((s, n) => s + n, 0)).toBe(6); | |
| 108 | + }); | |
| 109 | + it('is empty-safe', () => { | |
| 110 | + const b = stopReasonBreakdown([]); | |
| 111 | + expect(Object.values(b).every((n) => n === 0)).toBe(true); | |
| 112 | + }); | |
| 113 | +}); | |
| 114 | + | |
| 115 | +describe('isStopReasonCategory', () => { | |
| 116 | + it('accepts known categories and rejects others', () => { | |
| 117 | + expect(isStopReasonCategory('enrollment')).toBe(true); | |
| 118 | + expect(isStopReasonCategory('not_stated')).toBe(true); | |
| 119 | + expect(isStopReasonCategory('bogus')).toBe(false); | |
| 120 | + }); | |
| 121 | +}); | |
added
packages/ranking/src/trial-stop-reasons.ts
+68 −0
@@ -0,0 +1,68 @@ | ||
| 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 / SUSPENDED | |
| 5 | + * studies. This module maps that text to a small vocabulary using ONLY explicit keyword rules so the | |
| 6 | + * categorisation is reproducible and auditable (docs/methodology/trial-intelligence.md lists every | |
| 7 | + * keyword). Nothing is inferred: text that matches no rule is `other_stated`, an absent text is | |
| 8 | + * `not_stated`. The classifier is pure and versioned (`STOP_REASON_RULES_VERSION`). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export const STOP_REASON_RULES_VERSION = 'ci-stop-reasons-v1'; | |
| 12 | + | |
| 13 | +export const STOP_REASON_CATEGORIES = ['covid', 'safety', 'efficacy', 'drug_supply', 'investigator', 'enrollment', 'funding', 'sponsor_decision', 'other_stated', 'not_stated'] as const; | |
| 14 | +export type StopReasonCategory = (typeof STOP_REASON_CATEGORIES)[number]; | |
| 15 | + | |
| 16 | +/** Categories that come from a keyword rule (excludes the two fall-backs). */ | |
| 17 | +export type RuledCategory = Exclude<StopReasonCategory, 'other_stated' | 'not_stated'>; | |
| 18 | + | |
| 19 | +export interface StopReasonRule { | |
| 20 | + category: RuledCategory; | |
| 21 | + /** Human-readable keyword list (documentation / API). */ | |
| 22 | + keywords: string[]; | |
| 23 | + patterns: RegExp[]; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** | |
| 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 over | |
| 29 | + * 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 | + */ | |
| 32 | +export 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 | +]; | |
| 42 | + | |
| 43 | +export 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 | +} | |
| 49 | + | |
| 50 | +/** Classify one `why_stopped` text. Pure; never infers a reason from anything but the text. */ | |
| 51 | +export 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 | +} | |
| 58 | + | |
| 59 | +/** Count classifications per category (all categories present, zeros included, stable key order). */ | |
| 60 | +export 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 | +} | |
| 65 | + | |
| 66 | +export function isStopReasonCategory(v: string): v is StopReasonCategory { | |
| 67 | + return (STOP_REASON_CATEGORIES as readonly string[]).includes(v); | |
| 68 | +} | |
| 69 | ||