SPB Git

spb/cancerindex

Public

Research Gap Index: components table, share/log2-ratio metrics, per-1000-deaths snapshots, /research-gap page with log-log scatter, API, CSV export, methodology

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 5379f0a Browse files

11 changed files +1,917 −78

modified apps/api/src/routes/research-gap.ts +129 −4
@@ -1,10 +1,135 @@
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 { RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '@cancerindex/ranking';
5+import { NotFound } from '../lib/errors.js';
6+import { AnyRecord, AnyList, camelRows, num, ok, respond } from '../lib/respond.js';
7+
8+const scopeQuery = z.object({
9+ geography: z.string().default('USA').describe('ISO3 of the burden scope (WORLD once licensed global estimates exist)'),
10+ year: z.coerce.number().int().optional().describe('Burden year; omitted = latest year available for the geography and sex'),
11+ sex: z.enum(['all', 'male', 'female']).default('all'),
12+ source: z.string().optional().describe('Burden source (slug or CI-SOURCE id); omitted = the source with the most component rows in the scope'),
13+});
14+
15+type ScopeRow = {
16+ geography: string;
17+ year: number;
18+ sex: string;
19+ burden_source_id: string;
20+ source_slug: string;
21+ source_name: string;
22+ n_total: number;
23+ n_eligible: number;
24+ formula_version: string;
25+ computed_at: Date | string;
26+};
27+
28+const SCOPES_SQL = sql`
29+ SELECT r.geography, r.year, r.sex, r.burden_source_id, s.slug AS source_slug, s.name AS source_name,
30+ count(*)::int AS n_total, count(*) FILTER (WHERE r.eligible)::int AS n_eligible,
31+ max(r.formula_version) AS formula_version, max(r.updated_at) AS computed_at
32+ FROM research_gap_components r JOIN sources s ON s.id = r.burden_source_id
33+ GROUP BY r.geography, r.year, r.sex, r.burden_source_id, s.slug, s.name`;
2 34
3 /** 35 /**
4 * Research Gap routes (SPEC §34, §113): `GET /research-gap` → components (deaths, trials, 36 * Research Gap routes (SPEC §34, §113): `GET /research-gap` → components (deaths, trials,
5− * publications, shares, log-ratios) per cancer for one burden scope; `GET /research-gap/scopes`. 37+ * publications, shares, log-ratios, per-1,000 values) per top-level cancer for one burden scope;
6− * Filled by the Research Gap work package. 38+ * `GET /research-gap/scopes` → the scopes with computed components. Everything here is a
39+ * computed metric (formula version on every row); it is a signal about burden vs registered
40+ * research activity, not a judgement of research quality or funding.
7 */ 41 */
8−export const researchGapRoutes: FastifyPluginAsyncZod = async (_app) => { 42+export const researchGapRoutes: FastifyPluginAsyncZod = async (app) => {
9− /* routes added by the research-gap work package */ 43+ app.get('/research-gap/scopes', { schema: { tags: ['intelligence'], summary: 'Burden scopes (geography, year, sex, source) with research-gap components and their eligible counts', response: ok(AnyList) } }, async () => {
44+ const rows = await app.db.execute<ScopeRow>(sql`${SCOPES_SQL} ORDER BY r.geography, r.year DESC, r.sex, s.slug`);
45+ const data = camelRows(rows).map((r) => ({ ...r, scopeKey: `geo=${r.geography as string}|sex=${r.sex as string}|age=all|year=${r.year as number}|level=top` }));
46+ return respond(app, data, rows.map((r) => r.burden_source_id));
47+ });
48+
49+ app.get('/research-gap', { schema: { tags: ['intelligence'], summary: 'Research-gap components for one burden scope: deaths, active trials, publications (5 y), shares over the eligible set, log2 gap ratios and per-1,000-deaths intensities per top-level cancer', querystring: scopeQuery, response: ok(AnyRecord) } }, async (req) => {
50+ const q = req.query;
51+ const geography = q.geography.toUpperCase();
52+ const scopes = await app.db.execute<ScopeRow>(sql`${SCOPES_SQL} HAVING upper(r.geography) = ${geography} AND r.sex = ${q.sex}
53+ ${q.year !== undefined ? sql`AND r.year = ${q.year}` : sql``}
54+ ${q.source ? sql`AND (s.slug = ${q.source} OR r.burden_source_id = ${q.source})` : sql``}
55+ ORDER BY r.year DESC, count(*) DESC, s.slug LIMIT 1`);
56+ const scope = scopes[0];
57+ if (!scope) {
58+ const any = await app.db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM research_gap_components`);
59+ if (num(any[0]?.n) === 0) return respond(app, { scope: null, rows: [], status: 'not_available', message: 'Research-gap components have not been computed yet (run `pnpm cix intel`).' }, []);
60+ throw new NotFound('research-gap scope', `geography=${geography} sex=${q.sex} year=${q.year ?? 'latest'} source=${q.source ?? 'any'}`);
61+ }
62+ const scopeKey = `geo=${scope.geography}|sex=${scope.sex}|age=all|year=${scope.year}|level=top`;
63+ const rows = await app.db.execute<Record<string, unknown>>(sql`
64+ SELECT r.id AS component_id, r.cancer_id, c.slug, c.canonical_name AS name, c.short_name, c.hematologic,
65+ r.deaths, r.incidence, r.active_trials, r.phase3_trials, r.publications_5y, r.approved_drugs,
66+ r.death_share, r.trial_share, r.publication_share, r.trial_gap_ratio, r.research_gap_ratio,
67+ r.trials_per_1000_deaths, r.publications_per_1000_deaths, r.eligible, r.ineligible_reason,
68+ r.formula_version, r.inputs, r.updated_at AS computed_at,
69+ tg.rank AS trial_gap_rank, rg.rank AS research_gap_rank
70+ FROM research_gap_components r JOIN cancers c ON c.id = r.cancer_id
71+ LEFT JOIN rankings tg ON tg.cancer_id = r.cancer_id AND tg.metric_slug = 'trial_gap_ratio' AND tg.scope_key = ${scopeKey}
72+ AND tg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'trial_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)
73+ LEFT JOIN rankings rg ON rg.cancer_id = r.cancer_id AND rg.metric_slug = 'research_gap_ratio' AND rg.scope_key = ${scopeKey}
74+ AND rg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'research_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)
75+ WHERE r.geography = ${scope.geography} AND r.year = ${scope.year} AND r.sex = ${scope.sex} AND r.burden_source_id = ${scope.burden_source_id}
76+ ORDER BY r.eligible DESC, r.research_gap_ratio DESC NULLS LAST, c.canonical_name`);
77+ const eligible = rows.filter((r) => r.eligible === true);
78+ const sums = {
79+ deaths: eligible.reduce((s, r) => s + num(r.deaths), 0),
80+ activeTrials: eligible.reduce((s, r) => s + num(r.active_trials), 0),
81+ publications5y: eligible.reduce((s, r) => s + num(r.publications_5y), 0),
82+ eligible: eligible.length,
83+ };
84+ const metrics = await app.db.execute<{ slug: string; name: string; formula: string; formula_version: string; unit: string }>(sql`
85+ SELECT slug, name, formula, formula_version, unit FROM metric_definitions WHERE slug IN ('trial_gap_ratio','research_gap_ratio','trials_per_1000_deaths','publications_per_1000_deaths') ORDER BY slug`);
86+ const data = {
87+ scope: {
88+ geography: scope.geography,
89+ year: Number(scope.year),
90+ sex: scope.sex,
91+ ageGroup: 'all',
92+ entityLevel: 'top',
93+ scopeKey,
94+ burdenSource: { id: scope.burden_source_id, slug: scope.source_slug, name: scope.source_name },
95+ activitySources: { activeTrials: 'clinicaltrials', publications5y: 'pubmed' },
96+ formulaVersion: scope.formula_version ?? RESEARCH_GAP_FORMULA_VERSION,
97+ computedAt: scope.computed_at,
98+ totalEntities: Number(scope.n_total),
99+ eligibleEntities: Number(scope.n_eligible),
100+ sums,
101+ },
102+ thresholds: { minDeaths: RESEARCH_GAP_THRESHOLDS.minDeaths, minActivity: RESEARCH_GAP_THRESHOLDS.minActivity, highConfidenceDeaths: RESEARCH_GAP_THRESHOLDS.highConfidenceDeaths },
103+ metrics: camelRows(metrics).map((m) => ({ ...m, ranking: `/v1/rankings?metric=${m.slug as string}&geography=${scope.geography}&sex=${scope.sex}&year=${scope.year}&level=top` })),
104+ claim: 'computed_metric',
105+ notes: [
106+ 'Shares are computed over the eligible top-level cancers of the scope (deaths ≥ minDeaths); a log2 ratio of +1 means the cancer has twice the share of deaths that its share of trials (or publications) would suggest, −1 half.',
107+ 'Trial counts aggregate a cancer and its NCIt descendants; literature counts are query-based per entity and not aggregated over descendants. Burden depends on the epidemiology source of the scope. A gap is a quantitative signal, not an accusation.',
108+ ],
109+ rows: rows.map((r) => ({
110+ componentId: r.component_id,
111+ cancer: { id: r.cancer_id, slug: r.slug, name: r.name, shortName: r.short_name, hematologic: r.hematologic },
112+ deaths: r.deaths,
113+ incidence: r.incidence,
114+ activeTrials: r.active_trials,
115+ phase3Trials: r.phase3_trials,
116+ publications5y: r.publications_5y,
117+ approvedDrugs: r.approved_drugs,
118+ deathShare: r.death_share,
119+ trialShare: r.trial_share,
120+ publicationShare: r.publication_share,
121+ trialGapRatio: r.trial_gap_ratio,
122+ researchGapRatio: r.research_gap_ratio,
123+ trialsPer1000Deaths: r.trials_per_1000_deaths,
124+ publicationsPer1000Deaths: r.publications_per_1000_deaths,
125+ ranks: { trialGapRatio: r.trial_gap_rank, researchGapRatio: r.research_gap_rank },
126+ eligible: r.eligible,
127+ ineligibleReason: r.ineligible_reason,
128+ formulaVersion: r.formula_version,
129+ inputs: r.inputs,
130+ computedAt: r.computed_at,
131+ })),
132+ };
133+ return respond(app, data, [scope.burden_source_id, 'clinicaltrials', 'pubmed']);
134+ });
10 }; 135 };
added apps/web/src/app/api/export/research-gap.csv/route.ts +49 −0
@@ -0,0 +1,49 @@
1+import { listGapScopes, pickGapScope, gapComponents, gapSums, gapScopeKey } from '@/lib/queries/research-gap';
2+import { SITE_URL } from '@/lib/site';
3+import { isoDate, toDate } from '@/lib/format';
4+
5+export const dynamic = 'force-dynamic';
6+
7+function csvCell(v: unknown): string {
8+ const s = v == null ? '' : String(v);
9+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
10+}
11+
12+/**
13+ * GET /api/export/research-gap.csv?geography=USA&year=2024&sex=all&source=cdc-wonder
14+ * Research-gap components for one burden scope as CSV. Attribution header rows precede the data
15+ * (same convention as /api/export/rankings.csv): CancerIndex-derived values, with the burden source
16+ * and the activity sources named so the file can be cited correctly.
17+ */
18+export async function GET(req: Request) {
19+ const url = new URL(req.url);
20+ const geography = (url.searchParams.get('geography') ?? 'USA').toUpperCase();
21+ const yearRaw = url.searchParams.get('year');
22+ const year = yearRaw && /^\d{4}$/.test(yearRaw) ? Number(yearRaw) : null;
23+ const sexRaw = url.searchParams.get('sex') ?? 'all';
24+ const sex = ['all', 'male', 'female'].includes(sexRaw) ? sexRaw : 'all';
25+ const source = url.searchParams.get('source') ?? '';
26+ if (!/^[A-Z0-9_-]{2,16}$/.test(geography) || !/^[a-z0-9-]{0,64}$/.test(source)) return new Response('invalid scope', { status: 400 });
27+ const scopes = await listGapScopes();
28+ const scope = pickGapScope(scopes, { geography, year, sex, source });
29+ if (!scope) return new Response('no research-gap components for this scope', { status: 404 });
30+ const rows = await gapComponents(scope);
31+ const sums = gapSums(rows);
32+
33+ const header = [
34+ `# CancerIndex Research Gap Index export — ${scope.geography} ${scope.year} · ${scope.sex === 'all' ? 'both sexes' : scope.sex} · all ages · top-level cancers`,
35+ `# Source: CancerIndex (${SITE_URL}) — derived data, CC BY 4.0. Underlying observations remain under their providers' licenses: deaths from ${scope.source_name} (${scope.source_slug}); active interventional trials from ClinicalTrials.gov (cancer + NCIt descendants); publications (last 5 years) from PubMed (query-based per entity).`,
36+ `# Formulas: death_share = deaths / Σ deaths; trial_share = active_trials / Σ active_trials; publication_share = publications_5y / Σ publications_5y (sums over eligible cancers, deaths ≥ 100); trial_gap_ratio = log2(death_share / trial_share); research_gap_ratio = log2(death_share / publication_share); *_per_1000_deaths = activity / (deaths / 1000).`,
37+ `# formula_version: ${scope.formula_version} · scope_key: ${gapScopeKey(scope)} · eligible_entities: ${sums.eligible} of ${rows.length} · sum_deaths: ${sums.deaths} · sum_active_trials: ${sums.activeTrials} · sum_publications_5y: ${sums.publications5y} · computed_at: ${toDate(scope.computed_at)?.toISOString() ?? String(scope.computed_at)}`,
38+ `# A gap ratio is a quantitative signal about registered activity relative to deaths — not a judgement of research quality or funding. Ratios are relative to the eligible set of this scope only; compare within one file. United States burden only while global estimates are under license review.`,
39+ ];
40+ const cols = ['cancer_id', 'slug', 'canonical_name', 'geography', 'year', 'sex', 'burden_source', 'deaths', 'incidence', 'active_trials', 'phase3_trials', 'publications_5y', 'approved_drugs', 'death_share', 'trial_share', 'publication_share', 'trial_gap_ratio', 'research_gap_ratio', 'trials_per_1000_deaths', 'publications_per_1000_deaths', 'trial_gap_rank', 'research_gap_rank', 'eligible', 'ineligible_reason', 'formula_version', 'inputs_json'];
41+ const lines = rows.map((r) =>
42+ [r.cancer_id, r.slug, r.canonical_name, scope.geography, scope.year, scope.sex, scope.source_slug, r.deaths, r.incidence ?? '', r.active_trials, r.phase3_trials, r.publications_5y, r.approved_drugs, r.death_share ?? '', r.trial_share ?? '', r.publication_share ?? '', r.trial_gap_ratio ?? '', r.research_gap_ratio ?? '', r.trials_per_1000_deaths ?? '', r.publications_per_1000_deaths ?? '', r.trial_gap_rank ?? '', r.research_gap_rank ?? '', r.eligible, r.ineligible_reason ?? '', r.formula_version, JSON.stringify(r.inputs)]
43+ .map(csvCell)
44+ .join(','),
45+ );
46+ const body = [...header, cols.join(','), ...lines].join('\n') + '\n';
47+ const fname = `cancerindex-research-gap-${scope.geography}-${scope.year}-${scope.sex}-${scope.source_slug}-${isoDate(scope.computed_at)}.csv`;
48+ return new Response(body, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fname}"`, 'Cache-Control': 'public, max-age=300' } });
49+}
added apps/web/src/app/research-gap/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 the Research Gap Index" />;
5+}
added apps/web/src/app/research-gap/page.tsx +446 −0
@@ -0,0 +1,446 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { Download } from 'lucide-react';
4+import { PageHeader, Section, Note } from '@/components/ui/section';
5+import { Badge, ClaimBadge } from '@/components/ui/badge';
6+import { EmptyState } from '@/components/ui/empty-state';
7+import { Freshness } from '@/components/ui/freshness';
8+import { SourceBadge } from '@/components/ui/source-badge';
9+import { JsonView } from '@/components/ui/json-view';
10+import { ScatterChart, type ScatterPoint } from '@/components/charts/scatter-chart';
11+import { listGapScopes, pickGapScope, gapComponents, gapSums, gapScopeKey, gapMetricDefs, type GapComponent } from '@/lib/queries/research-gap';
12+import { fmtInt, fmtNum, fmtPct, fmtValue, fmtDateTime, humanize } from '@/lib/format';
13+import { str, int, oneOf, withParams, type SP } from '@/lib/search-params';
14+
15+export const dynamic = 'force-dynamic';
16+
17+export const metadata: Metadata = {
18+ title: 'Research Gap Index — burden vs research activity',
19+ description: 'Deaths, active trials and publications per top-level cancer in one burden scope; shares, log2 gap ratios and per-1,000-deaths intensities with their formula versions and inputs.',
20+};
21+
22+const SORT_KEYS = ['canonical_name', 'deaths', 'death_share', 'active_trials', 'trial_share', 'publications_5y', 'publication_share', 'trial_gap_ratio', 'research_gap_ratio', 'trials_per_1000_deaths', 'publications_per_1000_deaths'] as const;
23+type SortKey = (typeof SORT_KEYS)[number];
24+
25+function sortRows(rows: GapComponent[], key: SortKey, dir: 'asc' | 'desc'): GapComponent[] {
26+ const sgn = dir === 'asc' ? 1 : -1;
27+ return [...rows].sort((a, b) => {
28+ // Ineligible rows always sink to the bottom.
29+ if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
30+ if (key === 'canonical_name') return sgn * a.canonical_name.localeCompare(b.canonical_name);
31+ const av = a[key];
32+ const bv = b[key];
33+ if (av == null && bv == null) return a.canonical_name.localeCompare(b.canonical_name);
34+ if (av == null) return 1;
35+ if (bv == null) return -1;
36+ return sgn * (Number(av) - Number(bv)) || a.canonical_name.localeCompare(b.canonical_name);
37+ });
38+}
39+
40+/** Short display label for chart points: the entity's short name, else the canonical name without the "Malignant … Neoplasm" wrapper. */
41+function chartLabel(r: { canonical_name: string; short_name: string | null }): string {
42+ if (r.short_name) return r.short_name;
43+ return r.canonical_name.replace(/^Malignant\s+/i, '').replace(/\s+Neoplasm$/i, '');
44+}
45+
46+function Ratio({ v }: { v: number | null }) {
47+ if (v == null) return <span className="text-ink-4">—</span>;
48+ const cls = v >= 1 ? 'text-danger font-medium' : v > 0 ? 'text-ink' : 'text-ink-3';
49+ return <span className={cls}>{fmtValue(v, 'log2_ratio')}</span>;
50+}
51+
52+export default async function ResearchGapPage({ searchParams }: { searchParams: Promise<SP> }) {
53+ const sp = await searchParams;
54+ const scopes = await listGapScopes();
55+ const want = { geography: str(sp, 'geography', 'USA'), year: int(sp, 'year', 0, 1900, 2100) || null, sex: oneOf(sp, 'sex', ['all', 'male', 'female'] as const, 'all'), source: str(sp, 'source') };
56+ const scope = pickGapScope(scopes, want);
57+ const sort = oneOf(sp, 'sort', SORT_KEYS, 'research_gap_ratio');
58+ const dir = oneOf(sp, 'dir', ['asc', 'desc'] as const, sort === 'canonical_name' ? 'asc' : 'desc');
59+ const [rowsRaw, defs] = scope ? await Promise.all([gapComponents(scope), gapMetricDefs()]) : [[] as GapComponent[], new Map()];
60+ const rows = sortRows(rowsRaw, sort, dir);
61+ const eligible = rows.filter((r) => r.eligible);
62+ const sums = gapSums(rows);
63+ const current = { geography: scope?.geography ?? want.geography, year: scope?.year ?? null, sex: scope?.sex ?? want.sex, source: scope?.source_slug ?? null, sort, dir };
64+ const sortLink = (key: SortKey) => withParams(current, { sort: key, dir: sort === key ? (dir === 'desc' ? 'asc' : 'desc') : key === 'canonical_name' ? 'asc' : 'desc' });
65+ const sortMark = (key: SortKey) => (sort === key ? (dir === 'desc' ? ' ▼' : ' ▲') : '');
66+ const geographies = [...new Set(scopes.map((s) => s.geography))];
67+ const yearsFor = (geo: string, sex: string) => [...new Set(scopes.filter((s) => s.geography === geo && s.sex === sex).map((s) => Number(s.year)))].sort((a, b) => b - a);
68+ const sexesFor = (geo: string) => [...new Set(scopes.filter((s) => s.geography === geo).map((s) => s.sex))];
69+ const sourcesFor = (geo: string, year: number | null, sex: string) => scopes.filter((s) => s.geography === geo && Number(s.year) === year && s.sex === sex);
70+ const pubSlope = sums.deaths > 0 ? sums.publications5y / sums.deaths : 0;
71+ const trialSlope = sums.deaths > 0 ? sums.activeTrials / sums.deaths : 0;
72+ const cancerHref = (r: GapComponent) => `/cancer/${r.slug}/rankings`;
73+ const sourceBadge = scope ? <SourceBadge p={{ sourceSlug: scope.source_slug, sourceName: scope.source_name, dataset: `mortality_count · ${scope.geography} · ${scope.year} · ${scope.sex}`, layer: 'normalized' }} compact /> : null;
74+ const pubPoints: ScatterPoint[] = eligible.map((r) => ({
75+ id: r.cancer_id,
76+ label: chartLabel(r),
77+ href: cancerHref(r),
78+ x: Number(r.deaths),
79+ y: Number(r.publications_5y),
80+ size: Number(r.active_trials),
81+ tooltip: `${r.canonical_name} — deaths ${fmtInt(r.deaths)} (${scope?.year}) · publications 5 y ${fmtInt(r.publications_5y)} · active trials ${fmtInt(r.active_trials)} · research gap ratio ${fmtValue(r.research_gap_ratio, 'log2_ratio')}`,
82+ }));
83+ const trialPoints: ScatterPoint[] = eligible.map((r) => ({
84+ id: r.cancer_id,
85+ label: chartLabel(r),
86+ href: cancerHref(r),
87+ x: Number(r.deaths),
88+ y: Number(r.active_trials),
89+ tooltip: `${r.canonical_name} — deaths ${fmtInt(r.deaths)} (${scope?.year}) · active trials ${fmtInt(r.active_trials)} · trial gap ratio ${fmtValue(r.trial_gap_ratio, 'log2_ratio')}`,
90+ }));
91+ const dTrial = defs.get('trial_gap_ratio');
92+ const dRes = defs.get('research_gap_ratio');
93+ const dTpd = defs.get('trials_per_1000_deaths');
94+ const dPpd = defs.get('publications_per_1000_deaths');
95+
96+ return (
97+ <div>
98+ <PageHeader
99+ kicker="Unmet need · computed index"
100+ title="Research Gap Index"
101+ lede="How a cancer's share of deaths compares with its share of registered research activity — active interventional trials (ClinicalTrials.gov) and publications of the last five years (PubMed) — within one burden scope. It measures where registered activity is thin relative to mortality; it does not measure the quality, funding or difficulty of research, and it inherits every limit of the burden source."
102+ >
103+ <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">
104+ <ClaimBadge kind="computed" />
105+ {scope ? <span className="ci-mono">{scope.formula_version}</span> : null}
106+ {dTrial ? <span className="ci-mono">{dTrial.formula_version}</span> : null}
107+ {dRes ? <span className="ci-mono">{dRes.formula_version}</span> : null}
108+ <Link href="/methodology#research-gap" className="ci-link">
109+ Methodology
110+ </Link>
111+ <Link href="/rankings/trial_gap_ratio" className="ci-link">
112+ Trial Gap Ratio ranking
113+ </Link>
114+ <Link href="/rankings/research_gap_ratio" className="ci-link">
115+ Research Gap Ratio ranking
116+ </Link>
117+ </p>
118+ <Note>
119+ Burden is currently <strong>United States only</strong> (CDC WONDER and U.S. Cancer Statistics): global estimates (IARC / GLOBOCAN) remain under license review, so no world scope exists yet. Shares and ratios are relative to the eligible top-level cancers of the scope, not to all cancers.
120+ </Note>
121+ </PageHeader>
122+
123+ {!scope ? (
124+ <EmptyState title="Research gap components not yet computed" knows={[{ label: 'Percentile-based Trial Gap Index', href: '/rankings/trial_gap' }, { label: 'Percentile-based Research Gap Index', href: '/rankings/research_gap' }, { label: 'Methodology', href: '/methodology#research-gap' }]}>
125+ Components require a burden scope (annual deaths per top-level cancer for one geography, year, sex and source) plus trial and literature counters. They are recomputed by <code className="ci-mono">pnpm cix intel</code>.
126+ </EmptyState>
127+ ) : (
128+ <>
129+ {/* Scope selector (GET params, server-rendered) */}
130+ <form method="get" action="/research-gap" className="ci-rule flex flex-wrap items-end gap-3 pt-4 text-[13px]" aria-label="Burden scope">
131+ <label className="flex flex-col gap-0.5">
132+ <span className="ci-kicker">Geography</span>
133+ <select name="geography" defaultValue={scope.geography} className="border border-rule bg-paper px-2 py-1">
134+ {geographies.map((g) => (
135+ <option key={g} value={g}>
136+ {g}
137+ </option>
138+ ))}
139+ </select>
140+ </label>
141+ <label className="flex flex-col gap-0.5">
142+ <span className="ci-kicker">Sex</span>
143+ <select name="sex" defaultValue={scope.sex} className="border border-rule bg-paper px-2 py-1">
144+ {sexesFor(scope.geography).map((s) => (
145+ <option key={s} value={s}>
146+ {s === 'all' ? 'both sexes' : humanize(s)}
147+ </option>
148+ ))}
149+ </select>
150+ </label>
151+ <label className="flex flex-col gap-0.5">
152+ <span className="ci-kicker">Year</span>
153+ <select name="year" defaultValue={String(scope.year)} className="border border-rule bg-paper px-2 py-1">
154+ {yearsFor(scope.geography, scope.sex).map((y) => (
155+ <option key={y} value={y}>
156+ {y}
157+ </option>
158+ ))}
159+ </select>
160+ </label>
161+ <label className="flex flex-col gap-0.5">
162+ <span className="ci-kicker">Burden source</span>
163+ <select name="source" defaultValue={scope.source_slug} className="border border-rule bg-paper px-2 py-1">
164+ {sourcesFor(scope.geography, Number(scope.year), scope.sex).map((s) => (
165+ <option key={s.source_slug} value={s.source_slug}>
166+ {s.source_slug} ({s.n_eligible} eligible)
167+ </option>
168+ ))}
169+ </select>
170+ </label>
171+ <input type="hidden" name="sort" value={sort} />
172+ <input type="hidden" name="dir" value={dir} />
173+ <button type="submit" className="border border-rule-strong px-3 py-1 text-ink hover:border-accent hover:text-accent">
174+ Apply
175+ </button>
176+ <span className="text-[12px] text-ink-3">
177+ Scope key <span className="ci-mono">{gapScopeKey(scope)}</span> · burden source {sourceBadge}
178+ </span>
179+ </form>
180+
181+ {/* KPI strip */}
182+ <section aria-label="Scope totals" className="mt-5">
183+ <ul className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-5">
184+ {[
185+ { label: 'Scope', value: `${scope.geography} · ${scope.year}`, note: `${scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · top-level cancers`, text: true },
186+ { label: 'Eligible cancers', value: fmtInt(sums.eligible), note: `of ${fmtInt(rows.length)} with a mortality observation · deaths ≥ 100` },
187+ { label: 'Σ deaths', value: fmtInt(sums.deaths), note: `${scope.source_slug} · ${scope.year} · eligible set` },
188+ { label: 'Σ active trials', value: fmtInt(sums.activeTrials), note: 'ClinicalTrials.gov · cancer + NCIt descendants' },
189+ { label: 'Σ publications (5 y)', value: fmtInt(sums.publications5y), note: 'PubMed · query-based per entity' },
190+ ].map((k) => (
191+ <li key={k.label}>
192+ <span className={`block font-display text-ink ${k.text ? 'text-xl sm:text-2xl' : 'ci-num text-2xl sm:text-3xl'}`}>{k.value}</span>
193+ <span className="block text-[12.5px] font-medium text-ink-2">{k.label}</span>
194+ <span className="block text-[11.5px] text-ink-3">{k.note}</span>
195+ </li>
196+ ))}
197+ </ul>
198+ <Freshness dataUpdatedAt={scope.computed_at} extra={`components ${scope.formula_version} · sums are over the eligible set only`} />
199+ </section>
200+
201+ {/* Scatter 1 */}
202+ <Section
203+ id="deaths-vs-publications"
204+ kicker="Burden vs literature"
205+ title="Annual deaths vs publications of the last five years"
206+ description={`Each bubble is one eligible top-level cancer; bubble area is proportional to its active interventional trials. The dashed line is the scope's overall intensity (${fmtNum(pubSlope * 1000, 0)} publications per 1,000 deaths): cancers below it have a positive Research Gap Ratio — fewer publications than their share of deaths would suggest — and cancers above it a negative one.`}
207+ className="mt-8"
208+ actions={
209+ <Link href={`/rankings/research_gap_ratio?scope=${encodeURIComponent(gapScopeKey(scope))}`} className="ci-link">
210+ Research Gap Ratio ranking →
211+ </Link>
212+ }
213+ >
214+ {pubPoints.length ? (
215+ <ScatterChart points={pubPoints} xLabel={`Annual deaths, ${scope.year}`} yLabel="Publications, last 5 years" sizeLabel="Active trials" ariaLabel={`Scatter of annual deaths against publications for ${pubPoints.length} eligible top-level cancers, ${scope.geography} ${scope.year}`} reference={{ slope: pubSlope, label: `Scope average: ${fmtNum(pubSlope * 1000, 0)} publications per 1,000 deaths (Research Gap Ratio = 0)` }} width={880} height={460} />
216+ ) : (
217+ <EmptyState compact>No eligible cancer in this scope.</EmptyState>
218+ )}
219+ <p className="mt-1.5 flex flex-wrap items-center gap-2 text-[11.5px] text-ink-3">
220+ <ClaimBadge kind="computed" />
221+ <span>
222+ x: {sourceBadge} mortality_count · y: <Link href="/source/pubmed" className="ci-src">pubmed</Link> publications_5y · size: <Link href="/source/clinicaltrials" className="ci-src">clinicaltrials</Link> active_trials
223+ </span>
224+ </p>
225+ </Section>
226+
227+ {/* Scatter 2 */}
228+ <Section
229+ id="deaths-vs-trials"
230+ kicker="Burden vs clinical research"
231+ title="Annual deaths vs active interventional trials"
232+ description={`Dashed line: ${fmtNum(trialSlope * 1000, 1)} active trials per 1,000 deaths (Trial Gap Ratio = 0). Below the line: positive Trial Gap Ratio.`}
233+ className="mt-8"
234+ actions={
235+ <Link href={`/rankings/trial_gap_ratio?scope=${encodeURIComponent(gapScopeKey(scope))}`} className="ci-link">
236+ Trial Gap Ratio ranking →
237+ </Link>
238+ }
239+ >
240+ <div className="max-w-3xl">
241+ {trialPoints.length ? <ScatterChart points={trialPoints} xLabel={`Annual deaths, ${scope.year}`} yLabel="Active interventional trials" ariaLabel={`Scatter of annual deaths against active trials for ${trialPoints.length} eligible top-level cancers, ${scope.geography} ${scope.year}`} reference={{ slope: trialSlope, label: `Scope average: ${fmtNum(trialSlope * 1000, 1)} active trials per 1,000 deaths (Trial Gap Ratio = 0)` }} width={640} height={320} /> : <EmptyState compact>No eligible cancer in this scope.</EmptyState>}
242+ </div>
243+ </Section>
244+
245+ {/* Table */}
246+ <Section
247+ id="components"
248+ kicker="Components"
249+ title="Deaths, activity, shares and gap ratios per cancer"
250+ description="Click a column header to sort (the order is kept in the URL). Ineligible cancers are listed last with the reason. Shares are the cancer's fraction of the eligible set's total; ratios are log₂(death share ÷ activity share): 0 = proportional, +1 = twice the death share, −1 = half."
251+ className="mt-8"
252+ actions={
253+ <a href={`/api/export/research-gap.csv${withParams({ geography: scope.geography, year: scope.year, sex: scope.sex, source: scope.source_slug }, {})}`} className="inline-flex items-center gap-1.5 border border-rule px-2.5 py-1 text-[13px] text-ink no-underline hover:border-accent hover:text-accent">
254+ <Download className="h-3.5 w-3.5" aria-hidden /> CSV (with attribution)
255+ </a>
256+ }
257+ >
258+ <div className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">
259+ {sourceBadge}
260+ <Link href="/source/clinicaltrials" className="ci-src">
261+ clinicaltrials
262+ </Link>
263+ <Link href="/source/pubmed" className="ci-src">
264+ pubmed
265+ </Link>
266+ <ClaimBadge kind="computed" />
267+ <span>
268+ {fmtInt(rows.length)} top-level cancers with a {scope.year} mortality observation · {fmtInt(sums.eligible)} eligible
269+ </span>
270+ </div>
271+ <div className="ci-table-wrap">
272+ <table className="ci-table">
273+ <thead>
274+ <tr>
275+ <th>
276+ <Link href={sortLink('canonical_name')} className="no-underline">
277+ Cancer{sortMark('canonical_name')}
278+ </Link>
279+ </th>
280+ <th className="num">
281+ <Link href={sortLink('deaths')} className="no-underline" title={`mortality_count · ${scope.source_slug} · ${scope.year}`}>
282+ Deaths {scope.year}
283+ {sortMark('deaths')}
284+ </Link>
285+ </th>
286+ <th className="num">
287+ <Link href={sortLink('death_share')} className="no-underline">
288+ Death share{sortMark('death_share')}
289+ </Link>
290+ </th>
291+ <th className="num">
292+ <Link href={sortLink('active_trials')} className="no-underline">
293+ Active trials{sortMark('active_trials')}
294+ </Link>
295+ </th>
296+ <th className="num">
297+ <Link href={sortLink('trial_share')} className="no-underline">
298+ Trial share{sortMark('trial_share')}
299+ </Link>
300+ </th>
301+ <th className="num">
302+ <Link href={sortLink('publications_5y')} className="no-underline">
303+ Publications 5 y{sortMark('publications_5y')}
304+ </Link>
305+ </th>
306+ <th className="num">
307+ <Link href={sortLink('publication_share')} className="no-underline">
308+ Publication share{sortMark('publication_share')}
309+ </Link>
310+ </th>
311+ <th className="num">
312+ <Link href={sortLink('trial_gap_ratio')} className="no-underline" title={dTrial?.formula}>
313+ Trial gap ratio{sortMark('trial_gap_ratio')}
314+ </Link>
315+ </th>
316+ <th className="num">
317+ <Link href={sortLink('research_gap_ratio')} className="no-underline" title={dRes?.formula}>
318+ Research gap ratio{sortMark('research_gap_ratio')}
319+ </Link>
320+ </th>
321+ <th className="num">
322+ <Link href={sortLink('trials_per_1000_deaths')} className="no-underline" title={dTpd?.formula}>
323+ Trials / 1,000 deaths{sortMark('trials_per_1000_deaths')}
324+ </Link>
325+ </th>
326+ <th className="num">
327+ <Link href={sortLink('publications_per_1000_deaths')} className="no-underline" title={dPpd?.formula}>
328+ Pubs / 1,000 deaths{sortMark('publications_per_1000_deaths')}
329+ </Link>
330+ </th>
331+ <th>Eligibility</th>
332+ <th>Inputs</th>
333+ </tr>
334+ </thead>
335+ <tbody>
336+ {rows.map((r) => (
337+ <tr key={r.cancer_id} className={r.eligible ? '' : 'text-ink-3'}>
338+ <td className="min-w-[200px]">
339+ <Link className="ci-link" href={cancerHref(r)}>
340+ {r.canonical_name}
341+ </Link>
342+ {r.eligible && (r.trial_gap_rank != null || r.research_gap_rank != null) ? (
343+ <span className="ml-1.5 text-[11px] text-ink-3" title="Rank in the Trial Gap Ratio / Research Gap Ratio rankings of this scope (1 = largest gap)">
344+ #{r.trial_gap_rank ?? '—'} / #{r.research_gap_rank ?? '—'}
345+ </span>
346+ ) : null}
347+ </td>
348+ <td className="num">
349+ <span className="ci-num">{fmtInt(r.deaths)}</span>
350+ <span className="ml-1 text-[11px] text-ink-3">
351+ {scope.year} {sourceBadge}
352+ </span>
353+ </td>
354+ <td className="num">{fmtPct(r.death_share, 1)}</td>
355+ <td className="num">{fmtInt(r.active_trials)}</td>
356+ <td className="num">{fmtPct(r.trial_share, 1)}</td>
357+ <td className="num">{fmtInt(r.publications_5y)}</td>
358+ <td className="num">{fmtPct(r.publication_share, 1)}</td>
359+ <td className="num">
360+ <Ratio v={r.trial_gap_ratio} />
361+ </td>
362+ <td className="num">
363+ <Ratio v={r.research_gap_ratio} />
364+ </td>
365+ <td className="num">{fmtValue(r.trials_per_1000_deaths, 'per_1000_deaths')}</td>
366+ <td className="num">{fmtValue(r.publications_per_1000_deaths, 'per_1000_deaths')}</td>
367+ <td>
368+ {r.eligible ? (
369+ <Badge tone="ok" title="deaths ≥ 100; ratios require ≥ 1 trial / ≥ 1 publication">
370+ eligible
371+ </Badge>
372+ ) : (
373+ <Badge tone="outline" title={r.ineligible_reason ?? undefined}>
374+ {r.ineligible_reason?.split(' ')[0]?.replace(/_/g, ' ') ?? 'ineligible'}
375+ </Badge>
376+ )}
377+ </td>
378+ <td>
379+ <details>
380+ <summary className="ci-link text-[12.5px]">inputs</summary>
381+ <div className="mt-1 max-w-[380px]">
382+ <JsonView data={r.inputs} />
383+ <p className="ci-mono mt-1 text-[10.5px] text-ink-4">
384+ component {r.component_id} · {r.formula_version}
385+ </p>
386+ </div>
387+ </details>
388+ </td>
389+ </tr>
390+ ))}
391+ </tbody>
392+ </table>
393+ </div>
394+ <Freshness dataUpdatedAt={scope.computed_at} extra={`computed ${fmtDateTime(scope.computed_at)} · ${scope.formula_version}`} />
395+ </Section>
396+
397+ <div className="mt-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
398+ <Section id="formulas" kicker="Formulas" title="How each column is computed" level={3}>
399+ {/* Stacked (not the two-column KV): formulas are long and must keep the full width on narrow screens. */}
400+ <dl className="space-y-2.5 text-[13px]">
401+ {[
402+ { k: 'Death share', f: 'deaths / Σ deaths (eligible set)', v: null },
403+ { k: 'Trial share', f: 'active_trials / Σ active_trials (eligible set)', v: null },
404+ { k: 'Publication share', f: 'publications_5y / Σ publications_5y (eligible set)', v: null },
405+ ...[dTrial, dRes, dTpd, dPpd].filter((d): d is NonNullable<typeof d> => !!d).map((d) => ({ k: d.name, f: d.formula, v: d.formula_version })),
406+ ].map((it) => (
407+ <div key={it.k}>
408+ <dt className="text-ink-3">{it.k}</dt>
409+ <dd className="min-w-0 break-words">
410+ <code className="ci-mono text-[12px]">{it.f}</code>
411+ {it.v ? <span className="ci-mono ml-1.5 text-[11px] text-ink-3">{it.v}</span> : null}
412+ </dd>
413+ </div>
414+ ))}
415+ <div>
416+ <dt className="text-ink-3">Eligibility</dt>
417+ <dd>deaths ≥ 100 in the scope; a ratio is undefined (—) when the cancer has no trial / no publication.</dd>
418+ </div>
419+ <div>
420+ <dt className="text-ink-3">Components</dt>
421+ <dd className="ci-mono">{scope.formula_version}</dd>
422+ </div>
423+ </dl>
424+ <p className="mt-3 text-[12px] text-ink-3">
425+ <Link href="/methodology#research-gap" className="ci-link">
426+ Full methodology
427+ </Link>{' '}
428+ · percentile-based indexes: <Link href="/rankings/trial_gap" className="ci-link">Trial Gap Index</Link>, <Link href="/rankings/research_gap" className="ci-link">Research Gap Index</Link>.
429+ </p>
430+ </Section>
431+ <Section id="caveats" kicker="Read with care" title="What this index is not" level={3}>
432+ <ul className="list-disc space-y-1.5 pl-5 text-[13px] text-ink-2">
433+ <li>Not a judgement of research quality, funding or difficulty — only registered activity counted against deaths. A positive ratio is a signal to look closer, not an accusation.</li>
434+ <li>Trial counts aggregate a cancer and its NCIt descendants; registrations phrased at a broader level (e.g. &ldquo;colorectal cancer&rdquo;) are attributed to the broader entity and can overstate the gap of narrower sites.</li>
435+ <li>Literature counts are query-based per entity (the query is stored with each count) and are not aggregated over descendants; a narrow query can inflate a Research Gap Ratio.</li>
436+ <li>Burden depends on the epidemiology source and its site definitions; the same cancer can have a different ratio under another source or year. Compare only within one table.</li>
437+ <li>United States only for now; a US death share is not a global death share.</li>
438+ <li>Shares are relative to the eligible set: adding or removing one cancer changes every other cancer&rsquo;s ratio slightly.</li>
439+ </ul>
440+ </Section>
441+ </div>
442+ </>
443+ )}
444+ </div>
445+ );
446+}
added apps/web/src/components/charts/scatter-chart.tsx +272 −0
@@ -0,0 +1,272 @@
1+import { fmtInt, fmtNum } from '@/lib/format';
2+
3+export interface ScatterPoint {
4+ id: string;
5+ label: string;
6+ /** Every point is a link (to the cancer page). */
7+ href: string;
8+ x: number;
9+ y: number;
10+ /** Bubble area is proportional to this value (e.g. active trials). Omit for uniform dots. */
11+ size?: number | null;
12+ /** Full tooltip text (`<title>`); defaults to label + coordinates. */
13+ tooltip?: string;
14+ /** Rendered in gray (e.g. ineligible for the index). */
15+ muted?: boolean;
16+}
17+
18+export interface ReferenceLine {
19+ /** y = slope · x (a straight line on log-log axes). */
20+ slope: number;
21+ label: string;
22+}
23+
24+const fmtTick = (v: number): string => (v >= 1000 ? fmtInt(v) : v >= 1 ? fmtNum(v, 0) : fmtNum(v, 2));
25+
26+/** Ticks at 1·10^k (and 2·, 5· when the axis spans few decades). */
27+function logTicks(min: number, max: number): number[] {
28+ const lo = Math.floor(Math.log10(min));
29+ const hi = Math.ceil(Math.log10(max));
30+ const decades = hi - lo;
31+ const mults = decades <= 2 ? [1, 2, 5] : decades <= 4 ? [1, 3] : [1];
32+ const out: number[] = [];
33+ for (let k = lo; k <= hi; k++) for (const m of mults) {
34+ const v = m * 10 ** k;
35+ if (v >= min && v <= max) out.push(v);
36+ }
37+ return out;
38+}
39+
40+/** Round to 1 significant digit in {1, 2, 5} × 10^k, not above v. */
41+function niceBelow(v: number): number {
42+ if (v <= 0) return 0;
43+ const k = 10 ** Math.floor(Math.log10(v));
44+ const m = v / k;
45+ return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * k;
46+}
47+
48+interface Box {
49+ x1: number;
50+ x2: number;
51+ y1: number;
52+ y2: number;
53+}
54+const overlaps = (a: Box, b: Box) => a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
55+
56+/**
57+ * Log–log scatter with area-proportional bubbles, one proportional reference line and per-point
58+ * labels — pure server-rendered SVG (no client JS). Every point is an `<a>` with a `<title>` tooltip
59+ * and an enlarged transparent hit circle; identity is carried by the label, never by colour alone.
60+ * Labels are placed to the right of each bubble and nudged vertically (then flipped to the left)
61+ * when they would overlap an already placed label — a cheap greedy pass that is enough for ≤ 40 points.
62+ */
63+export function ScatterChart({
64+ points,
65+ xLabel,
66+ yLabel,
67+ sizeLabel,
68+ ariaLabel,
69+ reference,
70+ width = 720,
71+ height = 400,
72+ labelAll,
73+ labelTop = 12,
74+}: {
75+ points: ScatterPoint[];
76+ xLabel: string;
77+ yLabel: string;
78+ sizeLabel?: string;
79+ ariaLabel: string;
80+ reference?: ReferenceLine | null;
81+ width?: number;
82+ height?: number;
83+ /** Label every point (default: when ≤ 40 points); otherwise only the `labelTop` largest bubbles. */
84+ labelAll?: boolean;
85+ labelTop?: number;
86+}) {
87+ const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x > 0 && p.y > 0);
88+ if (pts.length === 0) return null;
89+ const dropped = points.length - pts.length;
90+ const pad = { l: 64, r: 24, t: 14, b: 48 };
91+ const iw = width - pad.l - pad.r;
92+ const ih = height - pad.t - pad.b;
93+ const xs = pts.map((p) => p.x);
94+ const ys = pts.map((p) => p.y);
95+ // Domain padded by ~15% in log space so the largest bubbles and labels stay inside the plot.
96+ const padLog = (min: number, max: number): [number, number] => {
97+ const a = Math.log10(min);
98+ const b = Math.log10(max);
99+ const span = Math.max(b - a, 0.5);
100+ return [10 ** (a - span * 0.08), 10 ** (b + span * 0.15)];
101+ };
102+ const [xMin, xMax] = padLog(Math.min(...xs), Math.max(...xs));
103+ const [yMin, yMax] = padLog(Math.min(...ys), Math.max(...ys));
104+ const sx = (x: number) => pad.l + ((Math.log10(x) - Math.log10(xMin)) / (Math.log10(xMax) - Math.log10(xMin))) * iw;
105+ const sy = (y: number) => pad.t + ih - ((Math.log10(y) - Math.log10(yMin)) / (Math.log10(yMax) - Math.log10(yMin))) * ih;
106+
107+ const sizes = pts.map((p) => Math.max(0, Number(p.size ?? 0)));
108+ const maxSize = Math.max(...sizes, 0);
109+ const hasSize = maxSize > 0;
110+ const rMin = 3.5;
111+ const rMax = Math.min(18, Math.max(10, Math.sqrt(iw * ih) / 22));
112+ const radius = (s: number | null | undefined) => (hasSize ? rMin + (rMax - rMin) * Math.sqrt(Math.max(0, Number(s ?? 0)) / maxSize) : 5);
113+
114+ // Label placement (greedy).
115+ const font = 10;
116+ const showAll = labelAll ?? pts.length <= 40;
117+ const labelIds = new Set(showAll ? pts.map((p) => p.id) : [...pts].sort((a, b) => Number(b.size ?? 0) - Number(a.size ?? 0)).slice(0, labelTop).map((p) => p.id));
118+ const placed: Box[] = pts.map((p) => {
119+ const r = radius(p.size);
120+ return { x1: sx(p.x) - r, x2: sx(p.x) + r, y1: sy(p.y) - r, y2: sy(p.y) + r };
121+ });
122+ const labels = new Map<string, { x: number; y: number; anchor: 'start' | 'end'; off: number }>();
123+ const order = [...pts].sort((a, b) => sy(a.y) - sy(b.y) || sx(a.x) - sx(b.x));
124+ const dy = font + 1;
125+ for (const p of order) {
126+ if (!labelIds.has(p.id)) continue;
127+ const r = radius(p.size);
128+ const cx = sx(p.x);
129+ const cy = sy(p.y);
130+ const w = p.label.length * font * 0.6 + 2;
131+ // Right of the bubble first, then left, then growing vertical nudges on either side.
132+ const tries: Array<{ anchor: 'start' | 'end'; off: number }> = [];
133+ for (const off of [0, -dy, dy, -2 * dy, 2 * dy, -3 * dy, 3 * dy]) {
134+ tries.push({ anchor: 'start', off });
135+ tries.push({ anchor: 'end', off });
136+ }
137+ let chosen: { x: number; y: number; anchor: 'start' | 'end'; box: Box; off: number } | null = null;
138+ for (const t of tries) {
139+ const x = t.anchor === 'start' ? cx + r + 3 : cx - r - 3;
140+ const y = cy + font / 2 - 1 + t.off;
141+ const box: Box = t.anchor === 'start' ? { x1: x, x2: x + w, y1: y - font, y2: y + 2 } : { x1: x - w, x2: x, y1: y - font, y2: y + 2 };
142+ const inside = box.x1 >= pad.l - 2 && box.x2 <= width - 2 && box.y1 >= 0 && box.y2 <= height - pad.b + 8;
143+ if (inside && !placed.some((b) => overlaps(b, box))) {
144+ chosen = { x, y, anchor: t.anchor, box, off: t.off };
145+ break;
146+ }
147+ }
148+ if (!chosen) {
149+ // Last resort: keep the first candidate so no point is left unnamed (may touch a neighbour).
150+ const x = cx + r + 3;
151+ const y = cy + font / 2 - 1 + 4 * dy;
152+ chosen = { x, y, anchor: 'start', box: { x1: x, x2: x + w, y1: y - font, y2: y + 2 }, off: 4 * dy };
153+ }
154+ placed.push(chosen.box);
155+ labels.set(p.id, { x: chosen.x, y: chosen.y, anchor: chosen.anchor, off: chosen.off });
156+ }
157+
158+ // Reference line y = slope·x clipped to the domain.
159+ let ref: { x1: number; y1: number; x2: number; y2: number } | null = null;
160+ if (reference && reference.slope > 0) {
161+ const cands = [
162+ { x: xMin, y: reference.slope * xMin },
163+ { x: xMax, y: reference.slope * xMax },
164+ { x: yMin / reference.slope, y: yMin },
165+ { x: yMax / reference.slope, y: yMax },
166+ ].filter((c) => c.x >= xMin * 0.999 && c.x <= xMax * 1.001 && c.y >= yMin * 0.999 && c.y <= yMax * 1.001);
167+ if (cands.length >= 2) {
168+ cands.sort((a, b) => a.x - b.x);
169+ const a = cands[0]!;
170+ const b = cands[cands.length - 1]!;
171+ ref = { x1: sx(a.x), y1: sy(a.y), x2: sx(b.x), y2: sy(b.y) };
172+ }
173+ }
174+
175+ const xTicks = logTicks(xMin, xMax);
176+ const yTicks = logTicks(yMin, yMax);
177+ const legendSizes = hasSize ? [...new Set([niceBelow(maxSize), niceBelow(maxSize / 4), niceBelow(maxSize / 16)].filter((v) => v > 0))] : [];
178+
179+ return (
180+ <figure className="w-full">
181+ {/* Narrow screens scroll horizontally (like .ci-table-wrap) instead of shrinking labels below legibility. */}
182+ <div className="overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch' }}>
183+ <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block" style={{ fontFamily: 'var(--font-sans)', minWidth: Math.min(width, 640) }}>
184+ <title>{ariaLabel}</title>
185+ {/* grid */}
186+ {yTicks.map((v) => (
187+ <g key={`y${v}`}>
188+ <line x1={pad.l} x2={width - pad.r} y1={sy(v)} y2={sy(v)} stroke="var(--color-rule)" strokeWidth="1" />
189+ <text x={pad.l - 8} y={sy(v) + 3.5} textAnchor="end" fontSize="10.5" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>
190+ {fmtTick(v)}
191+ </text>
192+ </g>
193+ ))}
194+ {xTicks.map((v) => (
195+ <g key={`x${v}`}>
196+ <line y1={pad.t} y2={height - pad.b} x1={sx(v)} x2={sx(v)} stroke="var(--color-rule)" strokeWidth="1" />
197+ <text x={sx(v)} y={height - pad.b + 14} textAnchor="middle" fontSize="10.5" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>
198+ {fmtTick(v)}
199+ </text>
200+ </g>
201+ ))}
202+ <line x1={pad.l} x2={width - pad.r} y1={height - pad.b} y2={height - pad.b} stroke="var(--color-rule-strong)" strokeWidth="1" />
203+ <line x1={pad.l} x2={pad.l} y1={pad.t} y2={height - pad.b} stroke="var(--color-rule-strong)" strokeWidth="1" />
204+ {/* axis titles */}
205+ <text x={pad.l + iw / 2} y={height - 10} textAnchor="middle" fontSize="11" fill="var(--color-ink-2)">
206+ {xLabel} (log scale)
207+ </text>
208+ <text x={14} y={pad.t + ih / 2} textAnchor="middle" fontSize="11" fill="var(--color-ink-2)" transform={`rotate(-90 14 ${pad.t + ih / 2})`}>
209+ {yLabel} (log scale)
210+ </text>
211+ {/* reference line */}
212+ {ref && reference ? (
213+ <g>
214+ <line {...ref} stroke="var(--color-ink-3)" strokeWidth="1.25" strokeDasharray="5 4" />
215+ <title>{reference.label}</title>
216+ </g>
217+ ) : null}
218+ {/* points (largest first so small bubbles stay clickable on top) */}
219+ {[...pts]
220+ .sort((a, b) => Number(b.size ?? 0) - Number(a.size ?? 0))
221+ .map((p) => {
222+ const r = radius(p.size);
223+ const cx = sx(p.x);
224+ const cy = sy(p.y);
225+ const lab = labels.get(p.id);
226+ const tip = p.tooltip ?? `${p.label} — ${xLabel}: ${fmtTick(p.x)} · ${yLabel}: ${fmtTick(p.y)}${hasSize && p.size != null ? ` · ${sizeLabel ?? 'size'}: ${fmtInt(p.size)}` : ''}`;
227+ const fill = p.muted ? 'var(--color-ink-4)' : 'var(--color-accent)';
228+ return (
229+ <a key={p.id} href={p.href} aria-label={tip}>
230+ <title>{tip}</title>
231+ <circle cx={cx} cy={cy} r={Math.max(r + 8, 12)} fill="transparent" />
232+ <circle cx={cx} cy={cy} r={r} fill={fill} fillOpacity={p.muted ? 0.45 : 0.6} stroke="var(--color-paper)" strokeWidth="2" />
233+ <circle cx={cx} cy={cy} r={Math.min(1.5, r / 2)} fill={fill} />
234+ {lab && Math.abs(lab.off) >= dy ? (
235+ // Leader from the bubble edge to a label that had to be nudged away from its point.
236+ <line x1={lab.anchor === 'start' ? cx + r : cx - r} y1={cy} x2={lab.anchor === 'start' ? lab.x - 1 : lab.x + 1} y2={lab.y - font / 2 + 1} stroke="var(--color-ink-4)" strokeWidth="0.75" />
237+ ) : null}
238+ {lab ? (
239+ <text x={lab.x} y={lab.y} textAnchor={lab.anchor} fontSize={font} fill={p.muted ? 'var(--color-ink-3)' : 'var(--color-ink-2)'} paintOrder="stroke" stroke="var(--color-paper)" strokeWidth="2.5" strokeLinejoin="round">
240+ {p.label}
241+ </text>
242+ ) : null}
243+ </a>
244+ );
245+ })}
246+ </svg>
247+ </div>
248+ <figcaption className="mt-1 flex flex-wrap items-center gap-x-5 gap-y-1 text-[12px] text-ink-2">
249+ {reference ? (
250+ <span className="inline-flex items-center gap-1.5">
251+ <span aria-hidden className="inline-block h-0 w-5 border-t border-dashed border-ink-3" />
252+ {reference.label}
253+ </span>
254+ ) : null}
255+ {legendSizes.length ? (
256+ <span className="inline-flex items-center gap-2">
257+ <span>{sizeLabel ?? 'Bubble area'}:</span>
258+ {legendSizes.map((v) => (
259+ <span key={v} className="inline-flex items-center gap-1">
260+ <svg width={rMax * 2 + 2} height={rMax * 2 + 2} aria-hidden className="shrink-0">
261+ <circle cx={rMax + 1} cy={rMax + 1} r={radius(v)} fill="var(--color-accent)" fillOpacity="0.6" stroke="var(--color-paper)" strokeWidth="2" />
262+ </svg>
263+ <span className="ci-num">{fmtInt(v)}</span>
264+ </span>
265+ ))}
266+ </span>
267+ ) : null}
268+ {dropped > 0 ? <span className="text-ink-3">{dropped} point(s) with a zero value cannot be shown on log axes.</span> : null}
269+ </figcaption>
270+ </figure>
271+ );
272+}
modified apps/web/src/components/home/gaps-module.tsx +82 −67
@@ -3,91 +3,106 @@ import { Section } from '@/components/ui/section';
3 import { EmptyState } from '@/components/ui/empty-state'; 3 import { EmptyState } from '@/components/ui/empty-state';
4 import { Freshness } from '@/components/ui/freshness'; 4 import { Freshness } from '@/components/ui/freshness';
5 import { ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; 5 import { ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';
6−import { gapRankings } from '@/lib/queries/rankings'; 6+import { ratioGapRankings } from '@/lib/queries/research-gap';
7−import { fmtDate, fmtInt, fmtValue, scopeLabel } from '@/lib/format'; 7+import { fmtDate, fmtInt, fmtPct, fmtValue, scopeLabel } from '@/lib/format';
8 8
9 /** 9 /**
10− * Home module "Largest trial gaps / research gaps" (§105, §328, §265-266): the gap indexes from the current 10+ * Home module "Largest trial and research gaps" (§105, §328, §265-266): the ratio-based gap indexes
11− * ranking snapshots for a geography. Rendered only when snapshots exist; each row exposes the two percentiles 11+ * (Trial Gap Ratio, Research Gap Ratio) from the current ranking snapshots of the latest burden scope
12− * that produced the value (lineage stored on the ranking row). 12+ * of a geography. Each row exposes the two shares that produced the log₂ ratio (lineage stored on the
13+ * ranking row). The percentile-based indexes remain available under /rankings; the full component
14+ * table, scatter plots and CSV live at /research-gap.
13 */ 15 */
14 export async function GapsModule({ geo = 'USA', limit = 8 }: { geo?: string; limit?: number }) { 16 export async function GapsModule({ geo = 'USA', limit = 8 }: { geo?: string; limit?: number }) {
15− const gaps = await gapRankings(geo, limit); 17+ const gaps = await ratioGapRankings(geo, limit);
16 return ( 18 return (
17 <Section 19 <Section
18 id="gaps" 20 id="gaps"
19 kicker="Unmet need" 21 kicker="Unmet need"
20 title="Largest trial and research gaps" 22 title="Largest trial and research gaps"
21− description="Burden percentile minus activity percentile within the same scope. Positive values flag cancers with high mortality burden but comparatively few active trials (trial gap) or little recent literature (research gap). A quantitative signal, not an accusation." 23+ description="log₂ of a cancer's share of deaths divided by its share of active interventional trials (trial gap) or of publications of the last five years (research gap), over the eligible top-level cancers of one burden scope. 0 = proportional; +1 = twice the share of deaths that activity would suggest; −1 = half. A quantitative signal, not an accusation."
22 actions={ 24 actions={
23− <Link href="/rankings" className="ci-link"> 25+ <>
24− All rankings → 26+ <Link href="/research-gap" className="ci-link">
25− </Link> 27+ Research Gap Index →
28+ </Link>
29+ <Link href="/rankings" className="ci-link">
30+ All rankings
31+ </Link>
32+ </>
26 } 33 }
27 > 34 >
28 {gaps.length === 0 ? ( 35 {gaps.length === 0 ? (
29− <EmptyState title="Gap indexes not yet computed"> 36+ <EmptyState title="Gap indexes not yet computed" knows={[{ label: 'Percentile-based Trial Gap Index', href: '/rankings/trial_gap' }, { label: 'Percentile-based Research Gap Index', href: '/rankings/research_gap' }]}>
30− Trial and research gap indexes require a burden snapshot (deaths per top-level cancer for one geography and year) plus trial and literature counters. Nothing is shown until such a snapshot exists. 37+ Gap ratios require a burden scope (deaths per top-level cancer for one geography, year and source) plus trial and literature counters. Nothing is shown until such a snapshot exists.
31 </EmptyState> 38 </EmptyState>
32 ) : ( 39 ) : (
33 <div className="grid gap-6 lg:grid-cols-2"> 40 <div className="grid gap-6 lg:grid-cols-2">
34− {gaps.map(({ metric, snapshot, rows }) => ( 41+ {gaps.map(({ metric, snapshot, rows }) => {
35− <div key={metric.slug} className="min-w-0"> 42+ const isTrial = metric.slug === 'trial_gap_ratio';
36− <p className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2"> 43+ return (
37− <Link href={`/rankings/${metric.slug}?scope=${encodeURIComponent(snapshot.scope_key)}`} className="ci-link font-medium"> 44+ <div key={metric.slug} className="min-w-0">
38− {metric.name} 45+ <p className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2">
39− </Link> 46+ <Link href={`/rankings/${metric.slug}?scope=${encodeURIComponent(snapshot.scope_key)}`} className="ci-link font-medium">
40− <span className="text-[11.5px] text-ink-3">{scopeLabel(snapshot.scope_key)}</span> 47+ {metric.name}
41− </p> 48+ </Link>
42− <div className="ci-table-wrap"> 49+ <span className="text-[11.5px] text-ink-3">{scopeLabel(snapshot.scope_key)}</span>
43− <table className="ci-table"> 50+ </p>
44− <thead> 51+ <div className="ci-table-wrap">
45− <tr> 52+ <table className="ci-table">
46− <th className="num">#</th> 53+ <thead>
47− <th>Cancer</th> 54+ <tr>
48− <th className="num">Gap (pct. points)</th> 55+ <th className="num">#</th>
49− <th className="num">Deaths</th> 56+ <th>Cancer</th>
50− <th className="num">{metric.slug === 'trial_gap' ? 'Active trials' : 'Publications 5y'}</th> 57+ <th className="num">Ratio (log₂)</th>
51− <th>Confidence</th> 58+ <th className="num">Deaths</th>
52− </tr> 59+ <th className="num">Death share</th>
53− </thead> 60+ <th className="num">{isTrial ? 'Active trials' : 'Publications 5y'}</th>
54− <tbody> 61+ <th className="num">{isTrial ? 'Trial share' : 'Pub. share'}</th>
55− {rows.map((r) => ( 62+ <th>Confidence</th>
56− <tr key={r.id}>
57− <td className="num">{r.rank}</td>
58− <td>
59− <Link className="ci-link" href={`/cancer/${r.slug}/rankings`}>
60− {r.canonical_name}
61− </Link>
62− </td>
63− <td className={`num font-medium ${r.value > 0 ? 'text-danger' : ''}`}>{fmtValue(r.value, r.unit)}</td>
64− <td className="num">{fmtInt(r.inputs.deaths as number | undefined)}</td>
65− <td className="num">{fmtInt(r.inputs.activity as number | undefined)}</td>
66− <td>
67− <ConfidenceBadge level={r.confidence} />
68− </td>
69 </tr> 63 </tr>
70− ))} 64+ </thead>
71− </tbody> 65+ <tbody>
72− </table> 66+ {rows.map((r) => (
67+ <tr key={r.id}>
68+ <td className="num">{r.rank}</td>
69+ <td>
70+ <Link className="ci-link" href={`/cancer/${r.slug}/rankings`}>
71+ {r.canonical_name}
72+ </Link>
73+ </td>
74+ <td className={`num font-medium ${r.value >= 1 ? 'text-danger' : ''}`}>{fmtValue(r.value, r.unit)}</td>
75+ <td className="num">{fmtInt(r.inputs.deaths as number | undefined)}</td>
76+ <td className="num text-ink-3">{fmtPct(r.inputs.deathShare as number | undefined, 1)}</td>
77+ <td className="num">{fmtInt((isTrial ? r.inputs.activeTrials : r.inputs.publications5y) as number | undefined)}</td>
78+ <td className="num text-ink-3">{fmtPct((isTrial ? r.inputs.trialShare : r.inputs.publicationShare) as number | undefined, 1)}</td>
79+ <td>
80+ <ConfidenceBadge level={r.confidence} />
81+ </td>
82+ </tr>
83+ ))}
84+ </tbody>
85+ </table>
86+ </div>
87+ <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
88+ <ClaimBadge kind="computed" />
89+ <span>
90+ formula <code className="ci-mono">{metric.formula}</code> · <span className="ci-mono">{snapshot.formula_version}</span>
91+ </span>
92+ <span>sources: {snapshot.source_ids.join(', ')}</span>
93+ <span>{fmtInt(snapshot.eligible_entities)} eligible</span>
94+ </p>
95+ <p className="mt-1 text-[11.5px] text-ink-3">
96+ {isTrial ? 'Caveat: trial counts aggregate a cancer and its NCIt descendants; trials registered at a broader level (e.g. "colorectal") are attributed to the broader entity and can overstate the gap of narrower sites.' : 'Caveat: literature counts are query-based per entity and not aggregated over descendants; a narrow query inflates the ratio.'}{' '}
97+ Burden is US-only while global estimates are under license review.{' '}
98+ <Link className="ci-link" href="/methodology#research-gap">
99+ Read the caveats
100+ </Link>
101+ </p>
102+ <Freshness dataUpdatedAt={snapshot.generated_at} extra={`snapshot generated ${fmtDate(snapshot.generated_at)}`} />
73 </div> 103 </div>
74− <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3"> 104+ );
75− <ClaimBadge kind="computed" /> 105+ })}
76− <span>
77− formula <code className="ci-mono">{metric.formula}</code> · <span className="ci-mono">{snapshot.formula_version}</span>
78− </span>
79− <span>sources: {snapshot.source_ids.join(', ')}</span>
80− <span>{fmtInt(snapshot.eligible_entities)} eligible</span>
81− </p>
82− <p className="mt-1 text-[11.5px] text-ink-3">
83− {metric.slug === 'trial_gap' ? 'Caveat: trial counts aggregate a cancer and its NCIt descendants; trials registered at a broader level (e.g. "colorectal") are attributed to the broader entity and can overstate the gap of narrower sites.' : 'Caveat: literature counts are query-based per entity and not aggregated over descendants.'}{' '}
84− <Link className="ci-link" href="/methodology#gap-caveat">
85− Read the caveat
86− </Link>
87− </p>
88− <Freshness dataUpdatedAt={snapshot.generated_at} extra={`snapshot generated ${fmtDate(snapshot.generated_at)}`} />
89− </div>
90− ))}
91 </div> 106 </div>
92 )} 107 )}
93 </Section> 108 </Section>
added apps/web/src/components/research-gap/cancer-gap-card.tsx +146 −0
@@ -0,0 +1,146 @@
1+import Link from 'next/link';
2+import { Section, KV, Note } from '@/components/ui/section';
3+import { Badge, ClaimBadge } from '@/components/ui/badge';
4+import { EmptyState } from '@/components/ui/empty-state';
5+import { Freshness } from '@/components/ui/freshness';
6+import { SourceBadge } from '@/components/ui/source-badge';
7+import { gapComponentForCancer, gapScopeKey } from '@/lib/queries/research-gap';
8+import { fmtInt, fmtPct, fmtValue, humanize } from '@/lib/format';
9+
10+function ratioText(v: number | null): string {
11+ if (v == null) return 'undefined (no activity)';
12+ const abs = Math.abs(v);
13+ const factor = 2 ** abs;
14+ const how = factor >= 10 ? `${fmtInt(factor)}×` : `${fmtValue(factor, 'ratio')}×`;
15+ if (abs < 0.15) return 'about proportional to its share of deaths';
16+ return v > 0 ? `death share ${how} its activity share` : `activity share ${how} its death share`;
17+}
18+
19+/**
20+ * Small server component for a cancer page: that cancer's research-gap components in the latest
21+ * burden scope of a geography (default USA, both sexes). Shows the inputs behind each ratio and links
22+ * to the full index and the two ratio rankings. Renders an EmptyState when the cancer is not a
23+ * top-level entity of the scope (components exist only for top-level cancers with a mortality
24+ * observation — CLAUDE.md §246-247).
25+ */
26+export async function CancerGapCard({ cancerId, geography = 'USA' }: { cancerId: string; geography?: string }) {
27+ const res = await gapComponentForCancer(cancerId, geography);
28+ return (
29+ <Section
30+ id="research-gap"
31+ kicker="Unmet need · computed"
32+ title="Research gap"
33+ level={3}
34+ actions={
35+ <Link href="/research-gap" className="ci-link">
36+ Full index →
37+ </Link>
38+ }
39+ >
40+ {!res ? (
41+ <EmptyState compact title="No research-gap components for this entity" knows={[{ label: 'Research Gap Index (top-level cancers)', href: '/research-gap' }, { label: 'Methodology', href: '/methodology#research-gap' }]}>
42+ Components are computed for top-level cancers with a mortality observation in a burden scope (United States only for now). Entities below the top level inherit no value: shares would double-count their parent.
43+ </EmptyState>
44+ ) : (
45+ (() => {
46+ const { scope, row, sums } = res;
47+ const key = gapScopeKey(scope);
48+ const src = <SourceBadge p={{ sourceSlug: scope.source_slug, sourceName: scope.source_name, dataset: `mortality_count · ${scope.geography} · ${scope.year} · ${scope.sex}`, layer: 'normalized' }} compact />;
49+ return (
50+ <>
51+ <p className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">
52+ <ClaimBadge kind="computed" />
53+ <span>
54+ {scope.geography} · {scope.year} · {scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · {fmtInt(sums.eligible)} eligible top-level cancers
55+ </span>
56+ {row.eligible ? (
57+ <Badge tone="ok">eligible</Badge>
58+ ) : (
59+ <Badge tone="outline" title={row.ineligible_reason ?? undefined}>
60+ ineligible
61+ </Badge>
62+ )}
63+ </p>
64+ <KV
65+ items={[
66+ {
67+ k: `Deaths ${scope.year}`,
68+ v: (
69+ <>
70+ <span className="ci-num">{fmtInt(row.deaths)}</span> {src}
71+ {row.death_share != null ? <span className="text-ink-3"> · {fmtPct(row.death_share, 1)} of {fmtInt(sums.deaths)} in the eligible set</span> : null}
72+ </>
73+ ),
74+ },
75+ {
76+ k: 'Active trials',
77+ v: (
78+ <>
79+ <span className="ci-num">{fmtInt(row.active_trials)}</span> <Link href="/source/clinicaltrials" className="ci-src">clinicaltrials</Link>
80+ {row.trial_share != null ? <span className="text-ink-3"> · {fmtPct(row.trial_share, 1)} of {fmtInt(sums.activeTrials)}</span> : null}
81+ <span className="text-ink-3"> · {fmtValue(row.trials_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>
82+ </>
83+ ),
84+ },
85+ {
86+ k: 'Publications 5 y',
87+ v: (
88+ <>
89+ <span className="ci-num">{fmtInt(row.publications_5y)}</span> <Link href="/source/pubmed" className="ci-src">pubmed</Link>
90+ {row.publication_share != null ? <span className="text-ink-3"> · {fmtPct(row.publication_share, 1)} of {fmtInt(sums.publications5y)}</span> : null}
91+ <span className="text-ink-3"> · {fmtValue(row.publications_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>
92+ </>
93+ ),
94+ },
95+ {
96+ k: 'Trial gap ratio',
97+ v: row.eligible ? (
98+ <>
99+ <span className={`ci-num font-medium ${(row.trial_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.trial_gap_ratio, 'log2_ratio')}</span>
100+ <span className="text-ink-3"> log₂ · {ratioText(row.trial_gap_ratio)}</span>
101+ {row.trial_gap_rank != null ? (
102+ <>
103+ {' '}
104+ ·{' '}
105+ <Link href={`/rankings/trial_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">
106+ rank #{row.trial_gap_rank} of {fmtInt(sums.eligible)}
107+ </Link>
108+ </>
109+ ) : null}
110+ </>
111+ ) : (
112+ <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>
113+ ),
114+ },
115+ {
116+ k: 'Research gap ratio',
117+ v: row.eligible ? (
118+ <>
119+ <span className={`ci-num font-medium ${(row.research_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.research_gap_ratio, 'log2_ratio')}</span>
120+ <span className="text-ink-3"> log₂ · {ratioText(row.research_gap_ratio)}</span>
121+ {row.research_gap_rank != null ? (
122+ <>
123+ {' '}
124+ ·{' '}
125+ <Link href={`/rankings/research_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">
126+ rank #{row.research_gap_rank} of {fmtInt(sums.eligible)}
127+ </Link>
128+ </>
129+ ) : null}
130+ </>
131+ ) : (
132+ <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>
133+ ),
134+ },
135+ { k: 'Formula', v: <span className="ci-mono text-[12px]">log2(death share ÷ activity share) · {row.formula_version}</span> },
136+ ]}
137+ />
138+ <Note>A gap ratio compares registered activity with deaths inside one scope. It is a signal, not a judgement of research quality; trial counts include NCIt descendants, literature counts are query-based per entity.</Note>
139+ <Freshness dataUpdatedAt={row.computed_at} extra={`scope ${key}`} />
140+ </>
141+ );
142+ })()
143+ )}
144+ </Section>
145+ );
146+}
added apps/web/src/lib/queries/research-gap.ts +157 −0
@@ -0,0 +1,157 @@
1+import 'server-only';
2+import { run, sql, safe } from '@/lib/db';
3+import { getMetric, latestSnapshotForGeo, rankingRows, type MetricDef, type RankingRow, type Snapshot } from '@/lib/queries/rankings';
4+
5+/** One burden scope with computed research-gap components (SPEC §34, §113). */
6+export interface GapScope {
7+ geography: string;
8+ year: number;
9+ sex: string;
10+ burden_source_id: string;
11+ source_slug: string;
12+ source_name: string;
13+ n_total: number;
14+ n_eligible: number;
15+ formula_version: string;
16+ computed_at: Date | string;
17+}
18+
19+export function gapScopeKey(s: Pick<GapScope, 'geography' | 'year' | 'sex'>): string {
20+ return `geo=${s.geography}|sex=${s.sex}|age=all|year=${s.year}|level=top`;
21+}
22+
23+export async function listGapScopes(): Promise<GapScope[]> {
24+ return safe(
25+ () =>
26+ run<GapScope>(sql`
27+ SELECT r.geography, r.year, r.sex, r.burden_source_id, s.slug AS source_slug, s.name AS source_name,
28+ count(*)::int AS n_total, count(*) FILTER (WHERE r.eligible)::int AS n_eligible,
29+ max(r.formula_version) AS formula_version, max(r.updated_at) AS computed_at
30+ FROM research_gap_components r JOIN sources s ON s.id = r.burden_source_id
31+ GROUP BY r.geography, r.year, r.sex, r.burden_source_id, s.slug, s.name
32+ ORDER BY r.geography, r.year DESC, r.sex, count(*) DESC, s.slug`),
33+ [] as GapScope[],
34+ );
35+}
36+
37+/**
38+ * Resolve the requested scope: geography (default USA), sex (default all), year (default latest),
39+ * source (default: the source with the most component rows in that geography/year/sex).
40+ */
41+export function pickGapScope(scopes: GapScope[], want: { geography?: string; year?: number | null; sex?: string; source?: string }): GapScope | null {
42+ const geo = (want.geography || 'USA').toUpperCase();
43+ let pool = scopes.filter((s) => s.geography.toUpperCase() === geo);
44+ if (pool.length === 0) pool = scopes;
45+ const sex = want.sex || 'all';
46+ const bySex = pool.filter((s) => s.sex === sex);
47+ if (bySex.length) pool = bySex;
48+ if (want.year) {
49+ const byYear = pool.filter((s) => Number(s.year) === want.year);
50+ if (byYear.length) pool = byYear;
51+ }
52+ const latest = Math.max(...pool.map((s) => Number(s.year)));
53+ pool = pool.filter((s) => Number(s.year) === latest);
54+ if (want.source) {
55+ const bySrc = pool.filter((s) => s.source_slug === want.source || s.burden_source_id === want.source);
56+ if (bySrc.length) pool = bySrc;
57+ }
58+ return [...pool].sort((a, b) => b.n_total - a.n_total || a.source_slug.localeCompare(b.source_slug))[0] ?? null;
59+}
60+
61+export interface GapComponent {
62+ component_id: number;
63+ cancer_id: string;
64+ slug: string;
65+ canonical_name: string;
66+ short_name: string | null;
67+ hematologic: boolean;
68+ deaths: number | null;
69+ incidence: number | null;
70+ active_trials: number;
71+ phase3_trials: number;
72+ publications_5y: number;
73+ approved_drugs: number;
74+ death_share: number | null;
75+ trial_share: number | null;
76+ publication_share: number | null;
77+ trial_gap_ratio: number | null;
78+ research_gap_ratio: number | null;
79+ trials_per_1000_deaths: number | null;
80+ publications_per_1000_deaths: number | null;
81+ eligible: boolean;
82+ ineligible_reason: string | null;
83+ formula_version: string;
84+ inputs: Record<string, unknown>;
85+ computed_at: Date | string;
86+ trial_gap_rank: number | null;
87+ research_gap_rank: number | null;
88+}
89+
90+export async function gapComponents(scope: Pick<GapScope, 'geography' | 'year' | 'sex' | 'burden_source_id'>): Promise<GapComponent[]> {
91+ const key = gapScopeKey(scope);
92+ return safe(
93+ () =>
94+ run<GapComponent>(sql`
95+ SELECT r.id AS component_id, r.cancer_id, c.slug, c.canonical_name, c.short_name, c.hematologic,
96+ r.deaths, r.incidence, r.active_trials, r.phase3_trials, r.publications_5y, r.approved_drugs,
97+ r.death_share, r.trial_share, r.publication_share, r.trial_gap_ratio, r.research_gap_ratio,
98+ r.trials_per_1000_deaths, r.publications_per_1000_deaths, r.eligible, r.ineligible_reason,
99+ r.formula_version, r.inputs, r.updated_at AS computed_at,
100+ tg.rank AS trial_gap_rank, rg.rank AS research_gap_rank
101+ FROM research_gap_components r JOIN cancers c ON c.id = r.cancer_id
102+ LEFT JOIN rankings tg ON tg.cancer_id = r.cancer_id AND tg.metric_slug = 'trial_gap_ratio' AND tg.scope_key = ${key}
103+ AND tg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'trial_gap_ratio' AND scope_key = ${key} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)
104+ LEFT JOIN rankings rg ON rg.cancer_id = r.cancer_id AND rg.metric_slug = 'research_gap_ratio' AND rg.scope_key = ${key}
105+ AND rg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'research_gap_ratio' AND scope_key = ${key} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)
106+ WHERE r.geography = ${scope.geography} AND r.year = ${Number(scope.year)} AND r.sex = ${scope.sex} AND r.burden_source_id = ${scope.burden_source_id}
107+ ORDER BY r.eligible DESC, r.research_gap_ratio DESC NULLS LAST, c.canonical_name`),
108+ [] as GapComponent[],
109+ );
110+}
111+
112+/** Sums over the eligible set (what the shares were divided by). */
113+export function gapSums(rows: GapComponent[]): { deaths: number; activeTrials: number; publications5y: number; eligible: number } {
114+ const e = rows.filter((r) => r.eligible);
115+ return {
116+ deaths: e.reduce((s, r) => s + Number(r.deaths ?? 0), 0),
117+ activeTrials: e.reduce((s, r) => s + Number(r.active_trials), 0),
118+ publications5y: e.reduce((s, r) => s + Number(r.publications_5y), 0),
119+ eligible: e.length,
120+ };
121+}
122+
123+/** The latest scope for a geography (sex all, most-populated source) — used by the home module and cancer card. */
124+export async function latestGapScope(geography = 'USA'): Promise<GapScope | null> {
125+ const scopes = await listGapScopes();
126+ return pickGapScope(scopes, { geography, sex: 'all' });
127+}
128+
129+/** One cancer's component row in the latest scope of a geography (null when not part of the scope). */
130+export async function gapComponentForCancer(cancerId: string, geography = 'USA'): Promise<{ scope: GapScope; row: GapComponent; sums: ReturnType<typeof gapSums> } | null> {
131+ const scope = await latestGapScope(geography);
132+ if (!scope) return null;
133+ const rows = await gapComponents(scope);
134+ const row = rows.find((r) => r.cancer_id === cancerId);
135+ if (!row) return null;
136+ return { scope, row, sums: gapSums(rows) };
137+}
138+
139+/** Home module: the ratio-based gap metrics for a geography (latest snapshot), top rows with lineage. */
140+export async function ratioGapRankings(geo: string, limit = 8): Promise<Array<{ metric: MetricDef; snapshot: Snapshot; rows: RankingRow[] }>> {
141+ const out: Array<{ metric: MetricDef; snapshot: Snapshot; rows: RankingRow[] }> = [];
142+ for (const slug of ['trial_gap_ratio', 'research_gap_ratio']) {
143+ const snapshot = await latestSnapshotForGeo(slug, geo);
144+ if (!snapshot) continue;
145+ const metric = await getMetric(slug);
146+ if (!metric) continue;
147+ const rows = await rankingRows(snapshot.id, limit);
148+ if (rows.length) out.push({ metric, snapshot, rows });
149+ }
150+ return out;
151+}
152+
153+/** Metric definitions used on the research-gap page (formula + version per column). */
154+export async function gapMetricDefs(): Promise<Map<string, MetricDef>> {
155+ const rows = await safe(() => run<MetricDef>(sql`SELECT m.*, 0::int AS snapshot_count FROM metric_definitions m WHERE m.slug IN ('trial_gap_ratio','research_gap_ratio','trials_per_1000_deaths','publications_per_1000_deaths')`), [] as MetricDef[]);
156+ return new Map(rows.map((r) => [r.slug, r]));
157+}
added docs/methodology/research-gap.md +149 −0
@@ -0,0 +1,149 @@
1+# Research Gap Index — methodology
2+
3+*Formula versions: components `ci-research-gap-components-v1`; rankings `ci-trial-gap-ratio-v1`,
4+`ci-research-gap-ratio-v1`, `ci-trials-per-deaths-v1`, `ci-pubs-per-deaths-v1`. Code:
5+`packages/ranking/src/research-gap.ts` (unit tests in `packages/ranking/test/research-gap.test.ts`).
6+Table: `research_gap_components`. Web: `/research-gap`, `/rankings/trial_gap_ratio`,
7+`/rankings/research_gap_ratio`. API: `GET /v1/research-gap`, `GET /v1/research-gap/scopes`.*
8+
9+## What it measures
10+
11+The Research Gap Index compares, inside **one burden scope**, each top-level cancer's **share of
12+deaths** with its **share of registered research activity**:
13+
14+- **active interventional trials** registered on ClinicalTrials.gov and mapped to the cancer or one of
15+ its NCIt descendants (`entity_counters.active_trial_count`);
16+- **publications of the last five years** in PubMed (`entity_counters.publication_count_5y`, falling
17+ back to `literature_counts` window `5y` when the counter is empty; the exact query is stored on the
18+ literature row and copied into `inputs`).
19+
20+It is a **computed metric** (claim category `computed_metric`). It says *where registered activity is
21+thin relative to mortality*. It does **not** say anything about the quality, cost, difficulty or
22+funding of research, and it inherits every limitation of the burden source (site definitions,
23+coding, completeness).
24+
25+## Scope
26+
27+A scope is a (geography, year, sex, burden source) combination in which at least **10 top-level
28+cancers** carry a `mortality_count` observation with `age_group = 'all'` — exactly the discovery rule
29+of the ranking engine (`packages/ranking/src/engine.ts`). Every sex present is a separate scope
30+(`all`, `male`, `female`). Scopes are rebuilt from scratch on each run (`pnpm cix intel`): the rows of
31+a scope are deleted and reinserted in one transaction, then four ranking snapshots are persisted
32+through the shared `persistSnapshot` so "Why this rank?", previous-rank deltas, TRACE and CSV exports
33+work like for any other metric.
34+
35+Burden today is **United States only** (CDC WONDER underlying-cause mortality; U.S. Cancer Statistics
36+NVSS mortality). IARC / GLOBOCAN is under license review, so no `WORLD` scope exists; a US death share
37+must not be read as a global death share.
38+
39+## Eligibility
40+
41+| Rule | Effect |
42+|---|---|
43+| `deaths ≥ 100` in the scope (`eligibility.minDeaths` of the four metric definitions) | the cancer enters the **eligible set** `E`; below the threshold the row is stored with `eligible = false` and `ineligible_reason = "deaths_below_threshold (n < 100)"`, shares and ratios `NULL` |
44+| no mortality observation | not stored (the scope is built from mortality observations) — the reason `no_mortality_observation` exists in the pure function for completeness |
45+| `active_trials ≥ 1` | needed for `trial_gap_ratio` (log₂ undefined at 0); otherwise that ratio is `NULL`, the row stays eligible and its 0 trials still count in Σ |
46+| `publications_5y ≥ 1` | same for `research_gap_ratio` |
47+
48+Per-1,000-deaths intensities are computed for every stored row (they need no cross-entity
49+denominator) but only eligible rows are ranked. Ranking confidence is `HIGH` when `deaths ≥ 1000`,
50+`MEDIUM` otherwise.
51+
52+Sex-specific scopes naturally produce zero-death rows (e.g. prostate in the female scope): they are
53+stored as ineligible, never dropped.
54+
55+## Shares
56+
57+All sums run over the eligible set `E` of the scope:
58+
59+```
60+death_share_i = deaths_i / Σ_{j∈E} deaths_j
61+trial_share_i = active_trials_i / Σ_{j∈E} active_trials_j
62+publication_share_i = publications_5y_i / Σ_{j∈E} publications_5y_j
63+```
64+
65+Shares are stored rounded to 6 decimals; the ratios below are computed from the unrounded shares (so
66+rounding never leaks into the index). Each share column sums to 1 over `E`.
67+
68+## Ratios and intensities
69+
70+```
71+trial_gap_ratio_i = log2( death_share_i / trial_share_i )
72+research_gap_ratio_i = log2( death_share_i / publication_share_i )
73+trials_per_1000_deaths_i = active_trials_i / (deaths_i / 1000)
74+publications_per_1000_deaths_i = publications_5y_i / (deaths_i / 1000)
75+```
76+
77+Ratios are rounded to 6 decimals, intensities to 4. Rankings sort **descending**: rank 1 = largest
78+positive gap.
79+
80+### Why log₂
81+
82+The raw quotient `death_share / activity_share` is asymmetric: "twice the share" is 2 but "half the
83+share" is 0.5, so under- and over-representation of the same magnitude look very different and cannot
84+be averaged or compared symmetrically. Taking log₂ makes the scale **symmetric around 0** and
85+**scale-free**: it does not depend on the size of the scope, so a value of +1 in the US 2024 scope
86+means the same thing as +1 in any other scope (unlike percentile-point differences, whose meaning
87+depends on how many entities are ranked). Base 2 was chosen for readability: each unit is a doubling.
88+
89+### How to read ±1
90+
91+| Value | Reading |
92+|---|---|
93+| `0` | the cancer's share of trials (or publications) equals its share of deaths |
94+| `+1` | it carries **twice** the share of deaths that its share of activity would suggest — half the activity per death of the scope average |
95+| `+2` | four times |
96+| `−1` | its share of activity is twice its share of deaths — twice the activity per death of the scope average |
97+| `NULL` | undefined: no registered activity (`activity < 1`) or ineligible row |
98+
99+Equivalently, the ratio is `log2( scope-average intensity / own intensity )`: on the `/research-gap`
100+scatter plots the dashed line is the scope's overall activity per death, and every cancer below it
101+has a positive ratio. A change of one cancer's inputs changes every other cancer's ratio slightly,
102+because the sums move.
103+
104+## Inputs stored with every row
105+
106+`research_gap_components.inputs` (jsonb) records the mortality observation id (and any duplicate
107+observation ids for the same cancer/metric in the scope, e.g. other site definitions), the incidence
108+observation id when present, the counters' `updated_at`, which source supplied the publication count,
109+the literature count id and query when the fallback was used, the thresholds, the per-ratio
110+eligibility flags, the scope sums and the activity source ids. Ranking rows repeat deaths, the
111+activity, both shares, the sums, the observation id and the formula text, so `/rankings/<slug>` can
112+answer "Why this rank?" without joining back.
113+
114+## Caveats
115+
116+1. **Descendant aggregation of trials.** Trial counts include the cancer's NCIt descendants. A trial
117+ registered for "colorectal cancer" is attributed to the broader entity, not split between colon and
118+ rectum: narrower top-level sites can look under-trialled.
119+2. **Query-based literature counts.** Publication counts come from a PubMed query per entity (stored
120+ on the row) and are *not* aggregated over descendants. A narrow or unlucky query yields a small
121+ count and a large positive Research Gap Ratio — a very high ratio should first prompt a check of
122+ the query.
123+3. **Registrants' condition phrasing.** Whether a trial reaches a cancer depends on how sponsors phrase
124+ conditions and on reconciliation; unresolved labels are queued, not guessed.
125+4. **US-only burden.** The share of deaths is a share of *US* deaths in one year and one source. The
126+ same cancer can have a different ratio under another source (`cdc-wonder` vs `cdc-uscs` differ in
127+ site definitions and coverage) or another year. Compare only within one table.
128+5. **Correlation ≠ under-funding.** A positive ratio does not establish neglect: some cancers are hard
129+ to enrol, some are dominated by prevention or surgery rather than drug trials, some have large
130+ registries elsewhere. It is a signal, phrased as such ("signal, not accusation").
131+6. **Relative to the eligible set.** Adding a cancer to the top-level set, or a cancer crossing the
132+ 100-deaths threshold, shifts every share.
133+
134+## Differences from the percentile indexes
135+
136+`trial_gap` / `research_gap` (`ci-trial-gap-v1`, `ci-research-gap-v1`) are
137+`percentile(mortality_count) − percentile(activity)`: rank-based, bounded to ±100 percentile points,
138+insensitive to magnitudes (a 2× and a 20× mismatch can score the same) and dependent on the number of
139+ranked entities. The ratio indexes are **magnitude-based, symmetric and scale-free**; they expose their
140+inputs (shares, sums) directly and add the two intensities (`trials_per_1000_deaths`,
141+`publications_per_1000_deaths`) that need no cross-entity comparison. Both families stay available:
142+the percentile indexes for continuity, the ratio indexes as the primary reading on `/research-gap`.
143+
144+## Recompute
145+
146+```
147+pnpm cix counters # refresh entity_counters
148+pnpm cix intel # research_gap_components + the four ranking snapshots (with the other intelligence tables)
149+```
modified packages/ranking/src/research-gap.ts +307 −7
@@ -1,18 +1,318 @@
1−import type { Database } from '@cancerindex/database'; 1+import { and, eq, sql } from 'drizzle-orm';
2+import { type Database, metricDefinitions, researchGapComponents } from '@cancerindex/database';
3+import { persistSnapshot, type Scope } from './engine.js';
4+import type { RankInput } from './rank.js';
2 5
3 export const RESEARCH_GAP_FORMULA_VERSION = 'ci-research-gap-components-v1'; 6 export const RESEARCH_GAP_FORMULA_VERSION = 'ci-research-gap-components-v1';
4 7
8+/** Eligibility thresholds (metric_definitions eligibility.minDeaths + activity ≥ 1 per ratio). */
9+export const RESEARCH_GAP_THRESHOLDS = {
10+ /** Minimum annual deaths in the scope for a cancer to enter the eligible set. */
11+ minDeaths: 100,
12+ /** Minimum activity (trials or publications) for the corresponding log-ratio to be defined. */
13+ minActivity: 1,
14+ /** Minimum number of top-level cancers with a mortality_count observation for a scope to exist. */
15+ minCancersPerScope: 10,
16+ /** Deaths above which a ranking row gets HIGH confidence (else MEDIUM). */
17+ highConfidenceDeaths: 1000,
18+} as const;
19+
20+export type ResearchGapThresholds = { minDeaths: number; minActivity: number };
21+
5 export interface ResearchGapResult { 22 export interface ResearchGapResult {
6 rows: number; 23 rows: number;
7 scopes: number; 24 scopes: number;
8 } 25 }
9 26
27+/** Raw inputs for one cancer in one burden scope. */
28+export interface ComponentInput {
29+ id: string;
30+ deaths: number | null;
31+ activeTrials: number;
32+ publications5y: number;
33+}
34+
35+/** Eligibility verdict for one cancer (pure). */
36+export interface Eligibility {
37+ eligible: boolean;
38+ reason: string | null;
39+ /** Whether the trial-based ratio can be computed (eligible AND active_trials ≥ minActivity). */
40+ trialRatio: boolean;
41+ publicationRatio: boolean;
42+}
43+
44+/** Computed shares and ratios for one cancer (pure; nulls where undefined). */
45+export interface ComponentShares extends Eligibility {
46+ id: string;
47+ deathShare: number | null;
48+ trialShare: number | null;
49+ publicationShare: number | null;
50+ trialGapRatio: number | null;
51+ researchGapRatio: number | null;
52+ trialsPer1000Deaths: number | null;
53+ publicationsPer1000Deaths: number | null;
54+}
55+
56+export interface SharesResult {
57+ rows: ComponentShares[];
58+ /** Sums over the eligible set (deaths ≥ minDeaths). */
59+ sums: { deaths: number; activeTrials: number; publications5y: number; eligible: number };
60+}
61+
62+const round = (v: number, digits = 6): number => {
63+ const f = 10 ** digits;
64+ return Math.round(v * f) / f;
65+};
66+
10 /** 67 /**
11− * STUB — implemented by the Research Gap Index work package. 68+ * Eligibility rule (metric_definitions eligibility: deaths ≥ minDeaths; each ratio also needs
12− * Recomputes `research_gap_components` per (cancer, geography, year, sex, burden source) and the 69+ * activity ≥ minActivity so that log2 is defined). A cancer with enough deaths but no registered
13− * burden-normalized ranking snapshots (trials_per_1000_deaths, publications_per_1000_deaths, 70+ * trials still belongs to the eligible set (it contributes 0 to Σ trials) — only its ratio is null.
14− * trial_gap_ratio, research_gap_ratio).
15 */ 71 */
16−export async function computeResearchGap(_db: Database): Promise<ResearchGapResult> { 72+export function eligibility(input: Pick<ComponentInput, 'deaths' | 'activeTrials' | 'publications5y'>, t: ResearchGapThresholds = RESEARCH_GAP_THRESHOLDS): Eligibility {
17− return { rows: 0, scopes: 0 }; 73+ if (input.deaths == null || !Number.isFinite(input.deaths)) return { eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false };
74+ if (input.deaths < t.minDeaths) return { eligible: false, reason: `deaths_below_threshold (${input.deaths} < ${t.minDeaths})`, trialRatio: false, publicationRatio: false };
75+ return { eligible: true, reason: null, trialRatio: input.activeTrials >= t.minActivity, publicationRatio: input.publications5y >= t.minActivity };
76+}
77+
78+/** log2(a / b), or null when either side is not a positive finite number. */
79+export function log2Ratio(a: number | null | undefined, b: number | null | undefined): number | null {
80+ if (a == null || b == null || !Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return null;
81+ return round(Math.log2(a / b));
82+}
83+
84+/** activity per 1,000 deaths, or null when deaths are not positive. */
85+export function per1000Deaths(activity: number, deaths: number | null | undefined): number | null {
86+ if (deaths == null || !Number.isFinite(deaths) || deaths <= 0 || !Number.isFinite(activity)) return null;
87+ return round(activity / (deaths / 1000), 4);
88+}
89+
90+/**
91+ * Shares and log-ratios over the eligible set of one scope (pure, deterministic).
92+ * death_share = deaths / Σ deaths; trial_share = active_trials / Σ active_trials;
93+ * publication_share = publications_5y / Σ publications_5y — all sums over eligible cancers only.
94+ * Shares are rounded to 6 decimals; per-1,000 values to 4.
95+ */
96+export function computeShares(inputs: ComponentInput[], t: ResearchGapThresholds = RESEARCH_GAP_THRESHOLDS): SharesResult {
97+ const verdicts = new Map(inputs.map((i) => [i.id, eligibility(i, t)]));
98+ const eligible = inputs.filter((i) => verdicts.get(i.id)!.eligible);
99+ const sums = {
100+ deaths: eligible.reduce((s, i) => s + (i.deaths ?? 0), 0),
101+ activeTrials: eligible.reduce((s, i) => s + i.activeTrials, 0),
102+ publications5y: eligible.reduce((s, i) => s + i.publications5y, 0),
103+ eligible: eligible.length,
104+ };
105+ const share = (v: number, total: number): number | null => (total > 0 ? round(v / total) : null);
106+ const rows: ComponentShares[] = inputs.map((i) => {
107+ const e = verdicts.get(i.id)!;
108+ if (!e.eligible) {
109+ return { id: i.id, ...e, deathShare: null, trialShare: null, publicationShare: null, trialGapRatio: null, researchGapRatio: null, trialsPer1000Deaths: per1000Deaths(i.activeTrials, i.deaths), publicationsPer1000Deaths: per1000Deaths(i.publications5y, i.deaths) };
110+ }
111+ const deathShare = share(i.deaths!, sums.deaths);
112+ const trialShare = share(i.activeTrials, sums.activeTrials);
113+ const publicationShare = share(i.publications5y, sums.publications5y);
114+ return {
115+ id: i.id,
116+ ...e,
117+ deathShare,
118+ trialShare,
119+ publicationShare,
120+ // Ratios use unrounded shares (equivalently deaths·Σtrials / (trials·Σdeaths)) so rounding of shares never leaks into the index.
121+ trialGapRatio: e.trialRatio ? log2Ratio(i.deaths! / sums.deaths, i.activeTrials / sums.activeTrials) : null,
122+ researchGapRatio: e.publicationRatio ? log2Ratio(i.deaths! / sums.deaths, i.publications5y / sums.publications5y) : null,
123+ trialsPer1000Deaths: per1000Deaths(i.activeTrials, i.deaths),
124+ publicationsPer1000Deaths: per1000Deaths(i.publications5y, i.deaths),
125+ };
126+ });
127+ return { rows, sums };
128+}
129+
130+type ScopeRow = {
131+ geography_id: string;
132+ slug: string;
133+ iso3: string | null;
134+ year: number;
135+ sex: string;
136+ source_id: string;
137+ n: number;
138+};
139+
140+type ObsRow = {
141+ cancer_id: string;
142+ metric: string;
143+ value: number;
144+ id: number;
145+ estimate_type: string;
146+ site_definition: string | null;
147+};
148+
149+type CounterRow = {
150+ entity_id: string;
151+ active_trial_count: number;
152+ phase3_trial_count: number;
153+ publication_count_5y: number;
154+ approved_drug_count: number;
155+ updated_at: Date | string;
156+};
157+
158+type LitRow = {
159+ cancer_id: string;
160+ count: number;
161+ id: number;
162+ query: string;
163+ updated_at: Date | string;
164+};
165+
166+/**
167+ * Research Gap Index (SPEC §34, §113): burden vs research activity per top-level cancer and burden
168+ * scope. Scope discovery follows the ranking engine (every geography/year/sex/source with ≥ 10
169+ * top-level cancers carrying a `mortality_count` observation, age group "all"). For each scope the
170+ * component rows are rebuilt in one transaction, then four ranking snapshots are persisted through
171+ * the shared engine so `/rankings/<slug>` pages, "Why this rank?" and TRACE work unchanged.
172+ */
173+export async function computeResearchGap(db: Database): Promise<ResearchGapResult> {
174+ const t = RESEARCH_GAP_THRESHOLDS;
175+ const defs = await db.select().from(metricDefinitions);
176+ const byslug = new Map(defs.map((d) => [d.slug, d]));
177+ const activitySources = await db.execute<{ id: string; slug: string }>(sql`SELECT id, slug FROM sources WHERE slug IN ('clinicaltrials', 'pubmed')`);
178+ const srcId = (slug: string) => activitySources.find((s) => s.slug === slug)?.id ?? slug;
179+ const trialsSourceId = srcId('clinicaltrials');
180+ const pubmedSourceId = srcId('pubmed');
181+
182+ const [counters, lit] = await Promise.all([
183+ db.execute<CounterRow>(sql`SELECT entity_id, active_trial_count, phase3_trial_count, publication_count_5y, approved_drug_count, updated_at FROM entity_counters WHERE entity_type = 'cancer'`),
184+ db.execute<LitRow>(sql`SELECT cancer_id, count, id, query, updated_at FROM literature_counts WHERE window_key = '5y'`),
185+ ]);
186+ const cmap = new Map(counters.map((c) => [c.entity_id, c]));
187+ const lmap = new Map(lit.map((l) => [l.cancer_id, l]));
188+
189+ const scopes = await db.execute<ScopeRow>(sql`
190+ SELECT o.geography_id, g.slug, g.iso3, o.year, o.sex, o.source_id, count(DISTINCT o.cancer_id) AS n
191+ FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id JOIN cancers c ON c.id = o.cancer_id
192+ WHERE c.top_level AND c.status = 'active' AND o.age_group = 'all' AND o.metric = 'mortality_count'
193+ GROUP BY o.geography_id, g.slug, g.iso3, o.year, o.sex, o.source_id HAVING count(DISTINCT o.cancer_id) >= ${t.minCancersPerScope}
194+ ORDER BY o.year, o.sex, o.source_id`);
195+
196+ // Ranking snapshots are keyed by (geography, sex, age, year, level) without the burden source, so
197+ // two sources for the same population would overwrite each other's "current" snapshot. Components
198+ // are stored for every source; snapshots only for the preferred one per scope key (most cancers
199+ // with a mortality observation, ties broken by source id) — the same source the web page defaults to.
200+ const preferred = new Map<string, ScopeRow>();
201+ for (const s of scopes) {
202+ const key = `${s.iso3 ?? s.slug.toUpperCase()}|${s.year}|${s.sex}`;
203+ const cur = preferred.get(key);
204+ if (!cur || Number(s.n) > Number(cur.n) || (Number(s.n) === Number(cur.n) && s.source_id < cur.source_id)) preferred.set(key, s);
205+ }
206+
207+ let rows = 0;
208+ let nScopes = 0;
209+ for (const s of scopes) {
210+ const geography = s.iso3 ?? s.slug.toUpperCase();
211+ const year = Number(s.year);
212+ const obs = await db.execute<ObsRow>(sql`
213+ SELECT o.cancer_id, o.metric, o.value, o.id, o.estimate_type, o.site_definition
214+ FROM epidemiology_observations o JOIN cancers c ON c.id = o.cancer_id
215+ WHERE c.top_level AND c.status = 'active' AND o.geography_id = ${s.geography_id} AND o.year = ${year} AND o.sex = ${s.sex} AND o.age_group = 'all'
216+ AND o.source_id = ${s.source_id} AND o.metric IN ('mortality_count', 'incidence_count')
217+ ORDER BY o.id`);
218+ // First observation per (cancer, metric) is used; any duplicates (other site definitions) are kept in inputs.
219+ const mort = new Map<string, ObsRow & { otherIds: number[] }>();
220+ const inc = new Map<string, ObsRow & { otherIds: number[] }>();
221+ for (const o of obs) {
222+ const m = o.metric === 'mortality_count' ? mort : inc;
223+ const cur = m.get(o.cancer_id);
224+ if (cur) cur.otherIds.push(o.id);
225+ else m.set(o.cancer_id, { ...o, value: Number(o.value), otherIds: [] });
226+ }
227+ if (mort.size < t.minCancersPerScope) continue;
228+
229+ const inputs: ComponentInput[] = [...mort.keys()].sort().map((id) => {
230+ const c = cmap.get(id);
231+ const l = lmap.get(id);
232+ const fromCounter = Number(c?.publication_count_5y ?? 0);
233+ return { id, deaths: mort.get(id)!.value, activeTrials: Number(c?.active_trial_count ?? 0), publications5y: fromCounter > 0 ? fromCounter : Number(l?.count ?? 0) };
234+ });
235+ const { rows: shares, sums } = computeShares(inputs, t);
236+ const byId = new Map(shares.map((r) => [r.id, r]));
237+
238+ const values = inputs.map((i) => {
239+ const r = byId.get(i.id)!;
240+ const m = mort.get(i.id)!;
241+ const ic = inc.get(i.id);
242+ const c = cmap.get(i.id);
243+ const l = lmap.get(i.id);
244+ const pubsFrom = Number(c?.publication_count_5y ?? 0) > 0 ? 'entity_counters.publication_count_5y' : l ? 'literature_counts[5y]' : 'none';
245+ return {
246+ cancerId: i.id,
247+ geography,
248+ year,
249+ sex: s.sex,
250+ burdenSourceId: s.source_id,
251+ deaths: m.value,
252+ incidence: ic ? ic.value : null,
253+ activeTrials: i.activeTrials,
254+ phase3Trials: Number(c?.phase3_trial_count ?? 0),
255+ publications5y: i.publications5y,
256+ approvedDrugs: Number(c?.approved_drug_count ?? 0),
257+ deathShare: r.deathShare,
258+ trialShare: r.trialShare,
259+ publicationShare: r.publicationShare,
260+ trialGapRatio: r.trialGapRatio,
261+ researchGapRatio: r.researchGapRatio,
262+ trialsPer1000Deaths: r.trialsPer1000Deaths,
263+ publicationsPer1000Deaths: r.publicationsPer1000Deaths,
264+ eligible: r.eligible,
265+ ineligibleReason: r.reason,
266+ formulaVersion: RESEARCH_GAP_FORMULA_VERSION,
267+ inputs: {
268+ mortalityObservationId: m.id,
269+ mortalityOtherObservationIds: m.otherIds,
270+ mortalityEstimateType: m.estimate_type,
271+ mortalitySiteDefinition: m.site_definition,
272+ incidenceObservationId: ic?.id ?? null,
273+ countersComputedAt: c?.updated_at ?? null,
274+ publicationsSource: pubsFrom,
275+ literatureCountId: l?.id ?? null,
276+ literatureQuery: pubsFrom === 'literature_counts[5y]' ? l?.query : undefined,
277+ thresholds: { minDeaths: t.minDeaths, minActivity: t.minActivity },
278+ ratioEligibility: { trialGapRatio: r.trialRatio, researchGapRatio: r.publicationRatio },
279+ sums,
280+ activitySources: { activeTrials: trialsSourceId, publications5y: pubmedSourceId },
281+ } as Record<string, unknown>,
282+ };
283+ });
284+
285+ await db.transaction(async (tx) => {
286+ await tx.delete(researchGapComponents).where(and(eq(researchGapComponents.geography, geography), eq(researchGapComponents.year, year), eq(researchGapComponents.sex, s.sex), eq(researchGapComponents.burdenSourceId, s.source_id)));
287+ for (let i = 0; i < values.length; i += 200) await tx.insert(researchGapComponents).values(values.slice(i, i + 200));
288+ });
289+ rows += values.length;
290+ nScopes += 1;
291+
292+ // Ranking snapshots (same scope shape as the engine so /rankings/<slug>?scope=… resolves).
293+ if (preferred.get(`${geography}|${s.year}|${s.sex}`)?.source_id !== s.source_id) continue;
294+ const scope: Scope = { geography, sex: s.sex as Scope['sex'], ageGroup: 'all', year, entityLevel: 'top' };
295+ const confidence = (deaths: number) => (deaths >= t.highConfidenceDeaths ? 'HIGH' : 'MEDIUM') as 'HIGH' | 'MEDIUM';
296+ const base = (v: (typeof values)[number]) => ({
297+ deaths: v.deaths,
298+ mortalityObservationId: (v.inputs as { mortalityObservationId: number }).mortalityObservationId,
299+ burdenSourceId: s.source_id,
300+ countersComputedAt: (v.inputs as { countersComputedAt: unknown }).countersComputedAt,
301+ componentsFormulaVersion: RESEARCH_GAP_FORMULA_VERSION,
302+ thresholds: { minDeaths: t.minDeaths, minActivity: t.minActivity },
303+ });
304+ const eligibleRows = values.filter((v) => v.eligible);
305+ const metricItems: Array<[string, RankInput[], string[]]> = [
306+ ['trials_per_1000_deaths', eligibleRows.filter((v) => v.trialsPer1000Deaths != null).map((v) => ({ id: v.cancerId, value: v.trialsPer1000Deaths!, confidence: confidence(v.deaths), inputs: { ...base(v), activeTrials: v.activeTrials, formula: byslug.get('trials_per_1000_deaths')?.formula } })), [s.source_id, trialsSourceId]],
307+ ['publications_per_1000_deaths', eligibleRows.filter((v) => v.publicationsPer1000Deaths != null).map((v) => ({ id: v.cancerId, value: v.publicationsPer1000Deaths!, confidence: confidence(v.deaths), inputs: { ...base(v), publications5y: v.publications5y, formula: byslug.get('publications_per_1000_deaths')?.formula } })), [s.source_id, pubmedSourceId]],
308+ ['trial_gap_ratio', eligibleRows.filter((v) => v.trialGapRatio != null).map((v) => ({ id: v.cancerId, value: v.trialGapRatio!, confidence: confidence(v.deaths), inputs: { ...base(v), activeTrials: v.activeTrials, deathShare: v.deathShare, trialShare: v.trialShare, sumDeaths: sums.deaths, sumActiveTrials: sums.activeTrials, eligibleEntities: sums.eligible, formula: byslug.get('trial_gap_ratio')?.formula } })), [s.source_id, trialsSourceId]],
309+ ['research_gap_ratio', eligibleRows.filter((v) => v.researchGapRatio != null).map((v) => ({ id: v.cancerId, value: v.researchGapRatio!, confidence: confidence(v.deaths), inputs: { ...base(v), publications5y: v.publications5y, deathShare: v.deathShare, publicationShare: v.publicationShare, sumDeaths: sums.deaths, sumPublications5y: sums.publications5y, eligibleEntities: sums.eligible, formula: byslug.get('research_gap_ratio')?.formula } })), [s.source_id, pubmedSourceId]],
310+ ];
311+ for (const [slug, items, sourceIds] of metricItems) {
312+ const def = byslug.get(slug);
313+ if (!def || items.length < 3) continue;
314+ await persistSnapshot(db, def, scope, items, { descending: true, sourceIds });
315+ }
316+ }
317+ return { rows, scopes: nScopes };
18 } 318 }
added packages/ranking/test/research-gap.test.ts +175 −0
@@ -0,0 +1,175 @@
1+import { describe, expect, it } from 'vitest';
2+import { computeShares, eligibility, log2Ratio, per1000Deaths, RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '../src/research-gap.js';
3+
4+const T = { minDeaths: 100, minActivity: 1 };
5+
6+describe('eligibility', () => {
7+ it('rejects missing or small burden with a stored reason', () => {
8+ expect(eligibility({ deaths: null, activeTrials: 5, publications5y: 5 }, T)).toEqual({ eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false });
9+ expect(eligibility({ deaths: NaN, activeTrials: 5, publications5y: 5 }, T).reason).toBe('no_mortality_observation');
10+ const small = eligibility({ deaths: 99, activeTrials: 5, publications5y: 5 }, T);
11+ expect(small.eligible).toBe(false);
12+ expect(small.reason).toMatch(/deaths_below_threshold \(99 < 100\)/);
13+ });
14+ it('accepts deaths at the threshold and gates each ratio on its own activity', () => {
15+ expect(eligibility({ deaths: 100, activeTrials: 1, publications5y: 1 }, T)).toEqual({ eligible: true, reason: null, trialRatio: true, publicationRatio: true });
16+ const noTrials = eligibility({ deaths: 5000, activeTrials: 0, publications5y: 12 }, T);
17+ expect(noTrials).toEqual({ eligible: true, reason: null, trialRatio: false, publicationRatio: true });
18+ });
19+ it('uses the seeded thresholds by default', () => {
20+ expect(RESEARCH_GAP_THRESHOLDS.minDeaths).toBe(100);
21+ expect(RESEARCH_GAP_THRESHOLDS.minActivity).toBe(1);
22+ expect(eligibility({ deaths: 99, activeTrials: 1, publications5y: 1 }).eligible).toBe(false);
23+ expect(RESEARCH_GAP_FORMULA_VERSION).toBe('ci-research-gap-components-v1');
24+ });
25+});
26+
27+describe('log2Ratio', () => {
28+ it('is 0 when shares match, ±1 when they differ by a factor of two', () => {
29+ expect(log2Ratio(0.25, 0.25)).toBe(0);
30+ expect(log2Ratio(0.5, 0.25)).toBe(1);
31+ expect(log2Ratio(0.25, 0.5)).toBe(-1);
32+ expect(log2Ratio(0.3, 0.1)).toBeCloseTo(Math.log2(3), 6);
33+ });
34+ it('is undefined (null) for zero, negative, null or non-finite operands', () => {
35+ expect(log2Ratio(0.2, 0)).toBeNull();
36+ expect(log2Ratio(0, 0.2)).toBeNull();
37+ expect(log2Ratio(-1, 0.2)).toBeNull();
38+ expect(log2Ratio(null, 0.2)).toBeNull();
39+ expect(log2Ratio(0.2, undefined)).toBeNull();
40+ expect(log2Ratio(Infinity, 0.2)).toBeNull();
41+ expect(log2Ratio(0.2, NaN)).toBeNull();
42+ });
43+ it('rounds to 6 decimals', () => {
44+ const v = log2Ratio(1 / 3, 1 / 7)!;
45+ expect(v).toBe(Math.round(Math.log2(7 / 3) * 1e6) / 1e6);
46+ });
47+});
48+
49+describe('per1000Deaths', () => {
50+ it('divides activity by thousands of deaths', () => {
51+ expect(per1000Deaths(50, 1000)).toBe(50);
52+ expect(per1000Deaths(2622, 125000)).toBeCloseTo(20.976, 3);
53+ expect(per1000Deaths(0, 1000)).toBe(0);
54+ });
55+ it('is null without positive deaths', () => {
56+ expect(per1000Deaths(5, 0)).toBeNull();
57+ expect(per1000Deaths(5, null)).toBeNull();
58+ expect(per1000Deaths(5, -3)).toBeNull();
59+ expect(per1000Deaths(NaN, 100)).toBeNull();
60+ });
61+});
62+
63+describe('computeShares', () => {
64+ const inputs = [
65+ { id: 'lung', deaths: 60_000, activeTrials: 300, publications5y: 9_000 },
66+ { id: 'breast', deaths: 30_000, activeTrials: 300, publications5y: 9_000 },
67+ { id: 'rare', deaths: 10_000, activeTrials: 200, publications5y: 2_000 },
68+ { id: 'tiny', deaths: 50, activeTrials: 400, publications5y: 100 }, // below minDeaths → excluded from every sum
69+ ];
70+
71+ it('computes shares over the eligible set only and they sum to 1', () => {
72+ const { rows, sums } = computeShares(inputs, T);
73+ expect(sums).toEqual({ deaths: 100_000, activeTrials: 800, publications5y: 20_000, eligible: 3 });
74+ const byId = new Map(rows.map((r) => [r.id, r]));
75+ expect(byId.get('lung')!.deathShare).toBe(0.6);
76+ expect(byId.get('breast')!.deathShare).toBe(0.3);
77+ expect(byId.get('rare')!.deathShare).toBe(0.1);
78+ expect(byId.get('lung')!.trialShare).toBe(0.375);
79+ expect(byId.get('rare')!.trialShare).toBe(0.25);
80+ expect(byId.get('lung')!.publicationShare).toBe(0.45);
81+ const eligible = rows.filter((r) => r.eligible);
82+ for (const key of ['deathShare', 'trialShare', 'publicationShare'] as const) {
83+ const sum = eligible.reduce((s, r) => s + (r[key] ?? 0), 0);
84+ expect(sum).toBeCloseTo(1, 9);
85+ }
86+ });
87+
88+ it('derives the log2 ratios from the shares and keeps the ineligible row with nulls', () => {
89+ const { rows } = computeShares(inputs, T);
90+ const byId = new Map(rows.map((r) => [r.id, r]));
91+ expect(byId.get('lung')!.trialGapRatio).toBeCloseTo(Math.log2(0.6 / 0.375), 6); // +0.678: more deaths than trials would suggest
92+ expect(byId.get('rare')!.trialGapRatio).toBeCloseTo(Math.log2(0.1 / 0.25), 6); // −1.32: comparatively well trialled
93+ expect(byId.get('rare')!.researchGapRatio).toBe(0); // 10% of deaths, 10% of publications
94+ expect(byId.get('lung')!.trialsPer1000Deaths).toBe(5);
95+ expect(byId.get('lung')!.publicationsPer1000Deaths).toBe(150);
96+ const tiny = byId.get('tiny')!;
97+ expect(tiny.eligible).toBe(false);
98+ expect(tiny.reason).toMatch(/deaths_below_threshold/);
99+ expect(tiny.deathShare).toBeNull();
100+ expect(tiny.trialShare).toBeNull();
101+ expect(tiny.trialGapRatio).toBeNull();
102+ expect(tiny.researchGapRatio).toBeNull();
103+ // Per-1,000 values are still informative for an ineligible row (no cross-entity denominator involved).
104+ expect(tiny.trialsPer1000Deaths).toBe(8000);
105+ });
106+
107+ it('handles zero activity: share 0, ratio null, others unaffected', () => {
108+ const { rows, sums } = computeShares(
109+ [
110+ { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 10 },
111+ { id: 'b', deaths: 1000, activeTrials: 10, publications5y: 10 },
112+ ],
113+ T,
114+ );
115+ expect(sums.activeTrials).toBe(10);
116+ const a = rows.find((r) => r.id === 'a')!;
117+ const b = rows.find((r) => r.id === 'b')!;
118+ expect(a.eligible).toBe(true);
119+ expect(a.trialRatio).toBe(false);
120+ expect(a.trialShare).toBe(0);
121+ expect(a.trialGapRatio).toBeNull();
122+ expect(a.researchGapRatio).toBe(0);
123+ expect(a.trialsPer1000Deaths).toBe(0);
124+ expect(b.trialShare).toBe(1);
125+ expect(b.trialGapRatio).toBe(-1); // 50% of deaths, 100% of trials
126+ });
127+
128+ it('single eligible entity: every share is 1 and every ratio 0', () => {
129+ const { rows } = computeShares([{ id: 'only', deaths: 500, activeTrials: 3, publications5y: 7 }], T);
130+ expect(rows[0]).toMatchObject({ deathShare: 1, trialShare: 1, publicationShare: 1, trialGapRatio: 0, researchGapRatio: 0 });
131+ });
132+
133+ it('no eligible entity: sums are zero and shares null (no division by zero)', () => {
134+ const { rows, sums } = computeShares([{ id: 'x', deaths: 10, activeTrials: 3, publications5y: 7 }], T);
135+ expect(sums).toEqual({ deaths: 0, activeTrials: 0, publications5y: 0, eligible: 0 });
136+ expect(rows[0]!.deathShare).toBeNull();
137+ });
138+
139+ it('all activity zero in the scope: shares null, ratios null, nothing NaN', () => {
140+ const { rows } = computeShares(
141+ [
142+ { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 0 },
143+ { id: 'b', deaths: 3000, activeTrials: 0, publications5y: 0 },
144+ ],
145+ T,
146+ );
147+ for (const r of rows) {
148+ expect(r.deathShare).not.toBeNull();
149+ expect(r.trialShare).toBeNull();
150+ expect(r.publicationShare).toBeNull();
151+ expect(r.trialGapRatio).toBeNull();
152+ expect(r.researchGapRatio).toBeNull();
153+ for (const v of Object.values(r)) if (typeof v === 'number') expect(Number.isNaN(v)).toBe(false);
154+ }
155+ });
156+
157+ it('rounds shares to 6 decimals and is order-independent', () => {
158+ const a = computeShares(inputs, T);
159+ const b = computeShares([...inputs].reverse(), T);
160+ const sortById = (r: { id: string }[]) => [...r].sort((x, y) => x.id.localeCompare(y.id));
161+ expect(sortById(a.rows)).toEqual(sortById(b.rows));
162+ const { rows } = computeShares(
163+ [
164+ { id: 'a', deaths: 1000, activeTrials: 1, publications5y: 1 },
165+ { id: 'b', deaths: 2000, activeTrials: 1, publications5y: 1 },
166+ { id: 'c', deaths: 4000, activeTrials: 1, publications5y: 1 },
167+ ],
168+ T,
169+ );
170+ expect(rows.find((r) => r.id === 'a')!.deathShare).toBe(0.142857); // 1/7 rounded
171+ expect(rows.find((r) => r.id === 'a')!.trialShare).toBe(0.333333);
172+ // Ratio is computed from unrounded shares: log2((1/7)/(1/3)) = log2(3/7)
173+ expect(rows.find((r) => r.id === 'a')!.trialGapRatio).toBeCloseTo(Math.log2(3 / 7), 6);
174+ });
175+});
176
Menu

Theme