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>
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. “colorectal cancer”) 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’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 | ||