SPB Git forge

spb/cancerindex

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

Integration: home modules (explorer, trial intelligence, map, graph), graph links on entity pages, gap card on cancer rankings tab, methodology sections, API docs, QA routes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent db903de

10 changed files +144 −48

modified apps/web/qa/smoke.mjs +19 −2
@@ -23,7 +23,7 @@ const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 30000);
23 23 const MOBILE_WIDTH = 390;
24 24 const ONLY_HTTP = process.env.HTTP_ONLY === '1';
25 25
26 −/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */
26 +/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, maxKb?: number, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */
27 27 const ROUTES = [
28 28 { path: '/', expect: ['CancerIndex'] },
29 29 { path: '/cancers', expect: ['Cancers'] },
@@ -38,6 +38,23 @@ const ROUTES = [
38 38 { path: '/drug/osimertinib', expect: ['Osimertinib', 'Approvals'], resolve: (ctx) => ctx.drug ?? '/drug/osimertinib' },
39 39 { path: '/trial/<first NCT>', expect: ['NCT', 'Conditions'], resolve: (ctx) => ctx.trial ?? null },
40 40 { path: '/rankings', expect: ['Rankings', 'metric'] },
41 + { path: '/rankings/trial_gap_ratio', expect: ['Trial Gap Ratio', 'rank'] },
42 + { path: '/explore', expect: ['Data explorer', 'CSV'], mustHaveData: true },
43 + { path: '/explore/coverage', expect: ['Coverage'] },
44 + { path: '/trials/intelligence', expect: ['intelligence', 'HHI'], mustHaveData: true, maxKb: 900 },
45 + { path: '/trials/terminated', expect: ['NCT'] , maxKb: 900 },
46 + { path: '/trials/map', expect: ['Trial map', 'ISO'], maxKb: 1200 },
47 + { path: '/research-gap', expect: ['Research Gap', 'log'], mustHaveData: true, maxKb: 900 },
48 + { path: '/graph', expect: ['graph', 'CI-CAN-'], maxKb: 900 },
49 + { path: '/graph?focus=gene:KRAS', expect: ['KRAS'], maxKb: 900 },
50 + { path: '/approvals', expect: ['approvals', 'FDA'], maxKb: 900 },
51 + { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 },
52 + { path: '/methodology/trial-map', expect: ['ISO'] },
53 + { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true },
54 + { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true },
55 + { path: '/api/v1/epidemiology/metrics', expect: ['data'], kind: 'json', optionalLocal: true },
56 + { path: '/api/v1/approvals/recent?days=365&limit=5', expect: ['data'], kind: 'json', optionalLocal: true },
57 + { path: '/api/v1/graph/gene/TP53?limit=5', expect: ['data'], kind: 'json', optionalLocal: true },
41 58 { path: '/rankings/mortality_count', expect: ['mortality', 'rank'] },
42 59 { path: '/taxonomy', expect: ['Taxonomy'] },
43 60 { path: '/sources', expect: ['Sources', 'license'] },
@@ -153,7 +170,7 @@ async function main() {
153 170 const lower = res.text.toLowerCase();
154 171 for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`);
155 172 if (r.kind !== 'json' && r.kind !== 'xml') {
156 − if (kb > MAX_KB) problems.push(`weight ${kb} KB > ${MAX_KB} KB`);
173 + if (kb > (r.maxKb ?? MAX_KB)) problems.push(`weight ${kb} KB > ${r.maxKb ?? MAX_KB} KB`);
157 174 if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data');
158 175 if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered');
159 176 }
modified apps/web/src/app/gene/[symbol]/page.tsx +2 −0
@@ -14,6 +14,7 @@ import { evidenceForGene, evidenceForGeneCount, evidenceCancersForGene, EVIDENCE
14 14 import { loadProvenance } from '@/lib/queries/provenance';
15 15 import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';
16 16 import { PublicationList } from '@/components/data/publication-list';
17 +import { GraphLink } from '@/components/graph/graph-link';
17 18 import { fmtInt, humanize } from '@/lib/format';
18 19 import { pageInfo } from '@/lib/pagination';
19 20 import { int, withParams, type SP } from '@/lib/search-params';
@@ -58,6 +59,7 @@ export default async function GenePage({ params, searchParams }: { params: Promi
58 59 <article>
59 60 <PageHeader kicker="Gene" title={<span className="ci-mono font-sans">{g.symbol}</span>} lede={g.name ?? undefined}>
60 61 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">
62 + <GraphLink type="gene" ref={g.symbol} />
61 63 <span className="ci-mono text-ink-3">{g.id}</span>
62 64 {g.hgnc_id ? (
63 65 <a className="ci-link inline-flex items-center gap-1" href={`https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/${g.hgnc_id}`} target="_blank" rel="noopener noreferrer">
modified apps/web/src/app/methodology/page.tsx +66 −1
@@ -20,7 +20,7 @@ export default async function MethodologyPage() {
20 20 <PageHeader kicker="Methodology" title="How the index is built" lede="CancerIndex separates layers — raw, normalized, canonical, derived, ranked — and keeps them separable. This page documents the rules applied at each step and lists every metric with its exact formula and version." />
21 21
22 22 <nav aria-label="On this page" className="flex flex-wrap gap-x-4 gap-y-1 border-y border-rule py-2 text-[13px]">
23 − {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'not-computed', 'limitations'].map((id) => (
23 + {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'research-gap', 'trial-intelligence', 'trial-map', 'pipeline', 'knowledge-graph', 'data-explorer', 'not-computed', 'limitations'].map((id) => (
24 24 <a key={id} href={`#${id}`} className="ci-link">
25 25 {id === 'cagr' ? 'CAGR' : humanize(id)}
26 26 </a>
@@ -206,6 +206,71 @@ export default async function MethodologyPage() {
206 206 </ul>
207 207 </Section>
208 208
209 + <Section id="research-gap" kicker="§34 · §113" title="Research Gap Index (share-based)">
210 + <p>
211 + For one burden scope (geography, year, sex, one epidemiology source) and the eligible top-level cancers (annual deaths ≥ 100), each cancer's <em>share of deaths</em> is compared with its <em>share of active interventional trials</em> and its <em>share of publications over 5 years</em>, all shares summed over the same eligible set. <code>trial_gap_ratio = log₂(death share / trial share)</code>, <code>research_gap_ratio = log₂(death share / publication share)</code>; 0 means the activity share matches the death share, +1 twice as many deaths as the activity would suggest, −1 half. The ratios are scale-free, unlike the percentile indexes above, so they can be read across scopes. The same rows carry <code>trials_per_1000_deaths</code> and <code>publications_per_1000_deaths</code>. A ratio is undefined (not zero) when the activity is 0.
212 + </p>
213 + <ul>
214 + <li>Components are stored per cancer and scope in <code>research_gap_components</code> (formula <code>ci-research-gap-components-v1</code>) with the observation ids, counters and sums that produced them; ranking snapshots <code>trial_gap_ratio</code>, <code>research_gap_ratio</code>, <code>trials_per_1000_deaths</code>, <code>publications_per_1000_deaths</code> are persisted for the preferred source of each scope key.</li>
215 + <li>Caveats are those of the percentile indexes (descendant aggregation of trials, query-based literature counts, US-only burden while GLOBOCAN is under review) plus a new one: narrow literature queries make small entities look under-published. Read it as a signal for further inquiry, never as a judgement of research quality or funding.</li>
216 + </ul>
217 + <p className="mt-2 text-[13px]">
218 + <Link className="ci-link" href="/research-gap">Open the Research Gap page</Link> · full method: <code>docs/methodology/research-gap.md</code>.
219 + </p>
220 + </Section>
221 +
222 + <Section id="trial-intelligence" kicker="§10 · §115" title="Clinical trial intelligence">
223 + <p>
224 + <code>trial_intelligence</code> (formula <code>ci-trial-intel-v1</code>) is rebuilt daily for every top-level cancer and every malignant entity with at least one mapped study. Studies attach to a cancer through their reconciled conditions over the NCIt descendants (depth ≤ 12, distinct studies); counts use interventional studies and the same active statuses as the counters (recruiting, not yet recruiting, enrolling by invitation, active not recruiting).
225 + </p>
226 + <ul>
227 + <li><strong>Growth</strong>: studies first posted in the last 12 months versus the preceding 12 months; the year-over-year ratio is shown only when the prior window holds ≥ 20 studies.</li>
228 + <li><strong>Sponsor and country concentration</strong>: Herfindahl–Hirschman index (sum of squared shares) over active studies, by lead sponsor and by study–country pairs (a multinational study contributes to every country it lists); HHI needs ≥ 10 active studies. Industry share = lead sponsor class INDUSTRY; US share = studies listing a US site.</li>
229 + <li><strong>Termination share</strong>: (terminated + withdrawn) / (completed + terminated + withdrawn) over studies first posted since 2010, shown when the denominator is ≥ 30. Stated reasons are classified by explicit keyword rules (<code>ci-stop-reasons-v1</code>; precedence covid › safety › efficacy › drug supply › investigator › enrollment › funding › sponsor decision; text without a match is "other stated"; empty is "not stated") and never inferred.</li>
230 + <li><strong>Burden-normalized intensity</strong>: active studies per 1,000 annual deaths and per 100,000 new cases, using the latest US year with both counts from one source (deaths ≥ 100); trial counts are worldwide while the burden is US, which the table states.</li>
231 + <li>Four rankings derive from this layer: <code>phase3_recruiting_trials</code>, <code>trial_growth_yoy</code>, <code>trial_termination_share</code>, <code>sponsor_concentration</code>.</li>
232 + </ul>
233 + <p className="mt-2 text-[13px]">
234 + <Link className="ci-link" href="/trials/intelligence">Trial intelligence table</Link> · <Link className="ci-link" href="/trials/terminated">Terminated studies</Link> · full method: <code>docs/methodology/trial-intelligence.md</code>.
235 + </p>
236 + </Section>
237 +
238 + <Section id="trial-map" kicker="§11" title="Clinical trial map">
239 + <p>
240 + <code>trial_site_country_counts</code> (formula <code>ci-trial-sites-v1</code>) counts registrant-entered study locations per country for all oncology studies and for each top-level cancer (descendants included), any phase or one phase (a Phase II/III study counts in both), all statuses or recruiting only (location status when present, otherwise the study status). A study with forty US sites weighs forty sites and one study. Country names are mapped to ISO 3166-1 alpha-3 by a tested table; historical names that no longer exist stay unmapped and are listed, not painted. Fill classes are quantiles of the displayed values and the legend states the breaks; the data table below the map is the reference.
241 + </p>
242 + <p className="mt-2 text-[13px]">
243 + <Link className="ci-link" href="/trials/map">Open the map</Link> · <Link className="ci-link" href="/methodology/trial-map">detailed method page</Link>.
244 + </p>
245 + </Section>
246 +
247 + <Section id="pipeline" kicker="§15 · §114 · §119" title="Regulatory records and the drug development pipeline">
248 + <p>
249 + An approval record is one authority's decision for one application or product identifier, with jurisdiction, date, status and the indication text as published — never a bare "approved". US records come from openFDA (Drugs@FDA applications and labels); Canadian records come from the Health Canada Drug Product Database (one record per DIN with ATC class, market date and product status; the DPD does not publish indications, so no cancer is attached to these records and none is guessed from the ATC class). <code>drug_pipeline</code> (formula <code>ci-drug-pipeline-v1</code>) then assigns each drug — overall and per top-level cancer reached through trial conditions or approvals — a stage: approved when any approval is current; withdrawn when approvals exist but none is current; otherwise the highest registry phase among its interventional studies (Phase II/III → Phase III), or "phase not stated". Duplicate drug entities (salt forms, shared codes) are proposed to a merge queue, never merged automatically.
250 + </p>
251 + <p className="mt-2 text-[13px]">
252 + <Link className="ci-link" href="/approvals">Approvals feed</Link> · <Link className="ci-link" href="/pipeline">Pipeline</Link> · full method: <code>docs/methodology/pipeline.md</code>.
253 + </p>
254 + </Section>
255 +
256 + <Section id="knowledge-graph" kicker="§18 · §80" title="Knowledge graph">
257 + <p>
258 + The graph page shows the neighbourhood of one entity. Two families of links are never merged: <strong>source-native edges</strong> from <code>knowledge_edges</code> (CIViC, ChEMBL, openFDA…) with their native relationship, direction, evidence level, cancer context and provenance, aggregated for display per neighbour, relationship, direction, level and source; and <strong>derived registry links</strong> (dashed) that are counts computed at query time — studies per cancer, alteration frequency per cohort (≥ 5 %, ≥ 20 cases, largest denominator), approvals, drugs investigated in trials. At most 60 nodes are drawn, 25 per relationship group unless expanded; the table under the graph is complete for the expanded groups. CancerIndex never infers an edge and no language model writes into the graph.
259 + </p>
260 + <p className="mt-2 text-[13px]">
261 + <Link className="ci-link" href="/graph">Open the graph</Link> · full method: <code>docs/methodology/knowledge-graph.md</code>.
262 + </p>
263 + </Section>
264 +
265 + <Section id="data-explorer" kicker="§110" title="Data explorer comparability rules">
266 + <p>
267 + Observations are only overlaid on one chart when they share metric, unit, geography, source, standard population and age group; otherwise they are drawn as separate charts with a caption explaining the split (for example two registries publishing deaths for the same year, or two standard populations). Series are one cancer × one sex, dashed when the source labels the value as estimated. Defaults (metric, geography, the five cancers shown) are computed from the data present, not curated. Every view has a permalink, a CSV with attribution rows and the equivalent API call, and the observations table lists each value with its year, geography, sex, age, confidence interval, estimate type, site definition and source.
268 + </p>
269 + <p className="mt-2 text-[13px]">
270 + <Link className="ci-link" href="/explore">Open the Data explorer</Link> · <Link className="ci-link" href="/explore/coverage">Coverage matrix</Link> · full method: <code>docs/methodology/data-explorer.md</code>.
271 + </p>
272 + </Section>
273 +
209 274 <Section id="not-computed" kicker="Phase 1" title="What is not yet computed">
210 275 <ul>
211 276 <li>Burden, lethality and gap rankings exist only for scopes with licensed registry observations — currently the United States (CDC U.S. Cancer Statistics), per year and sex, top level. Global scopes wait for IARC / GLOBOCAN (license review) and SEER (credentials); 5-year survival waits for survival observations.</li>
modified apps/web/src/app/page.tsx +18 −45
@@ -7,22 +7,25 @@ import { Section } from '@/components/ui/section';
7 7 import { Badge, ClaimBadge, ConfidenceBadge, StatusBadge } from '@/components/ui/badge';
8 8 import { getSiteCounts, getRareSpotlight } from '@/lib/queries/stats';
9 9 import { previewSnapshot } from '@/lib/queries/rankings';
10 −import { mostActiveResearch, mostCuratedEvidence } from '@/lib/queries/trials';
10 +import { mostCuratedEvidence } from '@/lib/queries/trials';
11 11 import { resolveTopLevel } from '@/lib/queries/cancers';
12 12 import { listSources } from '@/lib/queries/sources';
13 13 import { fmtInt, fmtValue, fmtDate, scopeLabel, unitLabel, humanize, parseScopeKey } from '@/lib/format';
14 14 import { BurdenModule } from '@/components/home/burden-module';
15 15 import { RisingModule } from '@/components/home/rising-module';
16 16 import { GapsModule } from '@/components/home/gaps-module';
17 +import { TrialIntelModule } from '@/components/home/trial-intel-module';
18 +import { TrialMapModule } from '@/components/home/trial-map-module';
19 +import { GraphModule } from '@/components/home/graph-module';
20 +import { ExplorerModule } from '@/components/home/explorer-module';
17 21
18 22 export const revalidate = 900;
19 23
20 24 export default async function HomePage() {
21 − const [counts, spotlight, preview, active, curated, sources] = await Promise.all([
25 + const [counts, spotlight, preview, curated, sources] = await Promise.all([
22 26 getSiteCounts(),
23 27 getRareSpotlight(),
24 28 previewSnapshot(['mortality_count', 'incidence_count', 'as_mortality_rate', 'active_trials', 'publications_5y', 'curated_evidence_items']),
25 − mostActiveResearch(8),
26 29 mostCuratedEvidence(8),
27 30 listSources(),
28 31 ]);
@@ -79,6 +82,12 @@ export default async function HomePage() {
79 82 {/* Cancer burden (US, latest year) — §105 "Cancer burden today" */}
80 83 <BurdenModule slug="united-states" />
81 84
85 + {/* Data explorer default chart (SPEC §110) */}
86 + <ExplorerModule />
87 +
88 + {/* Clinical trial intelligence (SPEC §10) */}
89 + <TrialIntelModule limit={8} />
90 +
82 91 {/* Rankings preview */}
83 92 <Section
84 93 id="rankings"
@@ -152,48 +161,6 @@ export default async function HomePage() {
152 161 {/* Largest trial / research gaps (§105, §265-266) */}
153 162 <GapsModule geo="USA" />
154 163
155 − {/* Active research */}
156 − <Section id="active-research" kicker="Clinical research" title="Most active clinical research" description="Cancers with the most interventional trials in an active status (recruiting, not yet recruiting, enrolling by invitation, active not recruiting), counted over the entity and its descendants.">
157 − {active.length ? (
158 − <>
159 − <div className="ci-table-wrap">
160 − <table className="ci-table">
161 − <thead>
162 − <tr>
163 − <th>Cancer</th>
164 − <th className="num">Active trials (count)</th>
165 − <th className="num">Recruiting (count)</th>
166 − </tr>
167 − </thead>
168 − <tbody>
169 − {active.map((r) => (
170 − <tr key={r.slug}>
171 − <td>
172 − <Link className="ci-link" href={`/cancer/${r.slug}/trials`}>
173 − {r.canonical_name}
174 − </Link>
175 − </td>
176 − <td className="num">{fmtInt(r.active_trial_count)}</td>
177 − <td className="num">{fmtInt(r.recruiting_trial_count)}</td>
178 − </tr>
179 − ))}
180 − </tbody>
181 − </table>
182 − </div>
183 − <Freshness dataUpdatedAt={active[0]?.computed_at} extra="source: clinicaltrials · counters refreshed deterministically" />
184 − </>
185 − ) : (
186 − <EmptyState>
187 − No clinical trials have been ingested yet; trial counters are computed from ClinicalTrials.gov conditions mapped to the taxonomy.
188 − <div className="mt-1">
189 − <Link href="/trials" className="ci-link">
190 − Trials explorer
191 − </Link>
192 − </div>
193 − </EmptyState>
194 − )}
195 − </Section>
196 −
197 164 {/* Curated evidence */}
198 165 <Section id="curated" kicker="Molecular knowledge" title="Most curated molecular evidence" description="Accepted CIViC evidence items whose disease maps to the cancer or its descendants.">
199 166 {curated.length ? (
@@ -231,6 +198,12 @@ export default async function HomePage() {
231 198 </div>
232 199
233 200 <aside className="space-y-10">
201 + {/* Where trials recruit (SPEC §11) */}
202 + <TrialMapModule topN={8} />
203 +
204 + {/* Knowledge graph teaser (SPEC §18) */}
205 + <GraphModule limit={3} />
206 +
234 207 {/* Taxonomy at a glance */}
235 208 <Section
236 209 id="taxonomy"
modified apps/web/src/app/trial/[nct]/page.tsx +2 −0
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { notFound, permanentRedirect } from 'next/navigation';
4 4 import { ExternalLink } from 'lucide-react';
5 +import { GraphLink } from '@/components/graph/graph-link';
5 6 import { PageHeader, Section, KV, Note } from '@/components/ui/section';
6 7 import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge';
7 8 import { EmptyState } from '@/components/ui/empty-state';
@@ -50,6 +51,7 @@ export default async function TrialPage({ params, searchParams }: { params: Prom
50 51 </a>
51 52 <SourceBadge p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', retrievedAt: t.updated_at, ingestRunId: t.ingest_run_id, layer: 'normalized' }} />
52 53 <ClaimBadge kind="published" />
54 + <GraphLink type="trial" ref={t.nct_id} />
53 55 </div>
54 56 {t.why_stopped ? <Note tone="warn">Why stopped (as posted): {t.why_stopped}</Note> : null}
55 57 </PageHeader>
modified apps/web/src/app/variant/[slug]/page.tsx +2 −0
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { notFound } from 'next/navigation';
4 4 import { ExternalLink } from 'lucide-react';
5 +import { GraphLink } from '@/components/graph/graph-link';
5 6 import { PageHeader, Section, KV, Note } from '@/components/ui/section';
6 7 import { Badge, ClaimBadge } from '@/components/ui/badge';
7 8 import { EmptyState } from '@/components/ui/empty-state';
@@ -45,6 +46,7 @@ export default async function VariantPage({ params, searchParams }: { params: Pr
45 46 <PageHeader kicker={`Variant${v.variant_type ? ` · ${humanize(v.variant_type)}` : ''}`} title={<>{v.gene_symbol ? <Link href={`/gene/${v.gene_symbol}`} className="ci-mono font-sans text-ink no-underline hover:text-accent">{v.gene_symbol}</Link> : null} {v.name}</>}>
46 47 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">
47 48 <span className="ci-mono text-ink-3">{v.id}</span>
49 + <GraphLink type="variant" ref={v.slug} />
48 50 {v.hgvs_p ? <span className="ci-mono">{v.hgvs_p}</span> : null}
49 51 {v.hgvs_c ? <span className="ci-mono">{v.hgvs_c}</span> : null}
50 52 {v.clinvar_variation_id ? (
modified apps/web/src/components/cancer/header.tsx +2 −0
@@ -3,6 +3,7 @@ import { ExternalLink } from 'lucide-react';
3 3 import { Badge } from '@/components/ui/badge';
4 4 import { Breadcrumbs } from '@/components/layout/breadcrumbs';
5 5 import { Tabs } from '@/components/ui/tabs';
6 +import { GraphLink } from '@/components/graph/graph-link';
6 7 import { CODE_SYSTEM_LABEL, codeUrl } from '@/lib/site';
7 8 import { humanize, fmtInt } from '@/lib/format';
8 9 import { TABS, type CancerBundle, type TabKey } from './load';
@@ -46,6 +47,7 @@ export function CancerHeader({ b, tab }: { b: CancerBundle; tab: TabKey }) {
46 47 <span className="ci-mono text-ink-2">{c.id}</span>
47 48 {c.short_name && c.short_name !== c.canonical_name ? <span>{c.short_name}</span> : null}
48 49 {abbreviations.length ? <span>{abbreviations.slice(0, 6).join(' · ')}</span> : null}
50 + <GraphLink type="cancer" ref={c.slug} />
49 51 </p>
50 52 {badges.length ? (
51 53 <div className="mt-2 flex flex-wrap gap-1.5">
modified apps/web/src/components/cancer/tabs/rankings.tsx +2 −0
@@ -6,6 +6,7 @@ import { JsonView } from '@/components/ui/json-view';
6 6 import { rankingsForCancer } from '@/lib/queries/rankings';
7 7 import { fmtDate, fmtInt, fmtValue, scopeLabel, unitLabel } from '@/lib/format';
8 8 import { latestYearPerScope } from '@/lib/rankings-util';
9 +import { CancerGapCard } from '@/components/research-gap/cancer-gap-card';
9 10 import type { CancerBundle } from '../load';
10 11
11 12 function Delta({ rank, prev }: { rank: number; prev: number | null }) {
@@ -108,6 +109,7 @@ export async function RankingsTab({ b }: { b: CancerBundle }) {
108 109 </p>
109 110 </Section>
110 111 <Note>"Deadliest" is ambiguous: annual deaths, age-standardized mortality rate, mortality-to-incidence ratio and 5-year survival rank cancers differently. CancerIndex publishes each as a separate metric.</Note>
112 + <CancerGapCard cancerId={b.cancer.id} />
111 113 </div>
112 114 );
113 115 }
modified docs/API.md +8 −0
@@ -91,6 +91,14 @@ Payload objects that carry a number also carry the category (`observed_data`, `c
91 91 | `GET /v1/sources` · `GET /v1/sources/:slug` | registry, license status, connector health, runs, counts |
92 92 | `GET /v1/stats` | live counts (60 s cache) |
93 93 | `GET /v1/changes` | change events; `entityType`, `entityId`, `kind`, `since` |
94 +| `GET /v1/epidemiology` · `/epidemiology/coverage` · `/epidemiology/metrics` | time-aware observations with provenance (`metric`, `cancer` ≤ 8, `geography`, `sex`, `age`, `from`, `to`, `source`, `estimateType`); coverage matrix; metrics present |
95 +| `GET /v1/trials/intelligence` · `/trials/intelligence/:cancer` | derived per-cancer trial metrics (`level=top|all`, `sort`, `order`, `minActive`): counts, growth YoY, enrollment, sponsor/country HHI, termination share, trials per 1,000 deaths — formula `ci-trial-intel-v1` |
96 +| `GET /v1/trials/terminated` | terminated / withdrawn / suspended studies with registrant-stated reason and keyword category (`cancer`, `reason`, `status`, `since`) |
97 +| `GET /v1/trials/sites` | country (or city) aggregates of trial sites (`cancer` top-level, `phase`, `recruiting`, `level=country|city`) |
98 +| `GET /v1/research-gap` · `/research-gap/scopes` | Research Gap components per burden scope (`geography`, `year`, `sex`, `source`): deaths, trials, publications, shares, log₂ ratios, per-1,000-deaths intensities |
99 +| `GET /v1/approvals` · `/approvals/recent` | jurisdiction-aware approval records (`authority`, `jurisdiction`, `cancer`, `drug`, `status`, `from`, `to`, `q`); recent feed grouped by month |
100 +| `GET /v1/pipeline` · `/pipeline/summary` | drug development stage per drug / per drug × top-level cancer (`cancer`, `stage`, `drug`) — formula `ci-drug-pipeline-v1` |
101 +| `GET /v1/graph/:type/:id` · `/graph/cancer/:id/paths` | knowledge-graph neighbourhood (source-native edges + derived registry links; `limit`, `rel`, `context`, `includeDerived`) and cancer → gene → variant → drug → approval → trial chains |
94 102 | `GET/POST /v1/admin/*` | operators only (`x-admin-token`): connectors, run/pause/resume, runs/:runId, unresolved + resolve, trace, jobs/counters, jobs/rank, audit |
95 103
96 104 ## Examples
modified docs/METHODOLOGY.md +23 −0
@@ -179,3 +179,26 @@ lake path). The same lineage is available to operators with `pnpm cix trace <tab
179 179 deprecations with before/after snapshots and the ingest run that caused them
180 180 (`GET /v1/changes`). Ranking rows keep `previous_rank`, and every response states its
181 181 `dataRelease` (`CancerIndex YYYY-MM`) and `generatedAt`.
182 +
183 +
184 +## 6. Intelligence layer (wave 3, 2026-09-11)
185 +
186 +Derived tables recomputed daily by the worker (`maintenance.intel`, 06:15 UTC, between counters and
187 +rankings) or by hand with `pnpm cix intel`. Each has its own method page with formulas, thresholds
188 +and caveats; every row stores `formula_version` and an `inputs` JSON.
189 +
190 +| Module | Table(s) | Formula version | Method |
191 +|---|---|---|---|
192 +| Clinical trial intelligence (counts, growth, enrollment, sponsor & country concentration, termination share and stop reasons, burden-normalized intensity) | `trial_intelligence` | `ci-trial-intel-v1`, stop-reason rules `ci-stop-reasons-v1` | [docs/methodology/trial-intelligence.md](methodology/trial-intelligence.md) |
193 +| Clinical trial map (country aggregates of registrant-entered sites) | `trial_site_country_counts` | `ci-trial-sites-v1` | [docs/methodology/trial-map.md](methodology/trial-map.md) |
194 +| Research Gap Index (death share vs trial / publication share, log₂ ratios, per-1,000-deaths intensities) | `research_gap_components` + snapshots `trial_gap_ratio`, `research_gap_ratio`, `trials_per_1000_deaths`, `publications_per_1000_deaths` | `ci-research-gap-components-v1` | [docs/methodology/research-gap.md](methodology/research-gap.md) |
195 +| Drug development pipeline (stage per drug and per drug × top-level cancer; duplicate-drug proposals) | `drug_pipeline`, `entity_merges` | `ci-drug-pipeline-v1` | [docs/methodology/pipeline.md](methodology/pipeline.md) |
196 +| Knowledge graph (contextual neighbourhoods; source-native edges vs derived registry links) | query-time over `knowledge_edges` + relations | — (no derived numbers stored) | [docs/methodology/knowledge-graph.md](methodology/knowledge-graph.md) |
197 +| Data explorer (comparability groups, computed defaults, permalinks, CSV) | query-time over `epidemiology_observations` | — | [docs/methodology/data-explorer.md](methodology/data-explorer.md) |
198 +
199 +Trial interventions are reconciled to canonical drugs by `reconcileInterventionDrugs`
200 +(`packages/connectors/src/connectors/clinicaltrials/drugs.ts`): exact normalized alias → salt / dose /
201 +label-stripped alias → probabilistic head match, each recorded as its own `match_type`; aliases shared
202 +by several drugs are resolved only by a deterministic preference (own generic name > brand > base
203 +molecule) and otherwise left unresolved. Salt-form duplicate drugs are *proposed* to `entity_merges`,
204 +never merged automatically.
182 205