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%
10.6 KB

# 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);
  • 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.

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 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 <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:

  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.