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%
10.9 KB · 193 lines markdown
Rendered Raw Blame History
1# Methodology — Clinical trial intelligence23Formula version **`ci-trial-intel-v1`** · stop-reason rules **`ci-stop-reasons-v1`** · code4`packages/ranking/src/trial-intelligence.ts`, `packages/ranking/src/trial-stop-reasons.ts` · table5`trial_intelligence` · recomputed by `pnpm cix intel` after each ClinicalTrials.gov ingest.67Every value described here is a **computed metric** (claim category `computed_metric`). Nothing in8this layer is an observation: statuses, phases, dates, sponsors, countries, enrollment counts and9"why stopped" texts are taken as posted by the registrant on ClinicalTrials.gov, then counted. Each10row stores its `formula_version` and an `inputs` JSON (reference day, windows, thresholds, active11statuses, denominators, burden observation ids) so any figure can be traced back to its inputs.1213## 1. Scope and aggregation1415### 1.1 Which studies attach to a cancer1617A study is attached to a cancer when one of its reconciled conditions (`trial_conditions.cancer_id`)18points to the cancer **or to any of its descendants** in the NCIt-derived hierarchy19(`cancer_hierarchy`, recursive traversal, depth ≤ 12). Each study is counted **once per cancer**20(`DISTINCT`), even when several of its conditions map to different descendants. This is the same21traversal used by `entity_counters` and the ranking engine, so `active_trials` here equals22`entity_counters.active_trial_count`.2324**Caveat (descendant aggregation).** A study registered against a broad condition25("solid tumors", "lymphoma") is attributed to the broad entity only, never pushed down to subtypes.26Conversely a study registered against a subtype ("lung adenocarcinoma") also counts for every27ancestor ("non-small cell lung carcinoma", "lung cancer"). Figures for a parent are therefore not28the sum of its children, and entities at level `all` overlap: adding them double counts.2930### 1.2 Entity levels3132| `entity_level` | Rows | Purpose |33|---|---|---|34| `top` | every active cancer with `top_level = true` (36 today), including true zeros | mutually exclusive set — totals may be summed; rankings |35| `all` | every active malignant entity with ≥ 1 mapped study of any study type | subtype pages, exploration; overlapping |3637A top-level cancer has one row per level with identical figures.3839### 1.3 Study type and statuses4041Unless stated otherwise, every count uses **`study_type = 'INTERVENTIONAL'`** (observational and42expanded-access records are excluded). *Active* means overall status ∈ {`RECRUITING`,43`NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`} — the same list as44`entity_counters`. `UNKNOWN` status is neither active nor terminal.4546## 2. Counts4748| Column | Definition |49|---|---|50| `total_trials` | interventional studies mapped to the entity or a descendant, any status |51| `active_trials` | status ∈ active set |52| `recruiting_trials` | status = `RECRUITING` |53| `phase1_active` … `phase4_active` | active studies with `PHASEn` ∈ `phases`. A `PHASE2\|PHASE3` study counts in **both** phase 2 and phase 3. `EARLY_PHASE1` is **not** counted as phase 1. |54| `phase3_recruiting` | status = `RECRUITING` and `PHASE3` ∈ `phases` |55| `completed_trials`, `terminated_trials`, `withdrawn_trials`, `suspended_trials` | by overall status |56| `with_results` | `has_results = true` |5758## 3. Growth5960Registration date = `first_posted_date`. With `asOf` = the day of computation (stored in61`inputs.asOf`) and calendar-month arithmetic (day clamped to the month end):6263```64new_trials_12m       = studies with first_posted_date ∈ [asOf − 12 months, asOf)65new_trials_prior_12m = studies with first_posted_date ∈ [asOf − 24 months, asOf − 12 months)66trial_growth_yoy     = (new_trials_12m − new_trials_prior_12m) / new_trials_prior_12m67```6869`trial_growth_yoy` is **null when `new_trials_prior_12m` < 20** (`inputs.thresholds.growthMinPriorTrials`)70so small denominators do not produce spurious growth. The exact window bounds are stored in71`inputs.windows`. Because the reference day moves, two runs on different days are not comparable72window for window; the ranking snapshot keeps the run's windows in each row's inputs.7374## 4. Enrollment7576Over active interventional studies: `avg_enrollment` = mean of `enrollment_count`,77`median_enrollment` = `percentile_cont(0.5)`, `total_enrollment_active` = sum. `enrollment_count` is78the registrant's figure and may be *anticipated* rather than *actual* (`enrollment_type`); studies79without a count are ignored by the mean and median.8081## 5. Sponsors (active interventional studies)8283* `distinct_sponsors` — distinct `lead_sponsor` strings (no normalisation: "NCI" and "National84  Cancer Institute (NCI)" are two sponsors).85* `industry_share` = studies with `lead_sponsor_class = 'INDUSTRY'` / active studies.86* `sponsor_hhi` — Herfindahl–Hirschman index of lead sponsors:87  `HHI = Σ_s (n_s / N)²` where `n_s` = active studies led by sponsor *s* and `N` = active studies88  with a lead sponsor (`inputs.denominators.sponsor`). Range (0, 1]; 1 = a single sponsor; 1/k for89  *k* equal sponsors. **Null when active studies < 10** (`hhiMinActiveTrials`).90* `top_sponsor`, `top_sponsor_share` — the sponsor with the most active studies (ties broken91  alphabetically) and `n_top / N`.9293## 6. Geography (active interventional studies)9495Based on the study-level `countries` array (distinct countries with at least one site).9697* `distinct_countries` — countries appearing in at least one active study.98* `us_share` = studies with `'United States' ∈ countries` / active studies.99* `top_country`, `top_country_share` — most frequent country and its share of active studies.100* `country_hhi` — HHI over **trial–country pairs**: `Σ_c (p_c / P)²` where `p_c` = active studies101  listing country *c* and `P` = Σ p_c (`inputs.denominators.countryPairs`).102103**Caveat.** A multinational study contributes one pair to *every* country it lists, so country shares104can sum above 100 % and `country_hhi` measures the concentration of site presence, not of studies.105106## 7. Failures107108### 7.1 Termination share109110Over interventional studies with `first_posted_date ≥ 2010-01-01` (`terminationSince`):111112```113termination_share = (terminated + withdrawn) / (completed + terminated + withdrawn)114```115116Null when the denominator (terminal studies, `inputs.denominators.terminal`) is **< 30**117(`terminationMinTerminalTrials`). Suspended, active and unknown-status studies are not terminal118and are excluded from both numerator and denominator. Statuses are registrant-reported; a119`TERMINATED` status does not imply a negative result (many studies stop for accrual reasons).120121### 7.2 Stop-reason classification (`why_stopped_breakdown`)122123For interventional studies with status `TERMINATED`, `WITHDRAWN` or `SUSPENDED`, the free-text124`why_stopped` is mapped to one category using **explicit keyword rules only** (case-insensitive,125whole-word or word-prefix matches). Rules are tested in the order below; **the first match wins**126so specific causes take precedence over broad ones, and `sponsor_decision` — the broadest — is127tested last. The API also returns every matched category (`reasonMatches`). Nothing is inferred128from the design, sponsor or outcome of a study.129130| Order | Category | Keywords / patterns |131|---|---|---|132| 1 | `covid` | `covid…`, `pandemic` |133| 2 | `safety` | `safety`, `toxicit…`, `adverse` |134| 3 | `efficacy` | `efficacy`, `futility`, `lack of [clinical/therapeutic] benefit`, `interim analysis/analyses` |135| 4 | `drug_supply` | `supply`, `drug availability`, `manufactur…` |136| 5 | `investigator` | `PI left`, `investigator` |137| 6 | `enrollment` | `accru…` (accrual), `enrol…` (enrollment/enrolment), `recruit…` (recruitment) |138| 7 | `funding` | `fund`/`funds`/`funded`/`funding`, `financ…`, `budget…` |139| 8 | `sponsor_decision` | `business`, `sponsor decision` / `sponsor's decision` / `decision of/by the sponsor` / `sponsor decided`, `strateg…`, `portfolio`, `company` |140| — | `other_stated` | text present, no rule matched |141| — | `not_stated` | `why_stopped` null or blank |142143The breakdown is a JSON object `{ category: count }` over the entity's stopped interventional144studies; categories with zero studies are omitted. `inputs.stopReasonRulesVersion` records the rule145set. The public pages `/trials/terminated` and the API route `/v1/trials/terminated` apply the same146rules to any filtered set of studies (all study types there, with `studyType` as a filter).147148## 8. Burden-normalized intensity (top-level cancers only)149150```151trials_per_1000_deaths = active_trials / (deaths / 1 000)152trials_per_100k_cases  = active_trials / (incidence / 100 000)153```154155`deaths` and `incidence` come from `epidemiology_observations` for geography **USA**156(`burden_geography`), `sex = 'all'`, `age_group = 'all'`, metrics `mortality_count` and157`incidence_count`, **from the same `source_id` and the same year**. The latest year where both158counts exist is used (`burden_year`, `burden_source_id`); ties are broken by source id then159observation id so the choice is deterministic. **Deaths must be ≥ 100** (`burdenMinDeaths`),160otherwise both ratios are null. The observation ids used are stored in `inputs.burden`.161162Level `all` rows carry the burden ratios only when the entity is itself top-level; subtypes have no163site-level burden counts in the US registries and get null (shown as "—", never zero).164165**Caveats.** Trial counts are worldwide while the burden is US; the ratio therefore measures166worldwide research intensity relative to US burden, not US research per US death. Trial counts167depend on how registrants phrase conditions; burden counts depend on the registry's site definition168(`site_definition` on the observation).169170## 9. Rankings derived from this layer171172`rankTrialIntelligence` (called at the end of `computeTrialIntelligence`) persists snapshots for173scope `geo=WORLD | sex=all | age=all | year=latest | level=top|all`:174175| Metric slug | Value | Eligibility | Direction |176|---|---|---|---|177| `phase3_recruiting_trials` | `phase3_recruiting` | > 0 | descending |178| `trial_growth_yoy` | `trial_growth_yoy` | non-null (prior window ≥ 20) | descending |179| `trial_termination_share` | `termination_share` | non-null (terminal ≥ 30) | descending — rank 1 = highest share (`higher_is_worse` is display information) |180| `sponsor_concentration` | `sponsor_hhi` | non-null (active ≥ 10) | descending |181182Each ranking row's `inputs` holds the counts and windows behind its value. A metric is skipped when183fewer than three entities are eligible.184185## 10. Reproducibility186187The computation is a single SQL transaction: descendants temp table → cancer × study map →188per-cancer aggregates → delete + insert of both levels. Given the same database state and the same189`asOf` day it is deterministic (~14 s on the reference database: 126 195 studies, 187 523 condition190mappings, 9 510 cancers → 36 `top` rows + ~2 200 `all` rows). Pure formula helpers191(`growthWindows`, `growthYoy`, `hhi`, `terminationShare`, `burdenNormalized`, `classifyStopReason`)192are unit-tested in `packages/ranking/src/*.test.ts`.193