Research Gap Index — methodology
Formula versions: components ci-research-gap-components-v1; rankings ci-trial-gap-ratio-v1,
ci-research-gap-ratio-v1, ci-trials-per-deaths-v1, ci-pubs-per-deaths-v1. Code:
packages/ranking/src/research-gap.ts (unit tests in packages/ranking/test/research-gap.test.ts).
Table: research_gap_components. Web: /research-gap, /rankings/trial_gap_ratio,
/rankings/research_gap_ratio. API: GET /v1/research-gap, GET /v1/research-gap/scopes.
What it measures
The Research Gap Index compares, inside one burden scope, each top-level cancer's share of deaths with its share of registered research activity:
- active interventional trials registered on ClinicalTrials.gov and mapped to the cancer or one of
its NCIt descendants (
entity_counters.active_trial_count); - publications of the last five years in PubMed (
entity_counters.publication_count_5y, falling back toliterature_countswindow5ywhen the counter is empty; the exact query is stored on the literature row and copied intoinputs).
It is a computed metric (claim category computed_metric). It says where registered activity is
thin relative to mortality. It does not say anything about the quality, cost, difficulty or
funding of research, and it inherits every limitation of the burden source (site definitions,
coding, completeness).
Scope
A scope is a (geography, year, sex, burden source) combination in which at least 10 top-level
cancers carry a mortality_count observation with age_group = 'all' — exactly the discovery rule
of the ranking engine (packages/ranking/src/engine.ts). Every sex present is a separate scope
(all, male, female). Scopes are rebuilt from scratch on each run (pnpm cix intel): the rows of
a scope are deleted and reinserted in one transaction, then four ranking snapshots are persisted
through the shared persistSnapshot so "Why this rank?", previous-rank deltas, TRACE and CSV exports
work like for any other metric.
Burden today is United States only (CDC WONDER underlying-cause mortality; U.S. Cancer Statistics
NVSS mortality). IARC / GLOBOCAN is under license review, so no WORLD scope exists; a US death share
must not be read as a global death share.
Eligibility
| Rule | Effect |
|---|---|
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 |
| no mortality observation | not stored (the scope is built from mortality observations) — the reason no_mortality_observation exists in the pure function for completeness |
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 Σ |
publications_5y ≥ 1 |
same for research_gap_ratio |
Per-1,000-deaths intensities are computed for every stored row (they need no cross-entity
denominator) but only eligible rows are ranked. Ranking confidence is HIGH when deaths ≥ 1000,
MEDIUM otherwise.
Sex-specific scopes naturally produce zero-death rows (e.g. prostate in the female scope): they are stored as ineligible, never dropped.
Shares
All sums run over the eligible set E of the scope:
death_share_i = deaths_i / Σ_{j∈E} deaths_j
trial_share_i = active_trials_i / Σ_{j∈E} active_trials_j
publication_share_i = publications_5y_i / Σ_{j∈E} publications_5y_jShares are stored rounded to 6 decimals; the ratios below are computed from the unrounded shares (so
rounding never leaks into the index). Each share column sums to 1 over E.
Ratios and intensities
trial_gap_ratio_i = log2( death_share_i / trial_share_i )
research_gap_ratio_i = log2( death_share_i / publication_share_i )
trials_per_1000_deaths_i = active_trials_i / (deaths_i / 1000)
publications_per_1000_deaths_i = publications_5y_i / (deaths_i / 1000)Ratios are rounded to 6 decimals, intensities to 4. Rankings sort descending: rank 1 = largest positive gap.
Why log₂
The raw quotient death_share / activity_share is asymmetric: "twice the share" is 2 but "half the
share" is 0.5, so under- and over-representation of the same magnitude look very different and cannot
be averaged or compared symmetrically. Taking log₂ makes the scale symmetric around 0 and
scale-free: it does not depend on the size of the scope, so a value of +1 in the US 2024 scope
means the same thing as +1 in any other scope (unlike percentile-point differences, whose meaning
depends on how many entities are ranked). Base 2 was chosen for readability: each unit is a doubling.
How to read ±1
| Value | Reading |
|---|---|
0 |
the cancer's share of trials (or publications) equals its share of deaths |
+1 |
it carries twice the share of deaths that its share of activity would suggest — half the activity per death of the scope average |
+2 |
four times |
−1 |
its share of activity is twice its share of deaths — twice the activity per death of the scope average |
NULL |
undefined: no registered activity (activity < 1) or ineligible row |
Equivalently, the ratio is log2( scope-average intensity / own intensity ): on the /research-gap
scatter plots the dashed line is the scope's overall activity per death, and every cancer below it
has a positive ratio. A change of one cancer's inputs changes every other cancer's ratio slightly,
because the sums move.
Inputs stored with every row
research_gap_components.inputs (jsonb) records the mortality observation id (and any duplicate
observation ids for the same cancer/metric in the scope, e.g. other site definitions), the incidence
observation id when present, the counters' updated_at, which source supplied the publication count,
the literature count id and query when the fallback was used, the thresholds, the per-ratio
eligibility flags, the scope sums and the activity source ids. Ranking rows repeat deaths, the
activity, both shares, the sums, the observation id and the formula text, so /rankings/<slug> can
answer "Why this rank?" without joining back.
Caveats
- Descendant aggregation of trials. Trial counts include the cancer's NCIt descendants. A trial registered for "colorectal cancer" is attributed to the broader entity, not split between colon and rectum: narrower top-level sites can look under-trialled.
- Query-based literature counts. Publication counts come from a PubMed query per entity (stored on the row) and are not aggregated over descendants. A narrow or unlucky query yields a small count and a large positive Research Gap Ratio — a very high ratio should first prompt a check of the query.
- Registrants' condition phrasing. Whether a trial reaches a cancer depends on how sponsors phrase conditions and on reconciliation; unresolved labels are queued, not guessed.
- US-only burden. The share of deaths is a share of US deaths in one year and one source. The
same cancer can have a different ratio under another source (
cdc-wondervscdc-uscsdiffer in site definitions and coverage) or another year. Compare only within one table. - Correlation ≠ under-funding. A positive ratio does not establish neglect: some cancers are hard to enrol, some are dominated by prevention or surgery rather than drug trials, some have large registries elsewhere. It is a signal, phrased as such ("signal, not accusation").
- Relative to the eligible set. Adding a cancer to the top-level set, or a cancer crossing the 100-deaths threshold, shifts every share.
Differences from the percentile indexes
trial_gap / research_gap (ci-trial-gap-v1, ci-research-gap-v1) are
percentile(mortality_count) − percentile(activity): rank-based, bounded to ±100 percentile points,
insensitive to magnitudes (a 2× and a 20× mismatch can score the same) and dependent on the number of
ranked entities. The ratio indexes are magnitude-based, symmetric and scale-free; they expose their
inputs (shares, sums) directly and add the two intensities (trials_per_1000_deaths,
publications_per_1000_deaths) that need no cross-entity comparison. Both families stay available:
the percentile indexes for continuity, the ratio indexes as the primary reading on /research-gap.
Recompute
pnpm cix counters # refresh entity_counters
pnpm cix intel # research_gap_components + the four ranking snapshots (with the other intelligence tables)