import { sql } from 'drizzle-orm'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; import { RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '@cancerindex/ranking'; import { NotFound } from '../lib/errors.js'; import { AnyRecord, AnyList, camelRows, num, ok, respond } from '../lib/respond.js'; const scopeQuery = z.object({ geography: z.string().default('USA').describe('ISO3 of the burden scope (WORLD once licensed global estimates exist)'), year: z.coerce.number().int().optional().describe('Burden year; omitted = latest year available for the geography and sex'), sex: z.enum(['all', 'male', 'female']).default('all'), source: z.string().optional().describe('Burden source (slug or CI-SOURCE id); omitted = the source with the most component rows in the scope'), }); type ScopeRow = { geography: string; year: number; sex: string; burden_source_id: string; source_slug: string; source_name: string; n_total: number; n_eligible: number; formula_version: string; computed_at: Date | string; }; const SCOPES_SQL = sql` SELECT r.geography, r.year, r.sex, r.burden_source_id, s.slug AS source_slug, s.name AS source_name, count(*)::int AS n_total, count(*) FILTER (WHERE r.eligible)::int AS n_eligible, max(r.formula_version) AS formula_version, max(r.updated_at) AS computed_at FROM research_gap_components r JOIN sources s ON s.id = r.burden_source_id GROUP BY r.geography, r.year, r.sex, r.burden_source_id, s.slug, s.name`; /** * Research Gap routes (SPEC §34, §113): `GET /research-gap` → components (deaths, trials, * publications, shares, log-ratios, per-1,000 values) per top-level cancer for one burden scope; * `GET /research-gap/scopes` → the scopes with computed components. Everything here is a * computed metric (formula version on every row); it is a signal about burden vs registered * research activity, not a judgement of research quality or funding. */ export const researchGapRoutes: FastifyPluginAsyncZod = async (app) => { app.get('/research-gap/scopes', { schema: { tags: ['intelligence'], summary: 'Burden scopes (geography, year, sex, source) with research-gap components and their eligible counts', response: ok(AnyList) } }, async () => { const rows = await app.db.execute(sql`${SCOPES_SQL} ORDER BY r.geography, r.year DESC, r.sex, s.slug`); const data = camelRows(rows).map((r) => ({ ...r, scopeKey: `geo=${r.geography as string}|sex=${r.sex as string}|age=all|year=${r.year as number}|level=top` })); return respond(app, data, rows.map((r) => r.burden_source_id)); }); app.get('/research-gap', { schema: { tags: ['intelligence'], summary: 'Research-gap components for one burden scope: deaths, active trials, publications (5 y), shares over the eligible set, log2 gap ratios and per-1,000-deaths intensities per top-level cancer', querystring: scopeQuery, response: ok(AnyRecord) } }, async (req) => { const q = req.query; const geography = q.geography.toUpperCase(); const scopes = await app.db.execute(sql`${SCOPES_SQL} HAVING upper(r.geography) = ${geography} AND r.sex = ${q.sex} ${q.year !== undefined ? sql`AND r.year = ${q.year}` : sql``} ${q.source ? sql`AND (s.slug = ${q.source} OR r.burden_source_id = ${q.source})` : sql``} ORDER BY r.year DESC, count(*) DESC, s.slug LIMIT 1`); const scope = scopes[0]; if (!scope) { const any = await app.db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM research_gap_components`); if (num(any[0]?.n) === 0) return respond(app, { scope: null, rows: [], status: 'not_available', message: 'Research-gap components have not been computed yet (run `pnpm cix intel`).' }, []); throw new NotFound('research-gap scope', `geography=${geography} sex=${q.sex} year=${q.year ?? 'latest'} source=${q.source ?? 'any'}`); } const scopeKey = `geo=${scope.geography}|sex=${scope.sex}|age=all|year=${scope.year}|level=top`; const rows = await app.db.execute>(sql` SELECT r.id AS component_id, r.cancer_id, c.slug, c.canonical_name AS name, c.short_name, c.hematologic, r.deaths, r.incidence, r.active_trials, r.phase3_trials, r.publications_5y, r.approved_drugs, r.death_share, r.trial_share, r.publication_share, r.trial_gap_ratio, r.research_gap_ratio, r.trials_per_1000_deaths, r.publications_per_1000_deaths, r.eligible, r.ineligible_reason, r.formula_version, r.inputs, r.updated_at AS computed_at, tg.rank AS trial_gap_rank, rg.rank AS research_gap_rank FROM research_gap_components r JOIN cancers c ON c.id = r.cancer_id LEFT JOIN rankings tg ON tg.cancer_id = r.cancer_id AND tg.metric_slug = 'trial_gap_ratio' AND tg.scope_key = ${scopeKey} AND tg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'trial_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1) LEFT JOIN rankings rg ON rg.cancer_id = r.cancer_id AND rg.metric_slug = 'research_gap_ratio' AND rg.scope_key = ${scopeKey} AND rg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'research_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1) WHERE r.geography = ${scope.geography} AND r.year = ${scope.year} AND r.sex = ${scope.sex} AND r.burden_source_id = ${scope.burden_source_id} ORDER BY r.eligible DESC, r.research_gap_ratio DESC NULLS LAST, c.canonical_name`); const eligible = rows.filter((r) => r.eligible === true); const sums = { deaths: eligible.reduce((s, r) => s + num(r.deaths), 0), activeTrials: eligible.reduce((s, r) => s + num(r.active_trials), 0), publications5y: eligible.reduce((s, r) => s + num(r.publications_5y), 0), eligible: eligible.length, }; const metrics = await app.db.execute<{ slug: string; name: string; formula: string; formula_version: string; unit: string }>(sql` SELECT slug, name, formula, formula_version, unit FROM metric_definitions WHERE slug IN ('trial_gap_ratio','research_gap_ratio','trials_per_1000_deaths','publications_per_1000_deaths') ORDER BY slug`); const data = { scope: { geography: scope.geography, year: Number(scope.year), sex: scope.sex, ageGroup: 'all', entityLevel: 'top', scopeKey, burdenSource: { id: scope.burden_source_id, slug: scope.source_slug, name: scope.source_name }, activitySources: { activeTrials: 'clinicaltrials', publications5y: 'pubmed' }, formulaVersion: scope.formula_version ?? RESEARCH_GAP_FORMULA_VERSION, computedAt: scope.computed_at, totalEntities: Number(scope.n_total), eligibleEntities: Number(scope.n_eligible), sums, }, thresholds: { minDeaths: RESEARCH_GAP_THRESHOLDS.minDeaths, minActivity: RESEARCH_GAP_THRESHOLDS.minActivity, highConfidenceDeaths: RESEARCH_GAP_THRESHOLDS.highConfidenceDeaths }, metrics: camelRows(metrics).map((m) => ({ ...m, ranking: `/v1/rankings?metric=${m.slug as string}&geography=${scope.geography}&sex=${scope.sex}&year=${scope.year}&level=top` })), claim: 'computed_metric', notes: [ 'Shares are computed over the eligible top-level cancers of the scope (deaths ≥ minDeaths); a log2 ratio of +1 means the cancer has twice the share of deaths that its share of trials (or publications) would suggest, −1 half.', 'Trial counts aggregate a cancer and its NCIt descendants; literature counts are query-based per entity and not aggregated over descendants. Burden depends on the epidemiology source of the scope. A gap is a quantitative signal, not an accusation.', ], rows: rows.map((r) => ({ componentId: r.component_id, cancer: { id: r.cancer_id, slug: r.slug, name: r.name, shortName: r.short_name, hematologic: r.hematologic }, deaths: r.deaths, incidence: r.incidence, activeTrials: r.active_trials, phase3Trials: r.phase3_trials, publications5y: r.publications_5y, approvedDrugs: r.approved_drugs, deathShare: r.death_share, trialShare: r.trial_share, publicationShare: r.publication_share, trialGapRatio: r.trial_gap_ratio, researchGapRatio: r.research_gap_ratio, trialsPer1000Deaths: r.trials_per_1000_deaths, publicationsPer1000Deaths: r.publications_per_1000_deaths, ranks: { trialGapRatio: r.trial_gap_rank, researchGapRatio: r.research_gap_rank }, eligible: r.eligible, ineligibleReason: r.ineligible_reason, formulaVersion: r.formula_version, inputs: r.inputs, computedAt: r.computed_at, })), }; return respond(app, data, [scope.burden_source_id, 'clinicaltrials', 'pubmed']); }); };