# Knowledge graph — cancer · gene · variant · drug · trial The knowledge graph at `/graph` (API: `GET /v1/graph/:type/:id`) shows the **contextual neighbourhood of one entity**: which cancers, genes, variants, drugs and trials a source connects to it, with the cancer context, direction, evidence level, claim category and provenance of every link (CLAUDE.md non-negotiable 7, SPEC §29, §244). It is a reading aid over data CancerIndex already holds — it never adds knowledge of its own. ## Nodes | Type | Public id | Focus reference (`/graph?focus=`) | Entity page | |---|---|---|---| | cancer | `CI-CAN-…` | `cancer:` | `/cancer/` | | gene | `CI-GENE-…` | `gene:` (aliases and `HGNC:n` resolve) | `/gene/` | | variant | `CI-VAR-…` | `variant:` | `/variant/` | | drug | `CI-DRUG-…` | `drug:` | `/drug/` | | trial | `CI-TRIAL-…` | `trial:` | `/trial/` | | approval | `drug_approvals.id` (display only) | not focusable | `/drug/#approvals` | A bare NCT id, a bare CI id or an upper-case symbol is also accepted as `focus`. Approval nodes appear only for a **drug focus** when an approval record has no mapped cancer (the indication text is the label); approvals with a mapped cancer are drawn as `APPROVED_FOR` edges to that cancer. The **default focus** is the cancer with the most knowledge edges (source, target or context), computed at request time. The six "most connected" quick links (two cancers, two genes, two drugs) come from the same count. Nothing is hardcoded. ## Edges Every edge — drawn or tabulated — carries: - `relationshipType` (source-native or derived, see below), `outgoing` (focus → neighbour or neighbour → focus), `direction` (supports / sensitivity / resistance / unknown / mixed); - `evidenceLevel` **as stated by the source** (CIViC A–E, ChEMBL max phase 1–4 or mechanism action type, openFDA application type, approval status). Scales are never converted into one another; they are only *ordered* for display (A, FDA ≻ B, phase 4 ≻ C, phase 3 ≻ D, phase 2 ≻ E, phase 1 ≻ others ≻ unknown); - `evidenceCategory` → claim badge (observed / published / curated / regulatory / guideline / computed), never merged; - `cancerContext` (`cancer_context_ids` resolved to names — the disease the source stated, which may be a descendant of the focus), `supportCount`, `sourceIds`, `provenanceIds`, `derived`. ### Source-native edges (`knowledge_edges`, `derived: false`, solid spokes) Rows written by connectors, one per source record, with `status = 'active'`: | Relationship | Direction of the row | Source | Native level | |---|---|---|---| | `PREDICTS_RESPONSE_TO` | variant → drug (cancer in context) | CIViC | A–E | | `ASSOCIATED_WITH` | gene → cancer | CIViC | A–E | | `PROGNOSTIC_IN` / `DIAGNOSTIC_OF` / `PREDISPOSES_TO` | variant → cancer | CIViC | A–E | | `INVESTIGATED_FOR` | drug → cancer | ChEMBL (max phase), CIViC | 1–4 / A–E | | `TARGETS` | drug → gene | ChEMBL mechanisms | action type (e.g. INHIBITOR) | | `APPROVED_FOR` | drug → cancer | openFDA | application type (FDA ORIG) | For display the rows are **aggregated per (neighbour, relationship, direction, level, source)**: `supportCount` is the sum of the rows' `support_count`, `provenanceIds` the union of their provenance rows, `cancerContext` the union of their contexts, and the table says "*n* source records aggregated". Aggregation groups identical claims; it never combines different directions or levels into one. For a **cancer focus**, edges where the cancer is only the *context* (variant → drug **in this cancer**) are included too: the neighbour is the variant, the drug is shown as "→ predicts response to " (`via` in the API). These are the edges the "Paths" panel chains. ### Derived registry links (`derived: true`, dashed spokes) Counts and measurements read from registry tables at query time. They state that two entities co-occur in a registry, **not** that a source asserted a biological or clinical relationship. | Relationship | Focus → neighbour | Computed from | Shown as | |---|---|---|---| | `STUDIED_IN` | cancer → trial; drug → trial | `trial_conditions.cancer_id` (cancer + descendants) ; `trial_interventions.drug_id` | top 10 by `last_update_posted_date`, plus total and active count (`RECRUITING`, `NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`) | | `INVESTIGATED_IN_TRIALS` | cancer ↔ drug | `trial_interventions.drug_id × trial_conditions.cancer_id` | trial count and active count per pair | | `ALTERED_IN` | cancer ↔ gene | `cancer_gene_frequencies` (GDC / cBioPortal cohorts) | **the cohort with the largest denominator** for the pair: `cases_affected / cases_profiled (frequency)`, alteration type, study id, number of qualifying cohorts. Thresholds: frequency ≥ 0.05 **and** cases affected ≥ 20 on that cohort row. Genes flagged `is_cancer_gene` are listed first | | `APPROVED_FOR` (derived) | cancer ↔ drug / drug → approval | `drug_approvals` | authority, jurisdiction, status, date as published, tumour-agnostic flag; rows already present as an openFDA `APPROVED_FOR` knowledge edge are skipped | | `HAS_VARIANT` | gene → variant | `variants.gene_id` | ordered by accepted CIViC evidence items (curated when > 0, otherwise the bare variant record) | | `HAS_EVIDENCE_IN` | variant → cancer | `civic_evidence_items` (`ACCEPTED`) | items, levels present, sensitivity / resistance counts, supports / does-not-support counts | | `PREDICTS_RESPONSE_TO` (derived) | variant → drug | `civic_evidence_items` predictive items **without** a knowledge edge yet | gap filler, flagged derived | | `CONDITION_OF` / `INTERVENTION_OF` | cancer → trial / drug → trial (trial focus) | `trial_conditions`, `trial_interventions` | registry text and `match_type` of the mapping | Registry counts have no `provenance` row of their own (they are not a published number); the edge names the registry source and the query definition above, and cohort frequencies and approvals carry the provenance id of the underlying row. Cancer foci roll up **descendants** (all hierarchy types, depth ≤ 12, capped at 600 ids) for trials, cohort frequencies and approvals, and the edge's `cancerContext` names the descendant that actually matched. Source-native edges are not rolled up: they must name the focus itself (or have it in context). ## Caps and truncation - Per relationship type: 25 aggregated edges (10 for `STUDIED_IN`), ranked by native level, then support, then recency. `?more=` raises **one** group to 200. Each group shows "*shown* of *total*"; the API returns `groups: { [relationshipType]: total }` and `truncated`. - Drawn nodes: at most **60**. Selection is a deterministic round-robin over entity types (each type contributes its highest-degree node in turn) so every type present stays visible; the figure caption states how many neighbours are not drawn. The table lists every fetched edge. - Spokes: at most 3 relationships per neighbour are drawn as parallel lines; all are in the table. - Paths: 8 chains (`?limit` ≤ 50 in the API). ## Layout rules (`apps/web/src/lib/graph-model.ts`, unit-tested) - Focus at the centre. Neighbours sit on arcs grouped by entity type in the **fixed** clockwise order cancer → gene → variant → drug → trial → approval, starting at the top; sectors never reorder by size, so the same focus always draws the same picture. - Sector width ∝ node count with a minimum share; a fixed gap separates sectors; every sector uses one of three alternating ring radii so labels at sector borders do not collide. - Inside a sector nodes are ordered by degree (edges at the node), then label; a lone node sits at the sector's centre. Mark radius = 4 + 2.2·log₂(degree + 1), capped at 13. - Labels run along the spoke (rotated), flipped on the left half, truncated at 18 characters (full text in ``). Entity type is encoded by sector position and caption, mark shape (circle / square / triangle / rounded square / hexagon / diamond) and a muted fill from the design tokens — the picture is readable without colour. - Solid spoke = source-native curated / regulatory / published claim; dashed = derived count or observed data. Every spoke has a `<title>`: relationship · direction · level · context · source. - Server-rendered SVG only (no client graph library); the SVG scrolls horizontally below 560 px and the edge table is the accessible equivalent. ## Paths (cancer focus) `cancer → gene → variant → drug → approval → trials`, built only from what exists: 1. variant → drug: a source-native `PREDICTS_RESPONSE_TO` edge with `direction = 'sensitivity'` whose context contains the cancer or a descendant (aggregated per variant–drug pair); 2. gene: the variant's gene; its cohort frequency in the cancer is the **largest-denominator** cohort row with cases affected ≥ 20 — shown as "not yet available" when no cohort covers it; 3. approval: the earliest `drug_approvals` row for the drug in the cancer (or tumour-agnostic), with authority, jurisdiction, status and date as published; `null` when none; 4. trials: registry trials listing the drug with a condition mapped to the cancer (total / active). Ranking: native evidence level, then support count, then cohort frequency, then names (stable). Each hop keeps its own claim category. Chains are descriptive; they are not treatment guidance. ## What the graph never does - **No inferred edges.** Two entities are connected only when a source states the relationship or a registry row literally joins them. No transitive closure, no similarity, no co-citation. - **No LLM edges.** Nothing in the graph is generated or ranked by a language model. - **No re-scaled evidence.** CIViC letters, ChEMBL phases and FDA application types stay in their native form; ordering for display is documented above and never shown as a score. - **No silent merging.** Different directions, levels or sources are separate rows; derived counts are never mixed with curated claims; claim badges are never collapsed. - **No fabricated hops.** A missing frequency, approval or trial count is shown as missing. ## Implementation - Web: `apps/web/src/lib/graph-model.ts` (types, layout, ranking — pure), `apps/web/src/lib/queries/graph.ts` (SQL), `apps/web/src/components/graph/{radial-graph,path-chain,graph-link}.tsx`, `apps/web/src/app/graph/page.tsx`, home teaser `apps/web/src/components/home/graph-module.tsx`. - API: `apps/api/src/routes/graph.ts` — same SQL, duplicated on purpose (the web query module is `server-only`); keep thresholds and ordering in step. - Tests: `apps/web/test/graph-layout.test.ts`.