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:<slug> |
/cancer/<slug> |
| gene | CI-GENE-… |
gene:<HGNC symbol> (aliases and HGNC:n resolve) |
/gene/<symbol> |
| variant | CI-VAR-… |
variant:<slug> |
/variant/<slug> |
| drug | CI-DRUG-… |
drug:<slug> |
/drug/<slug> |
| trial | CI-TRIAL-… |
trial:<NCT id> |
/trial/<nct> |
| approval | drug_approvals.id (display only) |
not focusable | /drug/<slug>#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);evidenceLevelas 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_idsresolved 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=<RELATIONSHIP>raises one group to 200. Each group shows "shown of total"; the API returnsgroups: { [relationshipType]: total }andtruncated. - 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
<title>). 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:
- variant → drug: a source-native
PREDICTS_RESPONSE_TOedge withdirection = 'sensitivity'whose context contains the cancer or a descendant (aggregated per variant–drug pair); - 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;
- approval: the earliest
drug_approvalsrow for the drug in the cancer (or tumour-agnostic), with authority, jurisdiction, status and date as published;nullwhen none; - 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 teaserapps/web/src/components/home/graph-module.tsx. - API:
apps/api/src/routes/graph.ts— same SQL, duplicated on purpose (the web query module isserver-only); keep thresholds and ordering in step. - Tests:
apps/web/test/graph-layout.test.ts.