SPB Git forge

spb/cancerindex

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

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 13 days ago (Sep 11, 2026) parent 5379f0a

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 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 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`.
6 − * Filled by the Research Gap work package.
37 + * publications, shares, log-ratios, per-1,000 values) per top-level cancer for one burden scope;
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) => {
9 − /* routes added by the research-gap work package */
42 +export const researchGapRoutes: FastifyPluginAsyncZod = async (app) => {
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 3 import { EmptyState } from '@/components/ui/empty-state';
4 4 import { Freshness } from '@/components/ui/freshness';
5 5 import { ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';
6 −import { gapRankings } from '@/lib/queries/rankings';
7 −import { fmtDate, fmtInt, fmtValue, scopeLabel } from '@/lib/format';
6 +import { ratioGapRankings } from '@/lib/queries/research-gap';
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
11 − * ranking snapshots for a geography. Rendered only when snapshots exist; each row exposes the two percentiles
12 − * that produced the value (lineage stored on the ranking row).
10 + * Home module "Largest trial and research gaps" (§105, §328, §265-266): the ratio-based gap indexes
11 + * (Trial Gap Ratio, Research Gap Ratio) from the current ranking snapshots of the latest burden scope
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 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 18 return (
17 19 <Section
18 20 id="gaps"
19 21 kicker="Unmet need"
20 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 24 actions={
23 − <Link href="/rankings" className="ci-link">
24 − All rankings →
25 − </Link>
25 + <>
26 + <Link href="/research-gap" className="ci-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 35 {gaps.length === 0 ? (
29 − <EmptyState title="Gap indexes not yet computed">
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.
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' }]}>
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 38 </EmptyState>
32 39 ) : (
33 40 <div className="grid gap-6 lg:grid-cols-2">
34 − {gaps.map(({ metric, snapshot, rows }) => (
35 − <div key={metric.slug} className="min-w-0">
36 − <p className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2">
37 − <Link href={`/rankings/${metric.slug}?scope=${encodeURIComponent(snapshot.scope_key)}`} className="ci-link font-medium">
38 − {metric.name}
39 − </Link>
40 − <span className="text-[11.5px] text-ink-3">{scopeLabel(snapshot.scope_key)}</span>
41 − </p>
42 − <div className="ci-table-wrap">
43 − <table className="ci-table">
44 − <thead>
45 − <tr>
46 − <th className="num">#</th>
47 − <th>Cancer</th>
48 − <th className="num">Gap (pct. points)</th>
49 − <th className="num">Deaths</th>
50 − <th className="num">{metric.slug === 'trial_gap' ? 'Active trials' : 'Publications 5y'}</th>
51 − <th>Confidence</th>
52 − </tr>
53 − </thead>
54 − <tbody>
55 − {rows.map((r) => (
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>
41 + {gaps.map(({ metric, snapshot, rows }) => {
42 + const isTrial = metric.slug === 'trial_gap_ratio';
43 + return (
44 + <div key={metric.slug} className="min-w-0">
45 + <p className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2">
46 + <Link href={`/rankings/${metric.slug}?scope=${encodeURIComponent(snapshot.scope_key)}`} className="ci-link font-medium">
47 + {metric.name}
48 + </Link>
49 + <span className="text-[11.5px] text-ink-3">{scopeLabel(snapshot.scope_key)}</span>
50 + </p>
51 + <div className="ci-table-wrap">
52 + <table className="ci-table">
53 + <thead>
54 + <tr>
55 + <th className="num">#</th>
56 + <th>Cancer</th>
57 + <th className="num">Ratio (log₂)</th>
58 + <th className="num">Deaths</th>
59 + <th className="num">Death share</th>
60 + <th className="num">{isTrial ? 'Active trials' : 'Publications 5y'}</th>
61 + <th className="num">{isTrial ? 'Trial share' : 'Pub. share'}</th>
62 + <th>Confidence</th>
69 63 </tr>
70 − ))}
71 − </tbody>
72 − </table>
64 + </thead>
65 + <tbody>
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 103 </div>
74 − <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
75 − <ClaimBadge kind="computed" />
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 − ))}
104 + );
105 + })}
91 106 </div>
92 107 )}
93 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 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 22 export interface ResearchGapResult {
6 23 rows: number;
7 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.
12 − * Recomputes `research_gap_components` per (cancer, geography, year, sex, burden source) and the
13 − * burden-normalized ranking snapshots (trials_per_1000_deaths, publications_per_1000_deaths,
14 − * trial_gap_ratio, research_gap_ratio).
68 + * Eligibility rule (metric_definitions eligibility: deaths ≥ minDeaths; each ratio also needs
69 + * activity ≥ minActivity so that log2 is defined). A cancer with enough deaths but no registered
70 + * trials still belongs to the eligible set (it contributes 0 to Σ trials) — only its ratio is null.
15 71 */
16 −export async function computeResearchGap(_db: Database): Promise<ResearchGapResult> {
17 − return { rows: 0, scopes: 0 };
72 +export function eligibility(input: Pick<ComponentInput, 'deaths' | 'activeTrials' | 'publications5y'>, t: ResearchGapThresholds = RESEARCH_GAP_THRESHOLDS): Eligibility {
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