SPB Git

spb/cancerindex

Public
18.9 KB · 232 lines tsx
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section } from '@/components/ui/section';4import { Badge, ClaimBadge } from '@/components/ui/badge';5import { EmptyState } from '@/components/ui/empty-state';6import { JsonView } from '@/components/ui/json-view';7import { listMetrics } from '@/lib/queries/rankings';8import { humanize, unitLabel } from '@/lib/format';910export const metadata: Metadata = { title: 'Methodology', description: 'How CancerIndex normalizes, reconciles, aggregates and ranks: every metric with its formula and version.' };11export const revalidate = 3600;1213const CATEGORY_LABEL: Record<string, string> = { burden: 'Burden', lethality: 'Lethality', trials: 'Clinical research', research: 'Research activity', trend: 'Trends', molecular: 'Molecular knowledge', unmet_need: 'Gap indexes', rarity: 'Rarity', treatment: 'Treatment', composite: 'Composite' };1415export default async function MethodologyPage() {16  const metrics = await listMetrics();17  const cats = [...new Set(metrics.map((m) => m.category))];18  return (19    <div className="ci-prose max-w-4xl">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." />2122      <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) => (24          <a key={id} href={`#${id}`} className="ci-link">25            {id === 'cagr' ? 'CAGR' : humanize(id)}26          </a>27        ))}28      </nav>2930      <Section id="layers" kicker="§2" title="Data layers and claim types">31        <p>32          Every value belongs to one layer: <strong>raw</strong> (the payload as published, kept in the data lake), <strong>normalized</strong> (units, labels and identifiers harmonized; values unchanged), <strong>canonical</strong> (attached to a CancerIndex entity), <strong>derived</strong> (computed by a versioned formula) and <strong>ranked</strong>. AI-generated synthesis is a sixth layer that is <em>not enabled</em> in this phase.33        </p>34        <p className="mt-2 flex flex-wrap items-center gap-1.5">35          Claims are labelled by kind and never merged: <ClaimBadge kind="observed" /> <ClaimBadge kind="published" /> <ClaimBadge kind="curated" /> <ClaimBadge kind="regulatory" /> <ClaimBadge kind="guideline" /> <ClaimBadge kind="computed" /> <ClaimBadge kind="ai" />36        </p>37      </Section>3839      <Section id="normalization" kicker="§69" title="Normalization">40        <ul>41          <li>Labels: Unicode NFKD, diacritics stripped, lower-case, punctuation to space, Greek letters spelled out (α → alpha), British/American spellings and plurals folded (tumour → tumor, carcinomas → carcinoma), "NOS" removed. Digits are kept (G12C, HER2).</li>42          <li>MeSH inverted forms are un-inverted ("Carcinoma, Non-Small-Cell Lung" → "Non-Small-Cell Lung Carcinoma").</li>43          <li>Rates keep the source's standard population; rates standardized to different populations are never placed in one ranking.</li>44          <li>Observations are time-aware: a value for year X never overwrites year Y. Multi-year aggregates keep both bounds.</li>45          <li>Variants keep original and normalized nomenclature, and every coordinate carries its assembly (GRCh37 / GRCh38).</li>46        </ul>47      </Section>4849      <Section id="reconciliation" kicker="§221" title="Reconciliation and match types">50        <p>Source labels are mapped to entities in a fixed order: shared identifiers, then curated aliases, then normalized strings. Language models are never the mapper; at most they propose candidates that a curator reviews. Unmatched labels go to a review queue and are never dropped. Every mapping stores one of these match types:</p>51        <ul>52          <li>53            <Badge mono>EXACT_IDENTIFIER</Badge> shared code (NCIt, DOID, OncoTree…)54          </li>55          <li>56            <Badge mono>CURATED_EXACT</Badge> one-to-one curated mapping · <Badge mono>ONTOLOGY_EXACT</Badge> exact via ontology cross-reference57          </li>58          <li>59            <Badge mono>CURATED_BROADER</Badge> / <Badge mono>CURATED_NARROWER</Badge> curated mapping to a broader or narrower concept60          </li>61          <li>62            <Badge mono>ALIAS</Badge> known alias after normalization · <Badge mono>PROBABILISTIC</Badge> string similarity, shown with caution · <Badge mono>UNRESOLVED</Badge> not mapped63          </li>64        </ul>65      </Section>6667      <Section id="hierarchy" kicker="§246-247" title="Hierarchy and the double-counting rule">68        <p>Counts of trials, evidence items, cohorts and genes aggregate over an entity's descendants across all hierarchy types (a trial mapped to "Lung Adenocarcinoma" counts for "Lung Cancer"). Because descendants overlap between branches, these counts are only compared within one entity level. Global burden rankings use a mutually exclusive top-level set of 36 registry site groups (GLOBOCAN / ICD-10 ranges anchored to NCIt concepts), so that no case is counted twice.</p>69      </Section>7071      <Section id="uncertainty" kicker="§3" title="Uncertainty and confidence">72        <ul>73          <li>Confidence intervals are shown exactly as published; CancerIndex does not compute new intervals.</li>74          <li>75            Per-row confidence: <Badge tone="ok">High</Badge> observed registry data with complete coverage · <Badge>Medium</Badge> estimated or modelled · <Badge tone="warn">Low</Badge> small denominators or indirect mapping · <Badge tone="outline">Insufficient data</Badge>.76          </li>77          <li>Estimate type (observed / estimated / projected) is displayed on every observation.</li>78          <li>Population survival statistics never predict individual outcomes and are shown with that note.</li>79        </ul>80      </Section>8182      <Section id="metrics" kicker="§250-252" title="Metric catalog" description="Rendered live from the metric_definitions table. Each derived number on the site references one of these by slug.">83        {metrics.length === 0 ? (84          <EmptyState title="Metric catalog not seeded" />85        ) : (86          <div className="space-y-6">87            {cats.map((cat) => (88              <div key={cat}>89                <h3 id={`cat-${cat}`}>{CATEGORY_LABEL[cat] ?? humanize(cat)}</h3>90                <div className="space-y-4">91                  {metrics92                    .filter((m) => m.category === cat)93                    .map((m) => (94                      <article key={m.slug} id={m.slug} className="border-l-2 border-rule pl-4">95                        <h4 className="font-sans text-[15px] font-medium">96                          {m.name} <span className="ci-mono text-[12px] text-ink-3">{m.slug}</span>97                          {m.experimental ? <Badge tone="warn" className="ml-2">experimental</Badge> : null}98                          {m.snapshot_count > 0 ? (99                            <Link href={`/rankings/${m.slug}`} className="ci-link ml-2 text-[12.5px] font-normal">100                              view ranking101                            </Link>102                          ) : (103                            <span className="ml-2 text-[12px] font-normal text-ink-3">not yet computed</span>104                          )}105                        </h4>106                        <p className="mt-1 text-[13.5px]">{m.description}</p>107                        <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12.5px]">108                          <dt className="text-ink-3">Formula</dt>109                          <dd>110                            <code>{m.formula}</code>111                          </dd>112                          <dt className="text-ink-3">Version</dt>113                          <dd className="ci-mono">{m.formula_version}</dd>114                          <dt className="text-ink-3">Unit</dt>115                          <dd>{unitLabel(m.unit)}</dd>116                          <dt className="text-ink-3">Direction</dt>117                          <dd>{m.higher_is_worse == null ? 'neutral' : m.higher_is_worse ? 'higher is worse' : 'higher is better'}</dd>118                          <dt className="text-ink-3">Aggregation</dt>119                          <dd>{m.aggregation ?? 'none'}</dd>120                          <dt className="text-ink-3">Dimensions</dt>121                          <dd>{m.valid_dimensions.join(', ')}</dd>122                          <dt className="text-ink-3">Sources</dt>123                          <dd>124                            {m.source_slugs.map((s) => (125                              <Link key={s} className="ci-mono mr-1.5 text-ink-2 hover:text-accent" href={`/source/${s}`}>126                                {s}127                              </Link>128                            ))}129                          </dd>130                          {Object.keys(m.eligibility ?? {}).length ? (131                            <>132                              <dt className="text-ink-3">Eligibility</dt>133                              <dd>134                                <JsonView data={m.eligibility} />135                              </dd>136                            </>137                          ) : null}138                        </dl>139                      </article>140                    ))}141                </div>142              </div>143            ))}144          </div>145        )}146      </Section>147148      <Section id="versioning" kicker="§184" title="Versioning and reproducibility">149        <p>A ranking snapshot is one metric × one scope × one formula version at one time. It stores the number of eligible entities, the list of source ids and a hash of its inputs; each row stores its lineage (observation ids, counters, formula inputs). Re-running the engine with identical inputs yields identical ranks. Changing a formula creates a new version; old snapshots remain queryable. Public identifiers (CI-CAN-…, CI-GENE-…) are minted once and never reused.</p>150      </Section>151152      <Section id="country-scopes" kicker="§47" title="Country scopes and data sources per geography">153        <p>154          Population statistics are stored per geography, year, sex and age group, exactly as published (<ClaimBadge kind="observed" />), and rankings are computed per scope key — <code>geo=&lt;ISO3&gt;|sex=&lt;all|male|female&gt;|age=all|year=&lt;YYYY&gt;|level=top</code>. A country page (<Link className="ci-link" href="/countries">/countries</Link>, <Link className="ci-link" href="/country/united-states">/country/united-states</Link>) exists only for geographies with at least one ingested observation; nothing is shown for the others.155        </p>156        <ul>157          <li>158            <strong>United States</strong> — <Link className="ci-link" href="/source/cdc-uscs">U.S. Cancer Statistics (CDC)</Link>: NPCR/SEER incidence and NVSS mortality, 1999 onwards, per USCS site group (ICD-O-3 / ICD-10 ranges shown as the site definition on every row), both sexes and by sex, all ages, age-standardized to the 2000 U.S. standard population (19 age groups). Incidence is published about one year later than mortality, so the latest year differs between metrics; the page states the year actually used. USCS site groups are mapped one-to-one to the top-level registry set; "Female Breast" / "Male Breast" / "Male and Female Breast" map to the same entity and are selected by the sex dimension.159          </li>160          <li>161            <strong>Why US only for now</strong> — the connectors that would cover other countries are not licensed for production: the IARC Global Cancer Observatory (GLOBOCAN, 185 countries) has license status <em>review</em> and the SEER API is <em>awaiting credentials</em> (CLAUDE.md §10.4, §142). Estimates are never substituted for registry counts, and no country is listed without observations.162          </li>163          <li>164            <strong>All-sites totals</strong> — a national total is displayed only when the source publishes an all-sites row. CancerIndex never sums site groups (definitions overlap across sources and years); when the row is absent the page says so and shows per-site figures only.165          </li>166          <li>167            <strong>Standard populations</strong> — rates standardized to the 2000 U.S. standard population are not comparable with World (Segi) standardized rates used by IARC; the standard is printed with every rate and rankings never mix standards.168          </li>169        </ul>170      </Section>171172      <Section id="cagr" kicker="Derived on the fly" title='Compound annual growth rate (home page "Fastest-rising incidence")'>173        <p>174          The home module computes, for each cancer with a complete series, the compound annual growth rate of the age-standardized incidence rate over the last ten years published for the geography (both sexes, all ages):175        </p>176        <p className="my-2">177          <code>CAGR = (ASIR[end_year] / ASIR[start_year])^(1 / (end_year − start_year)) − 1</code>178        </p>179        <ul>180          <li>Window: the ten most recent distinct years with observations; the module is hidden when fewer than ten years exist. Cancers lacking a value at either endpoint, or with fewer than ten points in the window, are skipped — nothing is interpolated.</li>181          <li>Inputs: the two endpoint observations only (their provenance is kept on the row); the value is labelled <ClaimBadge kind="computed" /> with version <code>ci-asir-cagr-10y-v1</code>. It is a page-level derivation, not a stored metric and not a ranking snapshot.</li>182          <li>Interpretation: a rising rate may reflect true risk change, screening or diagnostic practice, coding or registry changes, or residual demographic effects. The module states the two endpoint values so the number can be reproduced.</li>183        </ul>184      </Section>185186      <Section id="compare" kicker="§100" title="Compare page rules">187        <ul>188          <li>Two to four cancers are compared (<Link className="ci-link" href="/compare">/compare?ids=slug-1,slug-2</Link>); the selection lives in the URL so a comparison is shareable. Unknown or merged slugs are ignored and reported.</li>189          <li>190            <strong>Nearest top-level ancestor</strong> — registry figures exist only for the mutually exclusive top-level site groups. For an entity below that level (e.g. glioblastoma), the compare page and the overview "Key figures" strip walk every hierarchy type upwards and use the nearest active top-level ancestor (e.g. "Malignant Central Nervous System Neoplasm"), labelling the cell "figures for &lt;ancestor&gt; (registry level)". Those figures describe the whole site group, not the subtype. When no top-level ancestor exists the cell reads "no registry observation at this level".191          </li>192          <li>Counters (trials, publications, evidence, genes, cohorts, drugs) are shown for the entity itself and aggregate over its descendants; entities at different depths are therefore labelled with their depth and hierarchy position and should not be read as equivalent.</li>193          <li>Ranks show the entity's own current rank when one exists (top-level entities among the top-level set, others among all malignant entities), otherwise the ancestor's burden rank, labelled as such. Bars in the charts are proportional within one chart only; missing values render as a dash, never as zero.</li>194        </ul>195      </Section>196197      <Section id="gap-caveat" kicker="§324" title="Trial gap and research gap — caveats">198        <p>199          Gap indexes are <em>burden percentile − activity percentile</em> within one scope (geography, year, sex, top level): <code>trial_gap = percentile(mortality_count) − percentile(active_trials)</code> and <code>research_gap = percentile(mortality_count) − percentile(publications_5y)</code>. Positive values flag cancers with high mortality burden and comparatively little registered activity. They are quantitative signals, not accusations.200        </p>201        <ul>202          <li>Trial counts aggregate a cancer and its NCIt descendants only. Registrations phrased at a broader level (e.g. "colorectal cancer" for colon and rectal cancer, or "head and neck cancer" for laryngeal and pharyngeal sites) are attributed to the broader entity, which can overstate the gap of narrower top-level sites.</li>203          <li>Literature counts are query-based per entity (the query is stored with each count) and are not aggregated over descendants; entities without a stored query are excluded from the research gap.</li>204          <li>Percentiles depend on the eligible set of the scope; a gap value is therefore comparable only with rows of the same snapshot. The number of eligible entities and the two percentiles are stored on every row and shown on the ranking page.</li>205          <li>Burden here is deaths in one geography (currently the United States); a cancer that is common elsewhere but rare in that geography will show a small burden percentile.</li>206        </ul>207      </Section>208209      <Section id="not-computed" kicker="Phase 1" title="What is not yet computed">210        <ul>211          <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>212          <li>Composite scores (an overall "impact" index) are deliberately absent: they would hide the choice of weights.</li>213          <li>AI-generated summaries are not enabled; all text on entity pages is sourced from terminologies or written as fixed methodology.</li>214        </ul>215      </Section>216217      <Section id="limitations" kicker="Caveats" title="Limitations">218        <ul>219          <li>Registries differ in site definitions (e.g. non-melanoma skin cancer inclusion) and in completeness; site definitions are shown per observation.</li>220          <li>Trial conditions and publication links rely on reconciliation; probabilistic matches are labelled and candidate links are not counted as validated.</li>221          <li>Literature counts measure indexed records returned by a stored query, not scientific quality or clinical impact.</li>222          <li>Counts aggregate over descendants; entities at different hierarchy depths are not directly comparable.</li>223          <li>Approval records reflect the jurisdictions ingested; absence of an approval on this site is not evidence of absence.</li>224        </ul>225        <p className="mt-3 text-[13px] text-ink-3">226          Corrections: <a className="ci-link" href="mailto:corrections@cancerindex.io">corrections@cancerindex.io</a>. See also <Link className="ci-link" href="/trust">Trust &amp; policies</Link>.227        </p>228      </Section>229    </div>230  );231}232
Files · 602dc111f3dd7d7dec082e01832909f07dca7bd3
Menu

Theme