SPB Git

spb/cancerindex

Public

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 (Sep 11, 2026) parent 9b09828 Browse files

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 import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; 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 51+ * Clinical-trial intelligence (SPEC §10): per-cancer derived metrics with formula version and inputs,
5− * with formula version and inputs), `GET /trials/sites` (country / city aggregates for the map). 52+ * and registrant-reported stop reasons classified by explicit keyword rules (never inferred).
6− * Filled by the Trial Intelligence and Trial Map work packages. 53+ * `GET /trials/sites` (trial map) is added by the Trial Map work package.
7 */ 54 */
8−export const intelligenceRoutes: FastifyPluginAsyncZod = async (_app) => { 55+export const intelligenceRoutes: FastifyPluginAsyncZod = async (app) => {
9− /* routes added by the trial-intelligence / trial-map work packages */ 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 import { Freshness } from '@/components/ui/freshness'; 4 import { Freshness } from '@/components/ui/freshness';
5 import { Pager } from '@/components/ui/pager'; 5 import { Pager } from '@/components/ui/pager';
6 import { TrialTable } from '@/components/data/trial-list'; 6 import { TrialTable } from '@/components/data/trial-list';
7+import { ClaimBadge } from '@/components/ui/badge';
8+import { SourceBadge } from '@/components/ui/source-badge';
7 import { listTrialRows, trialFacets, TRIAL_PAGE_SIZE } from '@/lib/queries/trials'; 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 import { pageInfo } from '@/lib/pagination'; 13 import { pageInfo } from '@/lib/pagination';
10 import { withParams } from '@/lib/search-params'; 14 import { withParams } from '@/lib/search-params';
11 import type { CancerBundle } from '../load'; 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 export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; status: string; phase: string; page: number }) { 62 export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; status: string; phase: string; page: number }) {
14− const facets = await trialFacets(b.descendants); 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)]);
15− const { rows, total } = await listTrialRows({ q: '', status, phase, country: '', cancerIds: b.descendants, page, pageSize: TRIAL_PAGE_SIZE });
16 const info = pageInfo(page, TRIAL_PAGE_SIZE, total); 64 const info = pageInfo(page, TRIAL_PAGE_SIZE, total);
17 const base = `/cancer/${b.cancer.slug}/trials`; 65 const base = `/cancer/${b.cancer.slug}/trials`;
18 const href = (o: Record<string, string | number | null | undefined>) => `${base}${withParams({ status, phase }, o)}`; 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 return ( 81 return (
34 <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.`}> 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 <div className="mb-3 flex flex-wrap gap-4 text-[12.5px]"> 84 <div className="mb-3 flex flex-wrap gap-4 text-[12.5px]">
36 <nav aria-label="Filter by status" className="flex flex-wrap items-center gap-1.5"> 85 <nav aria-label="Filter by status" className="flex flex-wrap items-center gap-1.5">
37 <span className="ci-kicker mr-1">Status</span> 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 export const TRIAL_INTELLIGENCE_FORMULA_VERSION = 'ci-trial-intel-v1'; 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 export interface TrialIntelligenceResult { 35 export interface TrialIntelligenceResult {
6 rows: number; 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. 319+ * Ranking snapshots for the four trial-intelligence metrics (WORLD, all sexes/ages, latest, per entity
11− * Recomputes `trial_intelligence` (per cancer × entity level). 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> { 323+export async function rankTrialIntelligence(db: Database): Promise<RankingResult[]> {
14− return { rows: 0 }; 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
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/trial-stop-reasons.ts +68 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme