SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%

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>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 9b09828

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