import { and, eq, sql } from 'drizzle-orm'; import { type Database, metricDefinitions, researchGapComponents } from '@cancerindex/database'; import { persistSnapshot, type Scope } from './engine.js'; import type { RankInput } from './rank.js'; export const RESEARCH_GAP_FORMULA_VERSION = 'ci-research-gap-components-v1'; /** Eligibility thresholds (metric_definitions eligibility.minDeaths + activity ≥ 1 per ratio). */ export const RESEARCH_GAP_THRESHOLDS = { /** Minimum annual deaths in the scope for a cancer to enter the eligible set. */ minDeaths: 100, /** Minimum activity (trials or publications) for the corresponding log-ratio to be defined. */ minActivity: 1, /** Minimum number of top-level cancers with a mortality_count observation for a scope to exist. */ minCancersPerScope: 10, /** Deaths above which a ranking row gets HIGH confidence (else MEDIUM). */ highConfidenceDeaths: 1000, } as const; export type ResearchGapThresholds = { minDeaths: number; minActivity: number }; export interface ResearchGapResult { rows: number; scopes: number; } /** Raw inputs for one cancer in one burden scope. */ export interface ComponentInput { id: string; deaths: number | null; activeTrials: number; publications5y: number; } /** Eligibility verdict for one cancer (pure). */ export interface Eligibility { eligible: boolean; reason: string | null; /** Whether the trial-based ratio can be computed (eligible AND active_trials ≥ minActivity). */ trialRatio: boolean; publicationRatio: boolean; } /** Computed shares and ratios for one cancer (pure; nulls where undefined). */ export interface ComponentShares extends Eligibility { id: string; deathShare: number | null; trialShare: number | null; publicationShare: number | null; trialGapRatio: number | null; researchGapRatio: number | null; trialsPer1000Deaths: number | null; publicationsPer1000Deaths: number | null; } export interface SharesResult { rows: ComponentShares[]; /** Sums over the eligible set (deaths ≥ minDeaths). */ sums: { deaths: number; activeTrials: number; publications5y: number; eligible: number }; } const round = (v: number, digits = 6): number => { const f = 10 ** digits; return Math.round(v * f) / f; }; /** * Eligibility rule (metric_definitions eligibility: deaths ≥ minDeaths; each ratio also needs * activity ≥ minActivity so that log2 is defined). A cancer with enough deaths but no registered * trials still belongs to the eligible set (it contributes 0 to Σ trials) — only its ratio is null. */ export function eligibility(input: Pick, t: ResearchGapThresholds = RESEARCH_GAP_THRESHOLDS): Eligibility { if (input.deaths == null || !Number.isFinite(input.deaths)) return { eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false }; if (input.deaths < t.minDeaths) return { eligible: false, reason: `deaths_below_threshold (${input.deaths} < ${t.minDeaths})`, trialRatio: false, publicationRatio: false }; return { eligible: true, reason: null, trialRatio: input.activeTrials >= t.minActivity, publicationRatio: input.publications5y >= t.minActivity }; } /** log2(a / b), or null when either side is not a positive finite number. */ export function log2Ratio(a: number | null | undefined, b: number | null | undefined): number | null { if (a == null || b == null || !Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return null; return round(Math.log2(a / b)); } /** activity per 1,000 deaths, or null when deaths are not positive. */ export function per1000Deaths(activity: number, deaths: number | null | undefined): number | null { if (deaths == null || !Number.isFinite(deaths) || deaths <= 0 || !Number.isFinite(activity)) return null; return round(activity / (deaths / 1000), 4); } /** * Shares and log-ratios over the eligible set of one scope (pure, deterministic). * death_share = deaths / Σ deaths; trial_share = active_trials / Σ active_trials; * publication_share = publications_5y / Σ publications_5y — all sums over eligible cancers only. * Shares are rounded to 6 decimals; per-1,000 values to 4. */ export function computeShares(inputs: ComponentInput[], t: ResearchGapThresholds = RESEARCH_GAP_THRESHOLDS): SharesResult { const verdicts = new Map(inputs.map((i) => [i.id, eligibility(i, t)])); const eligible = inputs.filter((i) => verdicts.get(i.id)!.eligible); const sums = { deaths: eligible.reduce((s, i) => s + (i.deaths ?? 0), 0), activeTrials: eligible.reduce((s, i) => s + i.activeTrials, 0), publications5y: eligible.reduce((s, i) => s + i.publications5y, 0), eligible: eligible.length, }; const share = (v: number, total: number): number | null => (total > 0 ? round(v / total) : null); const rows: ComponentShares[] = inputs.map((i) => { const e = verdicts.get(i.id)!; if (!e.eligible) { 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) }; } const deathShare = share(i.deaths!, sums.deaths); const trialShare = share(i.activeTrials, sums.activeTrials); const publicationShare = share(i.publications5y, sums.publications5y); return { id: i.id, ...e, deathShare, trialShare, publicationShare, // Ratios use unrounded shares (equivalently deaths·Σtrials / (trials·Σdeaths)) so rounding of shares never leaks into the index. trialGapRatio: e.trialRatio ? log2Ratio(i.deaths! / sums.deaths, i.activeTrials / sums.activeTrials) : null, researchGapRatio: e.publicationRatio ? log2Ratio(i.deaths! / sums.deaths, i.publications5y / sums.publications5y) : null, trialsPer1000Deaths: per1000Deaths(i.activeTrials, i.deaths), publicationsPer1000Deaths: per1000Deaths(i.publications5y, i.deaths), }; }); return { rows, sums }; } type ScopeRow = { geography_id: string; slug: string; iso3: string | null; year: number; sex: string; source_id: string; n: number; }; type ObsRow = { cancer_id: string; metric: string; value: number; id: number; estimate_type: string; site_definition: string | null; }; type CounterRow = { entity_id: string; active_trial_count: number; phase3_trial_count: number; publication_count_5y: number; approved_drug_count: number; updated_at: Date | string; }; type LitRow = { cancer_id: string; count: number; id: number; query: string; updated_at: Date | string; }; /** * Research Gap Index (SPEC §34, §113): burden vs research activity per top-level cancer and burden * scope. Scope discovery follows the ranking engine (every geography/year/sex/source with ≥ 10 * top-level cancers carrying a `mortality_count` observation, age group "all"). For each scope the * component rows are rebuilt in one transaction, then four ranking snapshots are persisted through * the shared engine so `/rankings/` pages, "Why this rank?" and TRACE work unchanged. */ export async function computeResearchGap(db: Database): Promise { const t = RESEARCH_GAP_THRESHOLDS; const defs = await db.select().from(metricDefinitions); const byslug = new Map(defs.map((d) => [d.slug, d])); const activitySources = await db.execute<{ id: string; slug: string }>(sql`SELECT id, slug FROM sources WHERE slug IN ('clinicaltrials', 'pubmed')`); const srcId = (slug: string) => activitySources.find((s) => s.slug === slug)?.id ?? slug; const trialsSourceId = srcId('clinicaltrials'); const pubmedSourceId = srcId('pubmed'); const [counters, lit] = await Promise.all([ db.execute(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'`), db.execute(sql`SELECT cancer_id, count, id, query, updated_at FROM literature_counts WHERE window_key = '5y'`), ]); const cmap = new Map(counters.map((c) => [c.entity_id, c])); const lmap = new Map(lit.map((l) => [l.cancer_id, l])); const scopes = await db.execute(sql` SELECT o.geography_id, g.slug, g.iso3, o.year, o.sex, o.source_id, count(DISTINCT o.cancer_id) AS n FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id JOIN cancers c ON c.id = o.cancer_id WHERE c.top_level AND c.status = 'active' AND o.age_group = 'all' AND o.metric = 'mortality_count' GROUP BY o.geography_id, g.slug, g.iso3, o.year, o.sex, o.source_id HAVING count(DISTINCT o.cancer_id) >= ${t.minCancersPerScope} ORDER BY o.year, o.sex, o.source_id`); // Ranking snapshots are keyed by (geography, sex, age, year, level) without the burden source, so // two sources for the same population would overwrite each other's "current" snapshot. Components // are stored for every source; snapshots only for the preferred one per scope key (most cancers // with a mortality observation, ties broken by source id) — the same source the web page defaults to. const preferred = new Map(); for (const s of scopes) { const key = `${s.iso3 ?? s.slug.toUpperCase()}|${s.year}|${s.sex}`; const cur = preferred.get(key); if (!cur || Number(s.n) > Number(cur.n) || (Number(s.n) === Number(cur.n) && s.source_id < cur.source_id)) preferred.set(key, s); } let rows = 0; let nScopes = 0; for (const s of scopes) { const geography = s.iso3 ?? s.slug.toUpperCase(); const year = Number(s.year); const obs = await db.execute(sql` SELECT o.cancer_id, o.metric, o.value, o.id, o.estimate_type, o.site_definition FROM epidemiology_observations o JOIN cancers c ON c.id = o.cancer_id 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' AND o.source_id = ${s.source_id} AND o.metric IN ('mortality_count', 'incidence_count') ORDER BY o.id`); // First observation per (cancer, metric) is used; any duplicates (other site definitions) are kept in inputs. const mort = new Map(); const inc = new Map(); for (const o of obs) { const m = o.metric === 'mortality_count' ? mort : inc; const cur = m.get(o.cancer_id); if (cur) cur.otherIds.push(o.id); else m.set(o.cancer_id, { ...o, value: Number(o.value), otherIds: [] }); } if (mort.size < t.minCancersPerScope) continue; const inputs: ComponentInput[] = [...mort.keys()].sort().map((id) => { const c = cmap.get(id); const l = lmap.get(id); const fromCounter = Number(c?.publication_count_5y ?? 0); return { id, deaths: mort.get(id)!.value, activeTrials: Number(c?.active_trial_count ?? 0), publications5y: fromCounter > 0 ? fromCounter : Number(l?.count ?? 0) }; }); const { rows: shares, sums } = computeShares(inputs, t); const byId = new Map(shares.map((r) => [r.id, r])); const values = inputs.map((i) => { const r = byId.get(i.id)!; const m = mort.get(i.id)!; const ic = inc.get(i.id); const c = cmap.get(i.id); const l = lmap.get(i.id); const pubsFrom = Number(c?.publication_count_5y ?? 0) > 0 ? 'entity_counters.publication_count_5y' : l ? 'literature_counts[5y]' : 'none'; return { cancerId: i.id, geography, year, sex: s.sex, burdenSourceId: s.source_id, deaths: m.value, incidence: ic ? ic.value : null, activeTrials: i.activeTrials, phase3Trials: Number(c?.phase3_trial_count ?? 0), publications5y: i.publications5y, approvedDrugs: Number(c?.approved_drug_count ?? 0), deathShare: r.deathShare, trialShare: r.trialShare, publicationShare: r.publicationShare, trialGapRatio: r.trialGapRatio, researchGapRatio: r.researchGapRatio, trialsPer1000Deaths: r.trialsPer1000Deaths, publicationsPer1000Deaths: r.publicationsPer1000Deaths, eligible: r.eligible, ineligibleReason: r.reason, formulaVersion: RESEARCH_GAP_FORMULA_VERSION, inputs: { mortalityObservationId: m.id, mortalityOtherObservationIds: m.otherIds, mortalityEstimateType: m.estimate_type, mortalitySiteDefinition: m.site_definition, incidenceObservationId: ic?.id ?? null, countersComputedAt: c?.updated_at ?? null, publicationsSource: pubsFrom, literatureCountId: l?.id ?? null, literatureQuery: pubsFrom === 'literature_counts[5y]' ? l?.query : undefined, thresholds: { minDeaths: t.minDeaths, minActivity: t.minActivity }, ratioEligibility: { trialGapRatio: r.trialRatio, researchGapRatio: r.publicationRatio }, sums, activitySources: { activeTrials: trialsSourceId, publications5y: pubmedSourceId }, } as Record, }; }); await db.transaction(async (tx) => { 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))); for (let i = 0; i < values.length; i += 200) await tx.insert(researchGapComponents).values(values.slice(i, i + 200)); }); rows += values.length; nScopes += 1; // Ranking snapshots (same scope shape as the engine so /rankings/?scope=… resolves). if (preferred.get(`${geography}|${s.year}|${s.sex}`)?.source_id !== s.source_id) continue; const scope: Scope = { geography, sex: s.sex as Scope['sex'], ageGroup: 'all', year, entityLevel: 'top' }; const confidence = (deaths: number) => (deaths >= t.highConfidenceDeaths ? 'HIGH' : 'MEDIUM') as 'HIGH' | 'MEDIUM'; const base = (v: (typeof values)[number]) => ({ deaths: v.deaths, mortalityObservationId: (v.inputs as { mortalityObservationId: number }).mortalityObservationId, burdenSourceId: s.source_id, countersComputedAt: (v.inputs as { countersComputedAt: unknown }).countersComputedAt, componentsFormulaVersion: RESEARCH_GAP_FORMULA_VERSION, thresholds: { minDeaths: t.minDeaths, minActivity: t.minActivity }, }); const eligibleRows = values.filter((v) => v.eligible); const metricItems: Array<[string, RankInput[], string[]]> = [ ['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]], ['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]], ['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]], ['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]], ]; for (const [slug, items, sourceIds] of metricItems) { const def = byslug.get(slug); if (!def || items.length < 3) continue; await persistSnapshot(db, def, scope, items, { descending: true, sourceIds }); } } return { rows, scopes: nScopes }; }