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%
43.9 KB · 626 lines typescript
Raw Blame History
1import { sql, type SQL } from 'drizzle-orm';2import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';3import { z } from 'zod';4import type { Database } from '@cancerindex/database';5import { descendantIds } from '../lib/descendants.js';6import { BadRequest } from '../lib/errors.js';7import { boolQuery } from '../lib/pagination.js';8import { resolveCancer, resolveDrug, resolveGene, resolveTrial, resolveVariant } from '../lib/resolve.js';9import { AnyRecord, num, ok, respond } from '../lib/respond.js';1011/**12 * Knowledge-graph routes (SPEC §18, §80).13 *14 *  GET /graph/:type/:id?limit=&rel=&context=&includeDerived=  → contextual neighbourhood of one entity15 *  GET /graph/:type/:id/paths                                   → cancer → gene → variant → drug → approval → trials chains (cancer only)16 *17 * Two families of links, never merged: source-native `knowledge_edges` rows (CIViC, ChEMBL, openFDA)18 * kept with their native evidence level, direction and cancer context, aggregated per19 * (neighbour, relationship, direction, level, source); and `derived: true` registry counts computed20 * at query time (trial_conditions, trial_interventions, cancer_gene_frequencies, drug_approvals,21 * civic_evidence_items). CancerIndex never infers an edge.22 *23 * The SQL is intentionally duplicated from `apps/web/src/lib/queries/graph.ts`: that module is24 * `server-only` and bound to the Next.js database helpers, so it cannot be imported here. Keep the25 * two in step when changing thresholds or ordering.26 */2728const TYPES = ['cancer', 'gene', 'variant', 'drug', 'trial'] as const;29type FocusType = (typeof TYPES)[number];30type NodeType = FocusType | 'approval';3132const ACTIVE = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'];33const FREQ_MIN = 0.05;34const CASES_MIN = 20;35const TRIAL_LIMIT = 10;36const MAX_DESCENDANTS = 600;37const SRC = { clinicaltrials: 'CI-SOURCE-00000004', civic: 'CI-SOURCE-00000006' } as const;3839const inList = (ids: string[]): SQL => sql`(${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`;40const activeList = (): SQL => sql`(${sql.join(ACTIVE.map((s) => sql`${s}`), sql`, `)})`;41const LEVEL_RANK = sql`CASE upper(coalesce(ke.evidence_level, '')) WHEN 'A' THEN 0 WHEN 'FDA ORIG' THEN 0 WHEN 'B' THEN 1 WHEN '4' THEN 1 WHEN 'C' THEN 2 WHEN '3' THEN 2 WHEN 'D' THEN 3 WHEN '2' THEN 3 WHEN 'E' THEN 4 WHEN '1' THEN 4 WHEN '' THEN 99 ELSE 50 END`;42const CIVIC_LEVEL_RANK = sql`CASE e.evidence_level WHEN 'A' THEN 0 WHEN 'B' THEN 1 WHEN 'C' THEN 2 WHEN 'D' THEN 3 WHEN 'E' THEN 4 ELSE 99 END`;4344interface Node {45  type: NodeType;46  id: string;47  ref: string | null;48  label: string;49  sublabel?: string | null;50  href: string;51}52interface Edge {53  relationshipType: string;54  outgoing: boolean;55  direction: string | null;56  evidenceLevel: string | null;57  evidenceCategory: string;58  cancerContext: Array<{ id: string; name: string; slug: string }>;59  supportCount: number;60  sourceIds: string[];61  provenanceIds: number[];62  derived: boolean;63  detail?: string | null;64  date?: string | null;65  via?: { type: NodeType; id: string; label: string; href: string } | null;66}67interface Link {68  node: Node;69  edge: Edge;70}71interface Derived {72  relationshipType: string;73  total: number;74  links: Link[];75}7677const href = (type: NodeType, ref: string): string => (type === 'cancer' ? `/cancer/${ref}` : type === 'gene' ? `/gene/${ref}` : type === 'variant' ? `/variant/${ref}` : type === 'drug' ? `/drug/${ref}` : type === 'trial' ? `/trial/${ref}` : ref);78const pct = (v: number) => `${(v * 100).toFixed(v >= 0.1 ? 0 : 1)} %`;7980async function resolveFocus(db: Database, type: FocusType, ref: string): Promise<Node & { ref: string }> {81  switch (type) {82    case 'cancer': {83      const c = await resolveCancer(db, ref);84      const r = await db.execute<{ canonical_name: string; entity_type: string }>(sql`SELECT canonical_name, entity_type FROM cancers WHERE id = ${c.id}`);85      return { type, id: c.id, ref: c.slug, label: r[0]?.canonical_name ?? c.slug, sublabel: r[0]?.entity_type ?? null, href: href(type, c.slug) };86    }87    case 'gene': {88      const g = await resolveGene(db, ref);89      const r = await db.execute<{ name: string | null }>(sql`SELECT name FROM genes WHERE id = ${g.id}`);90      return { type, id: g.id, ref: g.symbol, label: g.symbol, sublabel: r[0]?.name ?? null, href: href(type, g.symbol) };91    }92    case 'variant': {93      const v = await resolveVariant(db, ref);94      const r = await db.execute<{ label: string; variant_type: string | null }>(sql`SELECT coalesce(gene_symbol || ' ', '') || name AS label, variant_type FROM variants WHERE id = ${v.id}`);95      return { type, id: v.id, ref: v.slug, label: r[0]?.label ?? v.slug, sublabel: r[0]?.variant_type ?? null, href: href(type, v.slug) };96    }97    case 'drug': {98      const d = await resolveDrug(db, ref);99      const r = await db.execute<{ name: string; kind: string | null }>(sql`SELECT name, kind FROM drugs WHERE id = ${d.id}`);100      return { type, id: d.id, ref: d.slug, label: r[0]?.name ?? d.slug, sublabel: r[0]?.kind ?? null, href: href(type, d.slug) };101    }102    case 'trial': {103      const t = await resolveTrial(db, ref);104      const r = await db.execute<{ brief_title: string; overall_status: string | null }>(sql`SELECT brief_title, overall_status FROM clinical_trials WHERE id = ${t.id}`);105      return { type, id: t.id, ref: t.nctId, label: r[0]?.brief_title ?? t.nctId, sublabel: r[0]?.overall_status ?? null, href: href(type, t.nctId) };106    }107  }108}109110// ------------------------------------------------------------------ source-native edges111112type KeRow = {113  relationship_type: string;114  outgoing: boolean;115  ctx_only: boolean;116  n_type: NodeType;117  n_id: string;118  n_ref: string | null;119  n_label: string | null;120  n_sublabel: string | null;121  via_type: NodeType | null;122  via_id: string | null;123  via_ref: string | null;124  via_label: string | null;125  direction: string | null;126  evidence_level: string | null;127  evidence_category: string;128  source_id: string;129  support: string;130  edge_ids: number[];131  provenance_ids: number[];132  context_ids: string[];133  last_seen: Date | null;134  total: string;135};136137async function knowledgeEdges(db: Database, focus: Node, limit: number, rel: string | null, contextId: string | null): Promise<KeRow[]> {138  const t = focus.type;139  const id = focus.id;140  const ctx = t === 'cancer' ? sql`OR (${id} = ANY(ke.cancer_context_ids) AND ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id})` : sql``;141  const relF = rel ? sql`AND ke.relationship_type = ${rel}` : sql``;142  const ctxF = contextId ? sql`AND ${contextId} = ANY(ke.cancer_context_ids)` : sql``;143  return db.execute<KeRow>(sql`144    WITH e AS (145      SELECT ke.relationship_type,146             (ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) AS outgoing,147             (ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id}) AS ctx_only,148             CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_type ELSE ke.source_entity_type END AS n_type,149             CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_id ELSE ke.source_entity_id END AS n_id,150             CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_type END AS via_type,151             CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_id END AS via_id,152             ke.direction, ke.evidence_level, ke.evidence_category, ke.source_id, ke.id, ke.provenance_ids, ke.cancer_context_ids, ke.support_count, ke.last_seen_at, ${LEVEL_RANK} AS lvl153      FROM knowledge_edges ke154      WHERE ke.status = 'active' AND ((ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) OR (ke.target_entity_type = ${t} AND ke.target_entity_id = ${id}) ${ctx}) ${relF} ${ctxF}155    ), a AS (156      SELECT relationship_type, outgoing, ctx_only, n_type, n_id, via_type, via_id, direction, evidence_level, evidence_category, source_id, min(lvl) AS lvl,157             sum(support_count) AS support, array_agg(id ORDER BY id) AS edge_ids,158             (SELECT array_agg(DISTINCT x::int ORDER BY x::int) FROM unnest(string_to_array(string_agg(array_to_string(provenance_ids, ','), ','), ',')) x WHERE x <> '') AS provenance_ids,159             (SELECT array_agg(DISTINCT x ORDER BY x) FROM unnest(string_to_array(string_agg(array_to_string(cancer_context_ids, ','), ','), ',')) x WHERE x <> '') AS context_ids,160             max(last_seen_at) AS last_seen161      FROM e GROUP BY 1,2,3,4,5,6,7,8,9,10,11162    ), r AS (163      SELECT a.*, row_number() OVER (PARTITION BY a.relationship_type ORDER BY a.lvl, a.support DESC, a.last_seen DESC NULLS LAST, a.n_id, a.via_id) AS rn,164             count(*) OVER (PARTITION BY a.relationship_type) AS total165      FROM a166    )167    SELECT r.relationship_type, r.outgoing, r.ctx_only, r.n_type, r.n_id, r.via_type, r.via_id, r.direction, r.evidence_level, r.evidence_category, r.source_id,168           r.support, r.edge_ids, r.provenance_ids, r.context_ids, r.last_seen, r.total,169           coalesce(c.slug, g.symbol, v.slug, d.slug) AS n_ref,170           coalesce(c.canonical_name, g.symbol, coalesce(v.gene_symbol || ' ', '') || v.name, d.name) AS n_label,171           coalesce(c.entity_type, g.name, v.variant_type, d.kind) AS n_sublabel,172           coalesce(vc.slug, vg.symbol, vv.slug, vd.slug) AS via_ref,173           coalesce(vc.canonical_name, vg.symbol, coalesce(vv.gene_symbol || ' ', '') || vv.name, vd.name) AS via_label174    FROM r175    LEFT JOIN cancers c ON r.n_type = 'cancer' AND c.id = r.n_id176    LEFT JOIN genes g ON r.n_type = 'gene' AND g.id = r.n_id177    LEFT JOIN variants v ON r.n_type = 'variant' AND v.id = r.n_id178    LEFT JOIN drugs d ON r.n_type = 'drug' AND d.id = r.n_id179    LEFT JOIN cancers vc ON r.via_type = 'cancer' AND vc.id = r.via_id180    LEFT JOIN genes vg ON r.via_type = 'gene' AND vg.id = r.via_id181    LEFT JOIN variants vv ON r.via_type = 'variant' AND vv.id = r.via_id182    LEFT JOIN drugs vd ON r.via_type = 'drug' AND vd.id = r.via_id183    WHERE r.rn <= ${limit}184    ORDER BY r.relationship_type, r.rn`);185}186187// ------------------------------------------------------------------ derived registry links188189const total = (rows: Array<{ total: string }>) => (rows.length ? num(rows[0]!.total) : 0);190191async function frequencyLinks(db: Database, side: 'cancer' | 'gene', focus: Node, ids: string[], limit: number): Promise<Derived> {192  type Row = { gene_id: string; symbol: string; is_cancer_gene: boolean; cancer_id: string; cancer_slug: string; cancer_name: string; alteration_type: string; cases_affected: number; cases_profiled: number; frequency: number; study_id: string; source_id: string; provenance_id: number; cohorts: string; total: string };193  const where = side === 'cancer' ? sql`f.cancer_id IN ${inList(ids)} AND f.gene_id IS NOT NULL` : sql`f.gene_id = ${focus.id} AND f.cancer_id IS NOT NULL`;194  const part = side === 'cancer' ? sql`f.gene_id` : sql`f.cancer_id`;195  const order = side === 'cancer' ? sql`g.is_cancer_gene DESC, f.frequency DESC, g.symbol` : sql`f.frequency DESC, c.canonical_name`;196  const rows = await db.execute<Row>(sql`197    WITH f AS (198      SELECT f.gene_id, f.cancer_id, f.alteration_type, f.cases_affected, f.cases_profiled, f.frequency, f.provenance_id, co.study_id, co.source_id,199             count(*) OVER (PARTITION BY ${part}) AS cohorts, row_number() OVER (PARTITION BY ${part} ORDER BY f.cases_profiled DESC, f.frequency DESC, f.id) AS rn200      FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id201      WHERE ${where} AND f.frequency >= ${FREQ_MIN} AND f.cases_affected >= ${CASES_MIN}202    )203    SELECT f.*, g.symbol, g.is_cancer_gene, c.slug AS cancer_slug, c.canonical_name AS cancer_name, count(*) OVER() AS total204    FROM f JOIN genes g ON g.id = f.gene_id JOIN cancers c ON c.id = f.cancer_id WHERE f.rn = 1 ORDER BY ${order} LIMIT ${limit}`);205  return {206    relationshipType: 'ALTERED_IN',207    total: total(rows),208    links: rows.map((r) => ({209      node: side === 'cancer' ? { type: 'gene', id: r.gene_id, ref: r.symbol, label: r.symbol, sublabel: r.is_cancer_gene ? 'cancer gene' : null, href: href('gene', r.symbol) } : { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, href: href('cancer', r.cancer_slug) },210      edge: {211        relationshipType: 'ALTERED_IN',212        outgoing: side === 'gene',213        direction: null,214        evidenceLevel: null,215        evidenceCategory: 'observed_data',216        cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }],217        supportCount: num(r.cohorts),218        sourceIds: [r.source_id],219        provenanceIds: [r.provenance_id],220        derived: true,221        detail: `${r.cases_affected} / ${r.cases_profiled} cases (${pct(r.frequency)}) · ${r.alteration_type} · ${r.study_id}`,222        frequency: r.frequency,223        casesAffected: r.cases_affected,224        casesProfiled: r.cases_profiled,225        cohorts: num(r.cohorts),226      } as Edge,227    })),228  };229}230231const trialNode = (r: { id: string; nct_id: string; overall_status: string | null; phases: string[] }): Node => ({ type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.join('/'), r.overall_status].filter(Boolean).join(' · ') || null, href: href('trial', r.nct_id) });232233async function cancerTrialLinks(db: Database, focus: Node, ids: string[], limit: number): Promise<Derived> {234  type Row = { id: string; nct_id: string; brief_title: string; overall_status: string | null; phases: string[]; last_update_posted_date: string | null; cancer_id: string; cancer_slug: string; cancer_name: string; match_type: string; total: string; active: string };235  const rows = await db.execute<Row>(sql`236    WITH m AS (SELECT DISTINCT ON (tc.trial_id) tc.trial_id, tc.cancer_id, tc.match_type FROM trial_conditions tc WHERE tc.cancer_id IN ${inList(ids)} ORDER BY tc.trial_id, (tc.cancer_id = ${focus.id}) DESC, tc.id)237    SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date, m.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, m.match_type,238           count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active239    FROM m JOIN clinical_trials t ON t.id = m.trial_id JOIN cancers c ON c.id = m.cancer_id240    ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`);241  const tot = total(rows);242  const active = rows.length ? num(rows[0]!.active) : 0;243  return {244    relationshipType: 'STUDIED_IN',245    total: tot,246    links: rows.map((r) => ({247      node: trialNode(r),248      edge: { relationshipType: 'STUDIED_IN', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${r.brief_title} · condition mapped ${r.match_type}`, date: r.last_update_posted_date, trialsTotal: tot, trialsActive: active } as Edge,249    })),250  };251}252253async function cancerDrugTrialLinks(db: Database, focus: Node, ids: string[], limit: number): Promise<Derived> {254  type Row = { drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string };255  const rows = await db.execute<Row>(sql`256    SELECT ti.drug_id, d.slug, d.name, d.kind, count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active, max(t.last_update_posted_date) AS last, count(*) OVER() AS total257    FROM trial_conditions tc JOIN trial_interventions ti ON ti.trial_id = tc.trial_id AND ti.drug_id IS NOT NULL JOIN clinical_trials t ON t.id = tc.trial_id JOIN drugs d ON d.id = ti.drug_id258    WHERE tc.cancer_id IN ${inList(ids)} GROUP BY ti.drug_id, d.slug, d.name, d.kind ORDER BY trials DESC, d.name LIMIT ${limit}`);259  return {260    relationshipType: 'INVESTIGATED_IN_TRIALS',261    total: total(rows),262    links: rows.map((r) => ({263      node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) },264      edge: { relationshipType: 'INVESTIGATED_IN_TRIALS', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: focus.id, name: focus.label, slug: focus.ref ?? '' }], supportCount: num(r.trials), sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${num(r.trials)} trials (${num(r.active)} active) · roll-up of the cancer and its descendants`, date: r.last, trialsTotal: num(r.trials), trialsActive: num(r.active) } as Edge,265    })),266  };267}268269async function approvalLinks(db: Database, side: 'cancer' | 'drug', focus: Node, ids: string[], limit: number): Promise<Derived> {270  type Row = { id: number; drug_id: string; drug_slug: string; drug_name: string; kind: string | null; cancer_id: string | null; cancer_slug: string | null; cancer_name: string | null; tumor_agnostic: boolean; jurisdiction: string; authority: string; indication: string; approval_date: string | null; status: string; source_id: string; provenance_id: number; total: string };271  const where = side === 'cancer' ? sql`a.cancer_id IN ${inList(ids)} AND NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'APPROVED_FOR' AND ke.source_entity_id = a.drug_id AND ke.target_entity_id = ${focus.id})` : sql`a.drug_id = ${focus.id} AND (a.cancer_id IS NULL OR NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'APPROVED_FOR' AND ke.source_entity_id = a.drug_id AND ke.target_entity_id = a.cancer_id))`;272  const rows = await db.execute<Row>(sql`273    SELECT a.id, a.drug_id, d.slug AS drug_slug, d.name AS drug_name, d.kind, a.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, a.approval_date, a.status, a.source_id, a.provenance_id, count(*) OVER() AS total274    FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id WHERE ${where} ORDER BY a.approval_date DESC NULLS LAST, a.id LIMIT ${limit}`);275  return {276    relationshipType: 'APPROVED_FOR',277    total: total(rows),278    links: rows.map((r) => {279      const node: Node =280        side === 'cancer'281          ? { type: 'drug', id: r.drug_id, ref: r.drug_slug, label: r.drug_name, sublabel: r.kind, href: href('drug', r.drug_slug) }282          : r.cancer_id && r.cancer_slug && r.cancer_name283            ? { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, href: href('cancer', r.cancer_slug) }284            : { type: 'approval', id: `approval:${r.id}`, ref: null, label: `${r.authority} · ${r.jurisdiction}${r.approval_date ? ` · ${r.approval_date.slice(0, 4)}` : ''}`, sublabel: r.indication, href: `/drug/${r.drug_slug}#approvals` };285      return {286        node,287        edge: { relationshipType: 'APPROVED_FOR', outgoing: side === 'drug', direction: null, evidenceLevel: r.status, evidenceCategory: 'regulatory_status', cancerContext: r.cancer_id && r.cancer_slug && r.cancer_name ? [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }] : [], supportCount: 1, sourceIds: [r.source_id], provenanceIds: [r.provenance_id], derived: true, detail: `${r.authority} (${r.jurisdiction}) · ${r.status}${r.tumor_agnostic ? ' · tumour-agnostic' : ''}`, date: r.approval_date, jurisdiction: r.jurisdiction, authority: r.authority, status: r.status, indication: r.indication, tumorAgnostic: r.tumor_agnostic } as Edge,288      };289    }),290  };291}292293async function geneVariantLinks(db: Database, focus: Node, limit: number): Promise<Derived> {294  type Row = { id: string; slug: string; name: string; variant_type: string | null; ev: string; context_ids: string[] | null; provenance_ids: number[] | null; total: string };295  const rows = await db.execute<Row>(sql`296    WITH ev AS (297      SELECT vid, count(*) AS ev, (array_agg(DISTINCT e.cancer_id) FILTER (WHERE e.cancer_id IS NOT NULL))[1:5] AS context_ids, (array_agg(DISTINCT e.provenance_id))[1:20] AS provenance_ids298      FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.variant_ids) vid WHERE e.status = 'ACCEPTED' AND (${focus.id} = ANY(e.gene_ids) OR ${focus.label} = ANY(e.gene_symbols)) GROUP BY vid299    )300    SELECT v.id, v.slug, v.name, v.variant_type, coalesce(ev.ev, 0) AS ev, ev.context_ids, ev.provenance_ids, count(*) OVER() AS total301    FROM variants v LEFT JOIN ev ON ev.vid = v.id WHERE v.gene_id = ${focus.id} ORDER BY coalesce(ev.ev, 0) DESC, v.name LIMIT ${limit}`);302  return {303    relationshipType: 'HAS_VARIANT',304    total: total(rows),305    links: rows.map((r) => ({306      node: { type: 'variant', id: r.id, ref: r.slug, label: r.name, sublabel: r.variant_type, href: href('variant', r.slug) },307      edge: { relationshipType: 'HAS_VARIANT', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: num(r.ev) > 0 ? 'curated_evidence' : 'observed_data', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: num(r.ev), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, detail: `${num(r.ev)} accepted CIViC evidence items` } as Edge,308    })),309  };310}311312async function variantEvidenceLinks(db: Database, focus: Node, limit: number): Promise<Derived> {313  type Row = { cancer_id: string; slug: string; name: string; items: string; levels: string[] | null; best: string | null; sens: string; res: string; supports: string; does_not: string; provenance_ids: number[]; total: string };314  const rows = await db.execute<Row>(sql`315    SELECT e.cancer_id, c.slug, c.canonical_name AS name, count(*) AS items, array_agg(DISTINCT e.evidence_level ORDER BY e.evidence_level) FILTER (WHERE e.evidence_level IS NOT NULL) AS levels, min(e.evidence_level) AS best,316           count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,317           count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not,318           (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total319    FROM civic_evidence_items e JOIN cancers c ON c.id = e.cancer_id WHERE e.status = 'ACCEPTED' AND ${focus.id} = ANY(e.variant_ids)320    GROUP BY e.cancer_id, c.slug, c.canonical_name ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, c.canonical_name LIMIT ${limit}`);321  return {322    relationshipType: 'HAS_EVIDENCE_IN',323    total: total(rows),324    links: rows.map((r) => {325      const sens = num(r.sens);326      const res = num(r.res);327      return {328        node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) },329        edge: { relationshipType: 'HAS_EVIDENCE_IN', outgoing: true, direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : num(r.supports) && !num(r.does_not) ? 'supports' : num(r.does_not) ? 'does not support' : null, evidenceLevel: r.best, evidenceCategory: 'curated_evidence', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: num(r.items), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, levels: r.levels ?? [], sensitivity: sens, resistance: res, supports: num(r.supports), doesNotSupport: num(r.does_not) } as Edge,330      };331    }),332  };333}334335async function variantDrugCivicLinks(db: Database, focus: Node, limit: number): Promise<Derived> {336  type Row = { drug_id: string; slug: string; name: string; kind: string | null; items: string; best: string | null; sens: string; res: string; context_ids: string[] | null; provenance_ids: number[]; total: string };337  const rows = await db.execute<Row>(sql`338    SELECT tid AS drug_id, d.slug, d.name, d.kind, count(*) AS items, min(e.evidence_level) AS best, count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,339           (array_agg(DISTINCT e.cancer_id) FILTER (WHERE e.cancer_id IS NOT NULL))[1:5] AS context_ids, (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total340    FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid341    WHERE e.status = 'ACCEPTED' AND e.evidence_type = 'PREDICTIVE' AND ${focus.id} = ANY(e.variant_ids)342      AND NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'PREDICTS_RESPONSE_TO' AND ke.source_entity_id = ${focus.id} AND ke.target_entity_id = tid)343    GROUP BY tid, d.slug, d.name, d.kind ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, d.name LIMIT ${limit}`);344  return {345    relationshipType: 'PREDICTS_RESPONSE_TO',346    total: total(rows),347    links: rows.map((r) => {348      const sens = num(r.sens);349      const res = num(r.res);350      return {351        node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) },352        edge: { relationshipType: 'PREDICTS_RESPONSE_TO', outgoing: true, direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : null, evidenceLevel: r.best, evidenceCategory: 'curated_evidence', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: num(r.items), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, detail: `${num(r.items)} accepted predictive items aggregated from CIViC (no knowledge edge yet)` } as Edge,353      };354    }),355  };356}357358async function drugTrialLinks(db: Database, focus: Node, limit: number): Promise<Derived> {359  type Row = { id: string; nct_id: string; brief_title: string; overall_status: string | null; phases: string[]; last_update_posted_date: string | null; context_ids: string[] | null; total: string; active: string };360  const rows = await db.execute<Row>(sql`361    SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date,362           (SELECT (array_agg(DISTINCT tc.cancer_id))[1:5] FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id IS NOT NULL) AS context_ids,363           count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active364    FROM (SELECT DISTINCT trial_id FROM trial_interventions WHERE drug_id = ${focus.id}) ti JOIN clinical_trials t ON t.id = ti.trial_id365    ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`);366  const tot = total(rows);367  const active = rows.length ? num(rows[0]!.active) : 0;368  return {369    relationshipType: 'STUDIED_IN',370    total: tot,371    links: rows.map((r) => ({372      node: trialNode(r),373      edge: { relationshipType: 'STUDIED_IN', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: r.brief_title, date: r.last_update_posted_date, trialsTotal: tot, trialsActive: active } as Edge,374    })),375  };376}377378async function drugCancerTrialLinks(db: Database, focus: Node, limit: number): Promise<Derived> {379  type Row = { cancer_id: string; slug: string; name: string; trials: string; active: string; last: string | null; total: string };380  const rows = await db.execute<Row>(sql`381    SELECT tc.cancer_id, c.slug, c.canonical_name AS name, count(DISTINCT tc.trial_id) AS trials, count(DISTINCT tc.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active, max(t.last_update_posted_date) AS last, count(*) OVER() AS total382    FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IS NOT NULL JOIN clinical_trials t ON t.id = ti.trial_id JOIN cancers c ON c.id = tc.cancer_id383    WHERE ti.drug_id = ${focus.id} GROUP BY tc.cancer_id, c.slug, c.canonical_name ORDER BY trials DESC, c.canonical_name LIMIT ${limit}`);384  return {385    relationshipType: 'INVESTIGATED_IN_TRIALS',386    total: total(rows),387    links: rows.map((r) => ({388      node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) },389      edge: { relationshipType: 'INVESTIGATED_IN_TRIALS', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: num(r.trials), sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${num(r.trials)} trials (${num(r.active)} active) · conditions mapped to this cancer only`, date: r.last, trialsTotal: num(r.trials), trialsActive: num(r.active) } as Edge,390    })),391  };392}393394async function trialLinks(db: Database, focus: Node, limit: number): Promise<Derived[]> {395  type CRow = { cancer_id: string; slug: string; name: string; match_type: string; condition_text: string; total: string };396  type DRow = { drug_id: string; slug: string; name: string; kind: string | null; match_type: string; intervention_type: string | null; iname: string; total: string };397  const [conds, ints] = await Promise.all([398    db.execute<CRow>(sql`SELECT tc.cancer_id, c.slug, c.canonical_name AS name, tc.match_type, tc.condition_text, count(*) OVER() AS total FROM trial_conditions tc JOIN cancers c ON c.id = tc.cancer_id WHERE tc.trial_id = ${focus.id} ORDER BY c.canonical_name LIMIT ${limit}`),399    db.execute<DRow>(sql`SELECT ti.drug_id, d.slug, d.name, d.kind, ti.match_type, ti.intervention_type, ti.name AS iname, count(*) OVER() AS total FROM trial_interventions ti JOIN drugs d ON d.id = ti.drug_id WHERE ti.trial_id = ${focus.id} ORDER BY d.name LIMIT ${limit}`),400  ]);401  return [402    {403      relationshipType: 'CONDITION_OF',404      total: total(conds),405      links: conds.map((r) => ({ node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) }, edge: { relationshipType: 'CONDITION_OF', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `registry condition "${r.condition_text}" mapped ${r.match_type}`, matchType: r.match_type } as Edge })),406    },407    {408      relationshipType: 'INTERVENTION_OF',409      total: total(ints),410      links: ints.map((r) => ({ node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) }, edge: { relationshipType: 'INTERVENTION_OF', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `registry intervention "${r.iname}" (${r.intervention_type ?? 'type not stated'}) mapped ${r.match_type}`, matchType: r.match_type } as Edge })),411    },412  ];413}414415async function cancerNames(db: Database, ids: Iterable<string>): Promise<Map<string, { id: string; name: string; slug: string }>> {416  const uniq = [...new Set(ids)].filter(Boolean);417  if (uniq.length === 0) return new Map();418  const rows = await db.execute<{ id: string; slug: string; name: string }>(sql`SELECT id, slug, canonical_name AS name FROM cancers WHERE id IN ${inList(uniq)}`);419  return new Map(rows.map((r) => [r.id, { id: r.id, slug: r.slug, name: r.name }]));420}421422async function focusIds(db: Database, focus: Node): Promise<string[]> {423  if (focus.type !== 'cancer') return [];424  const ids = await descendantIds(db, focus.id);425  return ids.length > MAX_DESCENDANTS ? [focus.id, ...ids.filter((i) => i !== focus.id).slice(0, MAX_DESCENDANTS - 1)] : ids;426}427428// ------------------------------------------------------------------ routes429430export const graphRoutes: FastifyPluginAsyncZod = async (app) => {431  const params = z.object({ type: z.enum(TYPES).describe('Entity type'), id: z.string().min(1).max(200).describe('CI id, slug, HGNC symbol or NCT id') });432433  app.get(434    '/graph/:type/:id',435    {436      schema: {437        tags: ['graph'],438        summary: 'Contextual neighbourhood of one entity: source-native knowledge edges (with cancer context, direction, evidence level, provenance) plus derived registry links',439        params,440        querystring: z.object({441          limit: z.coerce.number().int().min(1).max(200).default(25).describe('Edges per relationship type (trials default 10)'),442          rel: z.string().trim().toUpperCase().max(40).optional().describe('Only this relationship type (e.g. PREDICTS_RESPONSE_TO)'),443          context: z.string().trim().max(200).optional().describe('Only knowledge edges whose cancer context includes this cancer (CI id or slug)'),444          includeDerived: boolQuery.describe('Include derived registry links (default true)'),445        }),446        response: ok(AnyRecord),447      },448    },449    async (req) => {450      const db = app.db;451      const focus = await resolveFocus(db, req.params.type, req.params.id);452      const q = req.query;453      const includeDerived = q.includeDerived ?? true;454      const rel = q.rel || null;455      const contextId = q.context ? (await resolveCancer(db, q.context)).id : null;456      const ids = await focusIds(db, focus);457      const trialLimit = q.limit === 25 ? TRIAL_LIMIT : q.limit;458459      const tasks: Array<Promise<Derived | Derived[]>> = [];460      if (includeDerived) {461        switch (focus.type) {462          case 'cancer':463            tasks.push(cancerTrialLinks(db, focus, ids, trialLimit), frequencyLinks(db, 'cancer', focus, ids, q.limit), approvalLinks(db, 'cancer', focus, ids, q.limit), cancerDrugTrialLinks(db, focus, ids, q.limit));464            break;465          case 'gene':466            tasks.push(geneVariantLinks(db, focus, q.limit), frequencyLinks(db, 'gene', focus, [], q.limit));467            break;468          case 'variant':469            tasks.push(variantEvidenceLinks(db, focus, q.limit), variantDrugCivicLinks(db, focus, q.limit));470            break;471          case 'drug':472            tasks.push(drugTrialLinks(db, focus, trialLimit), drugCancerTrialLinks(db, focus, q.limit), approvalLinks(db, 'drug', focus, [], q.limit));473            break;474          case 'trial':475            tasks.push(trialLinks(db, focus, q.limit));476            break;477        }478      }479      const [ke, ...derivedRaw] = await Promise.all([focus.type === 'trial' ? Promise.resolve([] as KeRow[]) : knowledgeEdges(db, focus, q.limit, rel, contextId), ...tasks]);480      let derived = derivedRaw.flat();481      if (rel) derived = derived.filter((d) => d.relationshipType === rel);482      if (contextId) derived = derived.map((d) => ({ ...d, links: d.links.filter((l) => l.edge.cancerContext.some((c) => c.id === contextId)) })).filter((d) => d.links.length);483484      const ctxIds = new Set<string>();485      for (const r of ke) for (const c of r.context_ids ?? []) ctxIds.add(c);486      for (const d of derived) for (const l of d.links) for (const c of l.edge.cancerContext) if (!c.slug) ctxIds.add(c.id);487      const names = await cancerNames(db, ctxIds);488      const ctx = (list: string[] | null | undefined) => (list ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }).sort((a, b) => a.name.localeCompare(b.name));489490      const neighbors = new Map<string, { node: Node; edges: Edge[] }>();491      const groups: Record<string, number> = {};492      const sources = new Set<string>();493      let truncated = false;494      const push = (node: Node, edge: Edge, total: number) => {495        const k = `${node.type}:${node.id}`;496        const cur = neighbors.get(k) ?? { node, edges: [] };497        cur.edges.push(edge);498        neighbors.set(k, cur);499        groups[edge.relationshipType] = Math.max(groups[edge.relationshipType] ?? 0, total);500        for (const s of edge.sourceIds) sources.add(s);501      };502      for (const r of ke) {503        if (!r.n_ref || !r.n_label) continue;504        push(505          { type: r.n_type, id: r.n_id, ref: r.n_ref, label: r.n_label, sublabel: r.n_sublabel, href: href(r.n_type, r.n_ref) },506          {507            relationshipType: r.relationship_type,508            outgoing: r.outgoing,509            direction: r.direction,510            evidenceLevel: r.evidence_level,511            evidenceCategory: r.evidence_category,512            cancerContext: ctx(r.context_ids),513            supportCount: num(r.support),514            sourceIds: [r.source_id],515            provenanceIds: (r.provenance_ids ?? []).map(Number),516            derived: false,517            detail: (r.edge_ids?.length ?? 1) > 1 ? `${r.edge_ids.length} source records aggregated` : null,518            date: r.last_seen ? new Date(r.last_seen).toISOString().slice(0, 10) : null,519            via: r.ctx_only && r.via_type && r.via_id && r.via_ref && r.via_label ? { type: r.via_type, id: r.via_id, label: r.via_label, href: href(r.via_type, r.via_ref) } : null,520            knowledgeEdgeIds: r.edge_ids,521          } as Edge,522          num(r.total),523        );524      }525      for (const d of derived) {526        for (const l of d.links) {527          l.edge.cancerContext = l.edge.cancerContext.map((c) => (c.slug ? c : (names.get(c.id) ?? c)));528          push(l.node, l.edge, d.total);529        }530      }531      const shown: Record<string, number> = {};532      for (const nb of neighbors.values()) for (const e of nb.edges) shown[e.relationshipType] = (shown[e.relationshipType] ?? 0) + 1;533      for (const [k, tot] of Object.entries(groups)) if ((shown[k] ?? 0) < tot) truncated = true;534535      const data = {536        node: focus,537        neighbors: [...neighbors.values()],538        groups,539        truncated,540        limits: { perRelationship: q.limit, trials: trialLimit, descendantsRolledUp: ids.length },541        thresholds: { cohortFrequencyMin: FREQ_MIN, cohortCasesAffectedMin: CASES_MIN },542        note: 'Edges are source-native (never inferred by CancerIndex) and keep their native evidence level; rows with derived=true are counts read from registries (ClinicalTrials.gov, GDC/cBioPortal cohorts, approval records).',543      };544      return respond(app, data, sources);545    },546  );547548  app.get(549    '/graph/:type/:id/paths',550    {551      schema: {552        tags: ['graph'],553        summary: 'Strongest cancer → gene → variant → drug → approval → trials chains (cancer focus only), ranked by evidence level then support',554        params,555        querystring: z.object({ limit: z.coerce.number().int().min(1).max(50).default(8) }),556        response: ok(AnyRecord),557      },558    },559    async (req) => {560      if (req.params.type !== 'cancer') throw new BadRequest('paths are built for cancer foci only');561      const db = app.db;562      const focus = await resolveFocus(db, 'cancer', req.params.id);563      const ids = await focusIds(db, focus);564      type Row = {565        variant_id: string; variant_slug: string; variant_name: string; gene_id: string; symbol: string; drug_id: string; drug_slug: string; drug_name: string;566        evidence_level: string | null; direction: string | null; support: string; source_ids: string[]; provenance_ids: number[]; context_ids: string[];567        frequency: number | null; cases_affected: number | null; cases_profiled: number | null; cohorts: string | null;568        approval_id: number | null; jurisdiction: string | null; authority: string | null; approval_date: string | null; approval_status: string | null; approval_cancer_id: string | null; approval_cancer_name: string | null; tumor_agnostic: boolean | null; approvals: string | null;569        trials: string | null; active_trials: string | null;570      };571      const rows = await db.execute<Row>(sql`572        WITH ids AS (SELECT unnest(ARRAY[${sql.join(ids.map((i) => sql`${i}`), sql`, `)}]::varchar[]) AS id),573        ed AS (574          SELECT ke.source_entity_id AS variant_id, ke.target_entity_id AS drug_id, min(${LEVEL_RANK}) AS lvl, min(ke.evidence_level) AS evidence_level, min(ke.direction) AS direction,575                 sum(ke.support_count) AS support, array_agg(DISTINCT ke.source_id) AS source_ids,576                 (SELECT (array_agg(DISTINCT x::int ORDER BY x::int))[1:50] FROM unnest(string_to_array(string_agg(array_to_string(ke.provenance_ids, ','), ','), ',')) x WHERE x <> '') AS provenance_ids,577                 (SELECT array_agg(DISTINCT x ORDER BY x) FROM unnest(string_to_array(string_agg(array_to_string(ke.cancer_context_ids, ','), ','), ',')) x WHERE x <> '' AND x IN (SELECT id FROM ids)) AS context_ids578          FROM knowledge_edges ke579          WHERE ke.status = 'active' AND ke.relationship_type = 'PREDICTS_RESPONSE_TO' AND ke.direction = 'sensitivity' AND ke.source_entity_type = 'variant' AND ke.target_entity_type = 'drug'580            AND ke.cancer_context_ids && (SELECT array_agg(id)::text[] FROM ids)581          GROUP BY ke.source_entity_id, ke.target_entity_id582        ),583        fq AS (584          SELECT DISTINCT ON (f.gene_id) f.gene_id, f.frequency, f.cases_affected, f.cases_profiled, count(*) OVER (PARTITION BY f.gene_id) AS cohorts585          FROM cancer_gene_frequencies f WHERE f.cancer_id IN (SELECT id FROM ids) AND f.gene_id IS NOT NULL AND f.cases_affected >= ${CASES_MIN}586          ORDER BY f.gene_id, f.cases_profiled DESC, f.frequency DESC587        )588        SELECT ed.variant_id, v.slug AS variant_slug, v.name AS variant_name, g.id AS gene_id, g.symbol, ed.drug_id, d.slug AS drug_slug, d.name AS drug_name,589               ed.evidence_level, ed.direction, ed.support, ed.source_ids, ed.provenance_ids, ed.context_ids,590               fq.frequency, fq.cases_affected, fq.cases_profiled, fq.cohorts,591               ap.id AS approval_id, ap.jurisdiction, ap.authority, ap.approval_date, ap.status AS approval_status, ap.cancer_id AS approval_cancer_id, ac.canonical_name AS approval_cancer_name, ap.tumor_agnostic, ap.approvals,592               tr.trials, tr.active_trials593        FROM ed JOIN variants v ON v.id = ed.variant_id JOIN genes g ON g.id = v.gene_id JOIN drugs d ON d.id = ed.drug_id594        LEFT JOIN fq ON fq.gene_id = g.id595        LEFT JOIN LATERAL (596          SELECT a.id, a.jurisdiction, a.authority, a.approval_date, a.status, a.cancer_id, a.tumor_agnostic, count(*) OVER() AS approvals597          FROM drug_approvals a WHERE a.drug_id = ed.drug_id AND (a.cancer_id IN (SELECT id FROM ids) OR a.tumor_agnostic)598          ORDER BY (a.cancer_id IS NOT NULL) DESC, a.approval_date ASC NULLS LAST, a.id LIMIT 1599        ) ap ON true600        LEFT JOIN cancers ac ON ac.id = ap.cancer_id601        LEFT JOIN LATERAL (602          SELECT count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active_trials603          FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IN (SELECT id FROM ids) JOIN clinical_trials t ON t.id = ti.trial_id604          WHERE ti.drug_id = ed.drug_id605        ) tr ON true606        ORDER BY ed.lvl, ed.support DESC, fq.frequency DESC NULLS LAST, g.symbol, v.name, d.name607        LIMIT ${req.query.limit}`);608      const names = await cancerNames(db, rows.flatMap((r) => r.context_ids ?? []));609      const sources = new Set<string>(['clinicaltrials', 'openfda']);610      const chains = rows.map((r) => {611        for (const s of r.source_ids ?? []) sources.add(s);612        return {613          cancer: { id: focus.id, slug: focus.ref, name: focus.label },614          gene: { id: r.gene_id, symbol: r.symbol, frequency: r.frequency, casesAffected: r.cases_affected, casesProfiled: r.cases_profiled, cohorts: num(r.cohorts), claim: 'observed_data' },615          variant: { id: r.variant_id, slug: r.variant_slug, name: r.variant_name },616          drug: { id: r.drug_id, slug: r.drug_slug, name: r.drug_name },617          edge: { relationshipType: 'PREDICTS_RESPONSE_TO', evidenceLevel: r.evidence_level, direction: r.direction, supportCount: num(r.support), sourceIds: r.source_ids ?? [], provenanceIds: (r.provenance_ids ?? []).map(Number), cancerContext: (r.context_ids ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }), claim: 'curated_evidence' },618          approval: r.approval_id ? { id: Number(r.approval_id), jurisdiction: r.jurisdiction, authority: r.authority, approvalDate: r.approval_date, status: r.approval_status, cancerId: r.approval_cancer_id, cancerName: r.approval_cancer_name, tumorAgnostic: !!r.tumor_agnostic, total: num(r.approvals), claim: 'regulatory_status' } : null,619          trials: r.trials !== null && r.trials !== undefined ? { total: num(r.trials), active: num(r.active_trials), claim: 'observed_data' } : null,620        };621      });622      return respond(app, { node: focus, chains, descendantsRolledUp: ids.length, ranking: 'evidence level (native CIViC A–E), then support count, then cohort frequency', note: 'Each hop keeps its own claim category; a missing hop is null, never filled in. Not treatment guidance.' }, sources);623    },624  );625};626