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%

Knowledge graph: contextual neighbourhoods (source-native edges + derived registry links), radial SVG explorer /graph, cancer→gene→variant→drug→approval→trial paths, /v1/graph API, methodology

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 33aae95

11 changed files +2,904 −5

modified apps/api/src/routes/graph.ts +620 −5
@@ -1,10 +1,625 @@
1 +import { sql, type SQL } from 'drizzle-orm';
1 2 import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
3 +import { z } from 'zod';
4 +import type { Database } from '@cancerindex/database';
5 +import { descendantIds } from '../lib/descendants.js';
6 +import { BadRequest } from '../lib/errors.js';
7 +import { boolQuery } from '../lib/pagination.js';
8 +import { resolveCancer, resolveDrug, resolveGene, resolveTrial, resolveVariant } from '../lib/resolve.js';
9 +import { AnyRecord, num, ok, respond } from '../lib/respond.js';
2 10
3 11 /**
4 − * Knowledge-graph routes (SPEC §18, §80): `GET /graph/:type/:id` → contextual neighbours of one
5 − * entity (cancer | gene | variant | drug | biomarker | trial) with relationship type, cancer context,
6 − * evidence level and provenance on every edge. Filled by the Knowledge Graph work package.
12 + * Knowledge-graph routes (SPEC §18, §80).
13 + *
14 + * GET /graph/:type/:id?limit=&rel=&context=&includeDerived= → contextual neighbourhood of one entity
15 + * 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 per
19 + * (neighbour, relationship, direction, level, source); and `derived: true` registry counts computed
20 + * 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 is
24 + * `server-only` and bound to the Next.js database helpers, so it cannot be imported here. Keep the
25 + * two in step when changing thresholds or ordering.
7 26 */
8 −export const graphRoutes: FastifyPluginAsyncZod = async (_app) => {
9 − /* routes added by the knowledge-graph work package */
27 +
28 +const TYPES = ['cancer', 'gene', 'variant', 'drug', 'trial'] as const;
29 +type FocusType = (typeof TYPES)[number];
30 +type NodeType = FocusType | 'approval';
31 +
32 +const ACTIVE = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'];
33 +const FREQ_MIN = 0.05;
34 +const CASES_MIN = 20;
35 +const TRIAL_LIMIT = 10;
36 +const MAX_DESCENDANTS = 600;
37 +const SRC = { clinicaltrials: 'CI-SOURCE-00000004', civic: 'CI-SOURCE-00000006' } as const;
38 +
39 +const inList = (ids: string[]): SQL => sql`(${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`;
40 +const activeList = (): SQL => sql`(${sql.join(ACTIVE.map((s) => sql`${s}`), sql`, `)})`;
41 +const 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`;
42 +const 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`;
43 +
44 +interface Node {
45 + type: NodeType;
46 + id: string;
47 + ref: string | null;
48 + label: string;
49 + sublabel?: string | null;
50 + href: string;
51 +}
52 +interface 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 +}
67 +interface Link {
68 + node: Node;
69 + edge: Edge;
70 +}
71 +interface Derived {
72 + relationshipType: string;
73 + total: number;
74 + links: Link[];
75 +}
76 +
77 +const 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);
78 +const pct = (v: number) => `${(v * 100).toFixed(v >= 0.1 ? 0 : 1)} %`;
79 +
80 +async 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 +}
109 +
110 +// ------------------------------------------------------------------ source-native edges
111 +
112 +type 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 +};
136 +
137 +async 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 lvl
153 + FROM knowledge_edges ke
154 + 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_seen
161 + FROM e GROUP BY 1,2,3,4,5,6,7,8,9,10,11
162 + ), 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 total
165 + FROM a
166 + )
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_label
174 + FROM r
175 + LEFT JOIN cancers c ON r.n_type = 'cancer' AND c.id = r.n_id
176 + LEFT JOIN genes g ON r.n_type = 'gene' AND g.id = r.n_id
177 + LEFT JOIN variants v ON r.n_type = 'variant' AND v.id = r.n_id
178 + LEFT JOIN drugs d ON r.n_type = 'drug' AND d.id = r.n_id
179 + LEFT JOIN cancers vc ON r.via_type = 'cancer' AND vc.id = r.via_id
180 + LEFT JOIN genes vg ON r.via_type = 'gene' AND vg.id = r.via_id
181 + LEFT JOIN variants vv ON r.via_type = 'variant' AND vv.id = r.via_id
182 + LEFT JOIN drugs vd ON r.via_type = 'drug' AND vd.id = r.via_id
183 + WHERE r.rn <= ${limit}
184 + ORDER BY r.relationship_type, r.rn`);
185 +}
186 +
187 +// ------------------------------------------------------------------ derived registry links
188 +
189 +const total = (rows: Array<{ total: string }>) => (rows.length ? num(rows[0]!.total) : 0);
190 +
191 +async 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 rn
200 + FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id
201 + 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 total
204 + 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 +}
230 +
231 +const 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) });
232 +
233 +async 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 active
239 + FROM m JOIN clinical_trials t ON t.id = m.trial_id JOIN cancers c ON c.id = m.cancer_id
240 + 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 +}
252 +
253 +async 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 total
257 + 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_id
258 + 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 +}
268 +
269 +async 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 total
274 + 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_name
283 + ? { 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 +}
292 +
293 +async 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_ids
298 + 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 vid
299 + )
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 total
301 + 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 +}
311 +
312 +async 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 total
319 + 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 +}
334 +
335 +async 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 total
340 + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid
341 + 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 +}
357 +
358 +async 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 active
364 + FROM (SELECT DISTINCT trial_id FROM trial_interventions WHERE drug_id = ${focus.id}) ti JOIN clinical_trials t ON t.id = ti.trial_id
365 + 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 +}
377 +
378 +async 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 total
382 + 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_id
383 + 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 +}
393 +
394 +async 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 +}
414 +
415 +async 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 +}
421 +
422 +async 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 +}
427 +
428 +// ------------------------------------------------------------------ routes
429 +
430 +export 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') });
432 +
433 + 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;
458 +
459 + 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);
483 +
484 + 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));
489 +
490 + 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;
534 +
535 + 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 + );
547 +
548 + 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_ids
578 + FROM knowledge_edges ke
579 + 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_id
582 + ),
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 cohorts
585 + 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 DESC
587 + )
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_trials
593 + 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_id
594 + LEFT JOIN fq ON fq.gene_id = g.id
595 + 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 approvals
597 + 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 1
599 + ) ap ON true
600 + LEFT JOIN cancers ac ON ac.id = ap.cancer_id
601 + 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_trials
603 + 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_id
604 + WHERE ti.drug_id = ed.drug_id
605 + ) tr ON true
606 + ORDER BY ed.lvl, ed.support DESC, fq.frequency DESC NULLS LAST, g.symbol, v.name, d.name
607 + 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 + );
10 625 };
added apps/web/src/app/graph/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading the knowledge graph" />;
5 +}
added apps/web/src/app/graph/page.tsx +339 −0
@@ -0,0 +1,339 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Section, KV, Note } from '@/components/ui/section';
4 +import { Badge, ClaimBadge, claimKindFromCategory } from '@/components/ui/badge';
5 +import { EmptyState } from '@/components/ui/empty-state';
6 +import { Freshness } from '@/components/ui/freshness';
7 +import { SourceBadge } from '@/components/ui/source-badge';
8 +import { RadialGraph } from '@/components/graph/radial-graph';
9 +import { PathChainView } from '@/components/graph/path-chain';
10 +import { defaultFocus, edgesFreshness, loadNeighborhood, loadPaths, resolveFocus, suggestedFoci, DEFAULT_GROUP_LIMIT, EXPANDED_GROUP_LIMIT, TRIAL_GROUP_LIMIT, FREQ_MIN, CASES_MIN, type FocusSuggestion } from '@/lib/queries/graph';
11 +import { loadProvenance, toInfo } from '@/lib/queries/provenance';
12 +import { type GraphEdge, type GraphNode, NODE_TYPE_LABEL, NODE_TYPE_ORDER, focusHref, nodeKey, parseFocus, relationshipLabel } from '@/lib/graph-model';
13 +import { fmtInt } from '@/lib/format';
14 +import { str, withParams, type SP } from '@/lib/search-params';
15 +
16 +export const dynamic = 'force-dynamic';
17 +
18 +const MAX_DRAWN = 60;
19 +
20 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
21 + const sp = await searchParams;
22 + const f = parseFocus(str(sp, 'focus'));
23 + const node = f ? await resolveFocus(f) : null;
24 + const title = node ? `${node.label} — knowledge graph` : 'Knowledge graph';
25 + return {
26 + title,
27 + description: node ? `Contextual knowledge graph around ${node.label}: source-native cancer–gene–variant–drug edges with evidence level and cancer context, plus derived registry counts (trials, cohort frequencies, approvals).` : 'Cancer–gene–variant–drug–trial knowledge graph: every edge carries its cancer context, direction, evidence level and provenance.',
28 + robots: { index: !str(sp, 'more') },
29 + alternates: { canonical: node ? `/graph?focus=${node.type}:${encodeURIComponent(node.ref)}` : '/graph' },
30 + };
31 +}
32 +
33 +function FocusForm({ focus, suggestions }: { focus: string; suggestions: FocusSuggestion[] }) {
34 + return (
35 + <div className="mt-3">
36 + <form method="get" action="/graph" className="flex max-w-2xl flex-wrap gap-2">
37 + <label htmlFor="focus" className="sr-only">
38 + Focus entity (type:reference)
39 + </label>
40 + <input id="focus" name="focus" defaultValue={focus} placeholder="cancer:melanoma · gene:EGFR · variant:braf-v600e · drug:osimertinib · trial:NCT04487080" className="min-w-0 flex-1 border border-rule-strong bg-white px-3 py-2 text-[14px] outline-none focus:border-accent" spellCheck={false} />
41 + <button type="submit" className="border border-ink bg-ink px-4 py-2 text-[14px] text-paper hover:bg-ink-2">
42 + Focus
43 + </button>
44 + </form>
45 + <p className="mt-1.5 text-[12px] text-ink-3">
46 + Accepted: <code className="ci-mono">cancer:&lt;slug&gt;</code>, <code className="ci-mono">gene:&lt;symbol&gt;</code>, <code className="ci-mono">variant:&lt;slug&gt;</code>, <code className="ci-mono">drug:&lt;slug&gt;</code>, <code className="ci-mono">trial:&lt;NCT id&gt;</code>, or a bare CI id. Find slugs with{' '}
47 + <Link href="/search" className="ci-link">
48 + search
49 + </Link>
50 + .
51 + </p>
52 + {suggestions.length ? (
53 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-ink-3">
54 + <span className="ci-kicker">Most connected</span>
55 + {suggestions.map((s) => (
56 + <Link key={`${s.type}:${s.ref}`} href={`/graph?focus=${s.type}:${encodeURIComponent(s.ref)}`} className="ci-link" title={`${fmtInt(s.edges)} knowledge edges`}>
57 + {s.type === 'gene' ? <span className="ci-mono">{s.label}</span> : s.label} <span className="ci-num text-ink-4">{fmtInt(s.edges)}</span>
58 + </Link>
59 + ))}
60 + </p>
61 + ) : null}
62 + </div>
63 + );
64 +}
65 +
66 +function DirectionCell({ e }: { e: GraphEdge }) {
67 + if (!e.direction) return <span className="text-ink-4">—</span>;
68 + const tone = e.direction === 'resistance' ? 'warn' : e.direction === 'sensitivity' ? 'ok' : e.direction === 'mixed' ? 'warn' : 'neutral';
69 + return <Badge tone={tone}>{e.direction}</Badge>;
70 +}
71 +
72 +function ContextCell({ e }: { e: GraphEdge }) {
73 + if (e.cancerContext.length === 0) return <span className="text-ink-4">{e.derived ? 'not mapped' : '—'}</span>;
74 + const shown = e.cancerContext.slice(0, 3);
75 + return (
76 + <span className="inline-flex flex-wrap gap-x-1.5 gap-y-0.5">
77 + {shown.map((c, i) => (
78 + <span key={c.id}>
79 + {c.slug ? (
80 + <Link href={`/graph?focus=cancer:${c.slug}`} className="ci-link" title={`Explore ${c.name} in the graph`}>
81 + {c.name}
82 + </Link>
83 + ) : (
84 + <span className="ci-mono">{c.id}</span>
85 + )}
86 + {i < shown.length - 1 ? ',' : ''}
87 + </span>
88 + ))}
89 + {e.cancerContext.length > 3 ? <span className="text-ink-3">+{e.cancerContext.length - 3}</span> : null}
90 + </span>
91 + );
92 +}
93 +
94 +export default async function GraphPage({ searchParams }: { searchParams: Promise<SP> }) {
95 + const sp = await searchParams;
96 + const focusParam = str(sp, 'focus').slice(0, 200);
97 + const more = str(sp, 'more').slice(0, 40).toUpperCase() || null;
98 + const suggestions = await suggestedFoci();
99 + const requested = parseFocus(focusParam);
100 + const focusRef = requested ?? (await defaultFocus());
101 + const focus = focusRef ? await resolveFocus(focusRef) : null;
102 +
103 + if (!focus) {
104 + return (
105 + <div>
106 + <PageHeader kicker="Knowledge graph" title="Knowledge graph" lede="Cancer–gene–variant–drug–trial relationships as the sources state them: every edge carries its cancer context, direction, evidence level and provenance. CancerIndex never infers an edge." />
107 + <FocusForm focus={focusParam} suggestions={suggestions} />
108 + <div className="mt-6">
109 + <EmptyState title={requested ? `No ${requested.type} matches “${requested.ref}”` : 'Data not yet available'} knows={[...suggestions.map((s) => ({ label: `${s.label} (${s.type})`, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` })), { label: 'Search entities', href: '/search' }]}>
110 + {requested ? 'The reference must be the entity slug (cancer, variant, drug), the HGNC symbol (gene) or the NCT id (trial). Use search to find it, or pick a suggested focus.' : 'No knowledge edges are loaded on this environment yet.'}
111 + </EmptyState>
112 + </div>
113 + </div>
114 + );
115 + }
116 +
117 + const nb = await loadNeighborhood(focus, { more });
118 + const [paths, freshAt] = await Promise.all([focus.type === 'cancer' ? loadPaths(focus, nb.cancerIds) : Promise.resolve([]), edgesFreshness(focus)]);
119 + const allEdges = nb.groups.flatMap((g) => g.edges);
120 + const prov = await loadProvenance(allEdges.map((e) => e.provenanceIds[0]).filter((x): x is number => typeof x === 'number'));
121 + const nodeByKey = new Map<string, GraphNode>(nb.nodes.map((n) => [nodeKey(n), n]));
122 + const totalEdges = nb.groups.reduce((a, g) => a + g.total, 0);
123 + const current = { focus: `${focus.type}:${focus.ref}`, more: more ?? '' };
124 + const hrefMore = (rel: string | null) => `/graph${withParams(current, { more: rel ?? '' })}#edges`;
125 + const typeOrder = NODE_TYPE_ORDER.filter((t) => nb.degreeByType[t] > 0);
126 +
127 + return (
128 + <article>
129 + <PageHeader kicker={`Knowledge graph · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')}`} title={focus.type === 'gene' ? <span className="ci-mono font-sans">{focus.label}</span> : focus.label} lede={focus.sublabel ? focus.sublabel.replace(/_/g, ' ') : undefined}>
130 + <div className="mt-3 grid gap-4 sm:grid-cols-2">
131 + <KV
132 + items={[
133 + { k: 'Identifier', v: <span className="ci-mono">{focus.id}</span> },
134 + {
135 + k: 'Entity page',
136 + v: (
137 + <Link href={focus.href} className="ci-link">
138 + {focus.href} ↗
139 + </Link>
140 + ),
141 + },
142 + { k: 'Edges', v: <span className="ci-num">{fmtInt(totalEdges)}</span> },
143 + ]}
144 + />
145 + <KV
146 + items={[
147 + {
148 + k: 'Neighbours',
149 + v: typeOrder.length ? (
150 + <span className="flex flex-wrap gap-x-3 gap-y-0.5">
151 + {typeOrder.map((t) => (
152 + <span key={t}>
153 + <span className="ci-num">{fmtInt(nb.degreeByType[t])}</span> {NODE_TYPE_LABEL[t].toLowerCase()}
154 + </span>
155 + ))}
156 + </span>
157 + ) : (
158 + 'none'
159 + ),
160 + },
161 + { k: 'Drawn', v: `${fmtInt(Math.min(nb.nodes.length, MAX_DRAWN))} of ${fmtInt(nb.nodes.length)} neighbours (cap ${MAX_DRAWN}); table lists every fetched edge` },
162 + ]}
163 + />
164 + </div>
165 + <FocusForm focus={`${focus.type}:${focus.ref}`} suggestions={suggestions} />
166 + </PageHeader>
167 +
168 + {nb.nodes.length === 0 ? (
169 + <EmptyState knows={[{ label: `Open ${focus.label}`, href: focus.href }, ...suggestions.slice(0, 4).map((s) => ({ label: s.label, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` }))]}>
170 + No knowledge edge or registry link touches this {focus.type} on this environment. Edges appear once a source (CIViC, ChEMBL, openFDA, ClinicalTrials.gov, GDC) states one — CancerIndex does not infer them.
171 + </EmptyState>
172 + ) : (
173 + <div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
174 + <Section id="graph" kicker="Neighbourhood" title="Radial view" description="Focus at the centre; neighbours grouped by entity type. Click a node to re-centre the graph on it; ↗ opens the entity page. Hover a spoke for relationship, evidence level, cancer context and source." level={3}>
175 + <RadialGraph focus={nb.focus} nodes={nb.nodes} edges={allEdges} maxNodes={MAX_DRAWN} />
176 + </Section>
177 +
178 + <Section id="paths" kicker="Paths" title={focus.type === 'cancer' ? 'Strongest chains' : 'Paths'} description={focus.type === 'cancer' ? 'Gene → variant → drug chains anchored in this cancer (or a descendant), ranked by source-native evidence level then support, with the approval and trial registry hops when they exist.' : 'Chains are built for cancer foci only.'} level={3}>
179 + {focus.type !== 'cancer' ? (
180 + <p className="text-[13px] text-ink-3">
181 + Pick a cancer in the graph or from the neighbour table to see gene → variant → drug → approval → trial chains.
182 + {nb.nodes.some((n) => n.type === 'cancer') ? (
183 + <>
184 + {' '}
185 + Cancers here:{' '}
186 + {nb.nodes
187 + .filter((n) => n.type === 'cancer')
188 + .slice(0, 5)
189 + .map((n, i) => (
190 + <span key={n.id}>
191 + {i > 0 ? ', ' : ''}
192 + <Link href={focusHref(n.type, n.ref) ?? n.href} className="ci-link">
193 + {n.label}
194 + </Link>
195 + </span>
196 + ))}
197 + .
198 + </>
199 + ) : null}
200 + </p>
201 + ) : paths.length === 0 ? (
202 + <EmptyState compact>No PREDICTS_RESPONSE_TO edge with direction “sensitivity” names this cancer (or a descendant) in its context, so no chain can be assembled. The neighbour table still lists what the sources state.</EmptyState>
203 + ) : (
204 + <>
205 + <ol className="m-0 list-none p-0">
206 + {paths.map((c) => (
207 + <PathChainView key={`${c.variant.id}:${c.drug.id}`} chain={c} />
208 + ))}
209 + </ol>
210 + <p className="mt-2 text-[12px] text-ink-3">
211 + Each hop keeps its own claim category: cohort alteration frequency (observed, cases ≥ {CASES_MIN}), CIViC predictive edge (curated, level as stated by CIViC), regulatory approval (authority, jurisdiction, date as published), trial count (registry). A missing hop is shown as missing — never filled in. Not treatment guidance.
212 + </p>
213 + </>
214 + )}
215 + </Section>
216 + </div>
217 + )}
218 +
219 + {nb.groups.length ? (
220 + <Section id="edges" kicker="Edges" title="Every edge, grouped by relationship" description={`Source-native edges first (aggregated per neighbour, direction, evidence level and source), then derived registry links. Up to ${DEFAULT_GROUP_LIMIT} per relationship (${TRIAL_GROUP_LIMIT} trials); “show all” raises one group to ${EXPANDED_GROUP_LIMIT}.`}>
221 + <div className="ci-table-wrap">
222 + <table className="ci-table ci-evidence">
223 + <thead>
224 + <tr>
225 + <th>Relationship</th>
226 + <th>Neighbour</th>
227 + <th>Direction</th>
228 + <th>Evidence level</th>
229 + <th>Cancer context</th>
230 + <th className="num">Support</th>
231 + <th>Claim</th>
232 + <th>Source</th>
233 + <th>Expand</th>
234 + </tr>
235 + </thead>
236 + {nb.groups.map((g) => {
237 + const expanded = more === g.relationshipType;
238 + return (
239 + <tbody key={g.relationshipType}>
240 + <tr className="ci-group">
241 + <th colSpan={9} scope="colgroup">
242 + <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
243 + <span>
244 + {focus.label} <span className="text-ink-2">{relationshipLabel(g.relationshipType)}</span> …
245 + </span>
246 + <span className="ci-mono text-[11px] text-ink-3">{g.relationshipType}</span>
247 + {g.derived ? <Badge tone="outline">derived</Badge> : null}
248 + <span className="ci-num text-[12px] text-ink-3">
249 + {fmtInt(g.edges.length)} of {fmtInt(g.total)}
250 + </span>
251 + {g.total > g.edges.length ? (
252 + <Link href={hrefMore(g.relationshipType)} className="ci-link text-[12.5px]">
253 + show all (up to {EXPANDED_GROUP_LIMIT})
254 + </Link>
255 + ) : expanded ? (
256 + <Link href={hrefMore(null)} className="ci-link text-[12.5px]">
257 + show fewer
258 + </Link>
259 + ) : null}
260 + </span>
261 + </th>
262 + </tr>
263 + {g.edges.map((e) => {
264 + const nnode = nodeByKey.get(e.neighborKey);
265 + if (!nnode) return null;
266 + const p = toInfo(prov.get(e.provenanceIds[0] ?? -1));
267 + const expand = focusHref(nnode.type, nnode.ref);
268 + return (
269 + <tr key={e.key}>
270 + <td className="whitespace-nowrap text-[12.5px] text-ink-2">
271 + {e.via ? (
272 + <span title="The focus is the cancer context of this edge (the neighbour and the third entity are the edge's ends)">in context · </span>
273 + ) : (
274 + <span aria-label={e.outgoing ? 'focus to neighbour' : 'neighbour to focus'}>{e.outgoing ? '→' : '←'} </span>
275 + )}
276 + {relationshipLabel(e.relationshipType)}
277 + </td>
278 + <td className="w-t">
279 + <Link href={nnode.href} className="ci-link">
280 + {nnode.type === 'gene' ? <span className="ci-mono">{nnode.label}</span> : nnode.label}
281 + </Link>
282 + {nnode.sublabel && nnode.type !== 'gene' ? <span className="ml-1.5 text-[12px] text-ink-3">{nnode.sublabel}</span> : null}
283 + {e.via ? (
284 + <span className="text-[12.5px] text-ink-2">
285 + {' '}
286 + → {relationshipLabel(e.relationshipType)}{' '}
287 + <Link href={e.via.href} className="ci-link">
288 + {e.via.label}
289 + </Link>
290 + </span>
291 + ) : null}
292 + {e.detail ? <div className="mt-0.5 text-[12px] text-ink-3">{e.detail}</div> : null}
293 + </td>
294 + <td>
295 + <DirectionCell e={e} />
296 + </td>
297 + <td>{e.evidenceLevel ? <Badge tone="accent" mono title="Source-native scale (CIViC A–E, ChEMBL max phase, FDA application type, approval status) — never re-scaled">{e.evidenceLevel}</Badge> : <span className="text-ink-4">—</span>}</td>
298 + <td className="text-[12.5px]">
299 + <ContextCell e={e} />
300 + </td>
301 + <td className="num">{fmtInt(e.supportCount)}</td>
302 + <td>
303 + <ClaimBadge kind={claimKindFromCategory(e.evidenceCategory)} />
304 + </td>
305 + <td>
306 + {e.sourceSlugs.map((s) => (
307 + <SourceBadge key={s} compact p={p ?? { sourceSlug: s }} title={p ? undefined : e.derived ? 'Derived by CancerIndex from registry rows of this source' : null} />
308 + ))}
309 + </td>
310 + <td className="whitespace-nowrap">
311 + {expand ? (
312 + <Link href={expand} className="ci-link text-[12.5px]">
313 + graph →
314 + </Link>
315 + ) : (
316 + <Link href={nnode.href} className="ci-link text-[12.5px]">
317 + open ↗
318 + </Link>
319 + )}
320 + </td>
321 + </tr>
322 + );
323 + })}
324 + </tbody>
325 + );
326 + })}
327 + </table>
328 + </div>
329 + <div className="mt-3 space-y-2">
330 + <Note>
331 + An <strong>edge</strong> is a relationship stated by a source (CIViC evidence item, ChEMBL indication or mechanism, openFDA approval) and kept with its native evidence level, direction and cancer context — CancerIndex never infers, merges or re-scales it. Rows marked <em>derived</em> are counts and measurements read from registries (ClinicalTrials.gov conditions and interventions, GDC/cBioPortal cohort frequencies ≥ {Math.round(FREQ_MIN * 100)} % with ≥ {CASES_MIN} cases affected, regulatory approval records): they say how often two entities co-occur in a registry, not that a source asserted a biological or clinical link. Trials are rolled up over the cancer and its descendants; approvals list authority, jurisdiction and date as published.
332 + </Note>
333 + <Freshness dataUpdatedAt={freshAt} extra={`${fmtInt(totalEdges)} edges in the database for this focus`} />
334 + </div>
335 + </Section>
336 + ) : null}
337 + </article>
338 + );
339 +}
added apps/web/src/components/graph/graph-link.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import Link from 'next/link';
2 +import type { FocusType } from '@/lib/graph-model';
3 +
4 +/**
5 + * "Explore in graph →" link for entity pages. `ref` is the public reference used by `/graph?focus=`:
6 + * cancer slug, gene symbol, variant slug, drug slug or NCT id.
7 + */
8 +export function GraphLink({ type, ref, label = 'Explore in graph →', className = '' }: { type: FocusType; ref: string; label?: string; className?: string }) {
9 + return (
10 + <Link href={`/graph?focus=${type}:${encodeURIComponent(ref)}`} className={`ci-link inline-flex items-center gap-1 text-[13px]${className ? ` ${className}` : ''}`} title="Open the contextual knowledge graph around this entity">
11 + {label}
12 + </Link>
13 + );
14 +}
added apps/web/src/components/graph/path-chain.tsx +116 −0
@@ -0,0 +1,116 @@
1 +import Link from 'next/link';
2 +import { Badge, ClaimBadge } from '@/components/ui/badge';
3 +import { SourceBadge } from '@/components/ui/source-badge';
4 +import { fmtInt, fmtPct } from '@/lib/format';
5 +import type { PathChain } from '@/lib/graph-model';
6 +
7 +const SOURCE_SLUG_BY_ID: Record<string, string> = { 'CI-SOURCE-00000006': 'civic', 'CI-SOURCE-00000015': 'chembl', 'CI-SOURCE-00000016': 'openfda', 'CI-SOURCE-00000004': 'clinicaltrials', 'CI-SOURCE-00000008': 'gdc', 'CI-SOURCE-00000017': 'cbioportal' };
8 +
9 +function Arrow() {
10 + return (
11 + <span aria-hidden className="mx-1 text-ink-4">
12 + →
13 + </span>
14 + );
15 +}
16 +
17 +/**
18 + * One cancer → gene → variant → drug → approval → trials chain, readable as a sentence. Each hop
19 + * keeps its own claim category: cohort frequency (observed), CIViC predictive edge (curated),
20 + * approval (regulatory), trial count (observed). Missing hops say so — nothing is filled in.
21 + */
22 +export function PathChainView({ chain, compact = false, sourceSlugs }: { chain: PathChain; compact?: boolean; sourceSlugs?: Map<string, string> }) {
23 + const slugOf = (id: string) => sourceSlugs?.get(id) ?? SOURCE_SLUG_BY_ID[id] ?? id;
24 + const g = chain.gene;
25 + const freq = g.frequency != null && g.casesAffected != null && g.casesProfiled != null ? `${fmtPct(g.frequency, g.frequency >= 0.1 ? 0 : 1)} (${fmtInt(g.casesAffected)} / ${fmtInt(g.casesProfiled)} cases${g.cohorts > 1 ? `, largest of ${g.cohorts} cohorts` : ''})` : null;
26 + return (
27 + <li className={`ci-rule ${compact ? 'py-2' : 'py-3'} text-[13.5px] leading-relaxed`}>
28 + <p className="m-0 flex flex-wrap items-baseline">
29 + <Link href={`/gene/${g.symbol}`} className="ci-link ci-mono font-sans font-medium">
30 + {g.symbol}
31 + </Link>
32 + <Arrow />
33 + <Link href={`/variant/${chain.variant.slug}`} className="ci-link">
34 + {chain.variant.name}
35 + </Link>
36 + <Arrow />
37 + <Link href={`/drug/${chain.drug.slug}`} className="ci-link font-medium">
38 + {chain.drug.name}
39 + </Link>
40 + {chain.approval ? (
41 + <>
42 + <Arrow />
43 + <span>
44 + {chain.approval.authority} <Badge tone="outline" mono>{chain.approval.jurisdiction}</Badge>
45 + {chain.approval.approvalDate ? <span className="ci-num text-ink-2"> {chain.approval.approvalDate}</span> : <span className="text-ink-3"> (date not published)</span>}
46 + {chain.approval.tumorAgnostic && !chain.approval.cancerId ? <span className="text-ink-3"> · tumour-agnostic</span> : null}
47 + </span>
48 + </>
49 + ) : null}
50 + {chain.trials ? (
51 + <>
52 + <Arrow />
53 + <span>
54 + <span className="ci-num">{fmtInt(chain.trials.active)}</span> active / <span className="ci-num">{fmtInt(chain.trials.total)}</span> trials
55 + </span>
56 + </>
57 + ) : null}
58 + </p>
59 + <p className="m-0 mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-ink-3">
60 + <span className="inline-flex items-center gap-1">
61 + <ClaimBadge kind="curated" />
62 + {chain.edge.evidenceLevel ? (
63 + <Badge tone="accent" mono title="Source-native evidence level (CIViC A–E)">
64 + level {chain.edge.evidenceLevel}
65 + </Badge>
66 + ) : (
67 + <Badge tone="outline">level not stated</Badge>
68 + )}
69 + <span>{chain.edge.direction ?? 'direction unknown'}</span>
70 + <span>
71 + · support <span className="ci-num">{fmtInt(chain.edge.supportCount)}</span>
72 + </span>
73 + {chain.edge.sourceIds.map((s) => (
74 + <SourceBadge key={s} compact p={{ sourceSlug: slugOf(s) }} title={null} />
75 + ))}
76 + </span>
77 + {!compact ? (
78 + <>
79 + <span aria-hidden>·</span>
80 + <span className="inline-flex items-center gap-1">
81 + <ClaimBadge kind="observed" />
82 + {freq ? <span>{g.symbol} altered in {freq}</span> : <span>cohort frequency not yet available for {g.symbol} in this cancer</span>}
83 + </span>
84 + {chain.approval ? (
85 + <>
86 + <span aria-hidden>·</span>
87 + <span className="inline-flex items-center gap-1">
88 + <ClaimBadge kind="regulatory" />
89 + <span>
90 + {chain.approval.status}
91 + {chain.approval.cancerName ? ` for ${chain.approval.cancerName}` : ''}
92 + {chain.approval.total > 1 ? ` (${fmtInt(chain.approval.total)} approval records)` : ''}
93 + </span>
94 + </span>
95 + </>
96 + ) : (
97 + <>
98 + <span aria-hidden>·</span>
99 + <span>no approval record in this cancer</span>
100 + </>
101 + )}
102 + {chain.edge.contextNames.length ? (
103 + <>
104 + <span aria-hidden>·</span>
105 + <span>
106 + context: {chain.edge.contextNames.slice(0, 3).join(', ')}
107 + {chain.edge.contextNames.length > 3 ? ` +${chain.edge.contextNames.length - 3}` : ''}
108 + </span>
109 + </>
110 + ) : null}
111 + </>
112 + ) : null}
113 + </p>
114 + </li>
115 + );
116 +}
added apps/web/src/components/graph/radial-graph.tsx +196 −0
@@ -0,0 +1,196 @@
1 +import Link from 'next/link';
2 +import { type GraphEdge, type GraphNode, type NodeType, type PlacedNode, NODE_TYPE_LABEL, NODE_TYPE_ORDER, edgeStroke, focusHref, labelPlacement, layoutRadial, nodeKey, parallelOffsets, relationshipLabel, shortLabel } from '@/lib/graph-model';
3 +
4 +/**
5 + * Server-rendered radial SVG of one neighbourhood. No client graph library: positions come from
6 + * `layoutRadial` (pure). Entity type is encoded three ways — sector position + sector caption,
7 + * mark shape and a muted fill — so the picture is readable without colour. Edge stroke: solid for
8 + * source-native curated / regulatory claims, dashed for derived or observed registry counts.
9 + * Every node is a link to `/graph?focus=…` (contextual expansion) with a small ↗ to the entity page;
10 + * `<title>` elements carry the full label and the edge context for hover and assistive technology.
11 + */
12 +
13 +const FILL: Record<NodeType, string> = {
14 + cancer: 'var(--color-series-1)',
15 + gene: 'var(--color-series-7)',
16 + variant: 'var(--color-series-8)',
17 + drug: 'var(--color-series-2)',
18 + trial: 'var(--color-series-3)',
19 + approval: 'var(--color-series-4)',
20 +};
21 +
22 +/** Mark shape per entity type (secondary encoding, independent of colour). */
23 +function Mark({ type, x, y, r, fill }: { type: NodeType; x: number; y: number; r: number; fill: string }) {
24 + const common = { fill, stroke: 'var(--color-paper)', strokeWidth: 1.5 } as const;
25 + switch (type) {
26 + case 'gene':
27 + return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} {...common} />;
28 + case 'variant':
29 + return <polygon points={`${x},${y - r * 1.15} ${x + r * 1.1},${y + r * 0.8} ${x - r * 1.1},${y + r * 0.8}`} {...common} />;
30 + case 'drug':
31 + return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} rx={r * 0.45} {...common} />;
32 + case 'trial':
33 + return <polygon points={hexagon(x, y, r * 1.1)} {...common} />;
34 + case 'approval':
35 + return <polygon points={`${x},${y - r * 1.2} ${x + r * 1.2},${y} ${x},${y + r * 1.2} ${x - r * 1.2},${y}`} {...common} />;
36 + default:
37 + return <circle cx={x} cy={y} r={r} {...common} />;
38 + }
39 +}
40 +
41 +function hexagon(cx: number, cy: number, r: number): string {
42 + const pts: string[] = [];
43 + for (let i = 0; i < 6; i++) {
44 + const a = (Math.PI / 3) * i - Math.PI / 6;
45 + pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);
46 + }
47 + return pts.join(' ');
48 +}
49 +
50 +function edgeTitle(e: GraphEdge, focus: GraphNode, neighbor: GraphNode): string {
51 + const from = e.outgoing ? focus.label : neighbor.label;
52 + const to = e.outgoing ? neighbor.label : focus.label;
53 + const bits = [e.via ? `${neighbor.label} ${relationshipLabel(e.relationshipType)} ${e.via.label} — in ${focus.label}` : `${from} ${relationshipLabel(e.relationshipType)} ${to}`];
54 + if (e.direction) bits.push(`direction: ${e.direction}`);
55 + if (e.evidenceLevel) bits.push(`evidence level: ${e.evidenceLevel}`);
56 + if (e.cancerContext.length) bits.push(`context: ${e.cancerContext.slice(0, 3).map((c) => c.name).join(', ')}${e.cancerContext.length > 3 ? ` +${e.cancerContext.length - 3}` : ''}`);
57 + bits.push(`${e.derived ? 'derived count' : e.evidenceCategory.replace(/_/g, ' ')} · source: ${e.sourceSlugs.join(', ')}`);
58 + if (e.detail) bits.push(e.detail);
59 + return bits.join(' · ');
60 +}
61 +
62 +export function RadialGraph({ focus, nodes, edges, size = 760, maxNodes = 60, className = '' }: { focus: GraphNode; nodes: GraphNode[]; edges: GraphEdge[]; size?: number; maxNodes?: number; className?: string }) {
63 + const layout = layoutRadial(nodes, { size, maxNodes });
64 + const placed = new Map<string, PlacedNode>(layout.nodes.map((p) => [nodeKey(p.node), p]));
65 + const byNeighbor = new Map<string, GraphEdge[]>();
66 + for (const e of edges) {
67 + if (!placed.has(e.neighborKey)) continue;
68 + byNeighbor.set(e.neighborKey, [...(byNeighbor.get(e.neighborKey) ?? []), e]);
69 + }
70 + const { cx, cy } = layout;
71 + const focusR = layout.focus.r;
72 +
73 + return (
74 + <figure className={`ci-graph ${className}`}>
75 + <div className="overflow-x-auto">
76 + <svg viewBox={`0 0 ${size} ${size}`} role="img" aria-labelledby="ci-graph-title ci-graph-desc" className="block h-auto w-full min-w-[560px] max-w-[820px] mx-auto" style={{ fontFamily: 'var(--font-sans)' }}>
77 + <title id="ci-graph-title">{`Knowledge graph around ${focus.label}`}</title>
78 + <desc id="ci-graph-desc">{`${layout.nodes.length} neighbours drawn in sectors by entity type: ${layout.sectors.map((s) => `${s.count} ${NODE_TYPE_LABEL[s.type].toLowerCase()}`).join(', ')}. Solid spokes are source-native curated or regulatory edges; dashed spokes are derived registry counts. The table below lists every edge with its context and provenance.`}</desc>
79 +
80 + {/* Sector captions: inside the ring, on the sector's mid-angle, with a paper halo so they stay legible over the spokes */}
81 + {layout.sectors.map((s) => {
82 + const mid = (s.start + s.end) / 2;
83 + const capR = s.ring - 34;
84 + const capX = cx + capR * Math.cos(mid);
85 + const capY = cy + capR * Math.sin(mid);
86 + return (
87 + <text key={s.type} aria-hidden x={capX.toFixed(1)} y={capY.toFixed(1)} textAnchor="middle" dominantBaseline="middle" fontSize="10" letterSpacing="0.08em" fill="var(--color-ink-3)" stroke="var(--color-paper)" strokeWidth="3" paintOrder="stroke" style={{ textTransform: 'uppercase' }}>
88 + {`${NODE_TYPE_LABEL[s.type]} · ${s.count}`}
89 + </text>
90 + );
91 + })}
92 +
93 + {/* Edges: one spoke per (neighbour, relationship), parallel offsets when several */}
94 + <g>
95 + {layout.nodes.map((p) => {
96 + const list = byNeighbor.get(nodeKey(p.node)) ?? [];
97 + const shown = list.slice(0, 3);
98 + const offsets = parallelOffsets(shown.length);
99 + const ux = p.x - cx;
100 + const uy = p.y - cy;
101 + const len = Math.hypot(ux, uy) || 1;
102 + const nx = -uy / len; // unit normal
103 + const ny = ux / len;
104 + const sx = cx + (ux / len) * (focusR + 3);
105 + const sy = cy + (uy / len) * (focusR + 3);
106 + const ex = p.x - (ux / len) * (p.r + 2);
107 + const ey = p.y - (uy / len) * (p.r + 2);
108 + return shown.map((e, i) => {
109 + const o = offsets[i]!;
110 + const dashed = edgeStroke(e) === 'dashed';
111 + return (
112 + <line key={e.key} x1={(sx + nx * o).toFixed(1)} y1={(sy + ny * o).toFixed(1)} x2={(ex + nx * o).toFixed(1)} y2={(ey + ny * o).toFixed(1)} stroke={dashed ? 'var(--color-ink-4)' : 'var(--color-ink-3)'} strokeWidth={dashed ? 1 : 1.2} strokeDasharray={dashed ? '3 3' : undefined} strokeOpacity={0.9}>
113 + <title>{edgeTitle(e, focus, p.node)}</title>
114 + </line>
115 + );
116 + });
117 + })}
118 + </g>
119 +
120 + {/* Neighbour nodes */}
121 + {layout.nodes.map((p) => {
122 + const lp = labelPlacement(p);
123 + const href = focusHref(p.node.type, p.node.ref);
124 + const label = `${p.node.label}${p.node.sublabel ? ` — ${p.node.sublabel}` : ''} · ${p.node.degree} edge${p.node.degree === 1 ? '' : 's'} here`;
125 + const body = (
126 + <>
127 + <title>{label}</title>
128 + <Mark type={p.node.type} x={p.x} y={p.y} r={p.r} fill={FILL[p.node.type]} />
129 + <text transform={`rotate(${lp.rotate.toFixed(2)} ${lp.x.toFixed(1)} ${lp.y.toFixed(1)})`} x={lp.x} y={lp.y} textAnchor={lp.anchor} dominantBaseline="middle" fontSize="11" fill="var(--color-ink)">
130 + {shortLabel(p.node.label)}
131 + </text>
132 + </>
133 + );
134 + return (
135 + <g key={nodeKey(p.node)} className="ci-graph-node">
136 + {href ? (
137 + <a href={href} aria-label={`Explore ${p.node.label} in the graph`}>
138 + {body}
139 + </a>
140 + ) : (
141 + <a href={p.node.href} aria-label={`Open ${p.node.label}`}>
142 + {body}
143 + </a>
144 + )}
145 + </g>
146 + );
147 + })}
148 +
149 + {/* Focus */}
150 + <g>
151 + <title>{`${focus.label} · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')} · focus`}</title>
152 + <a href={focus.href} aria-label={`Open the ${focus.type} page for ${focus.label}`}>
153 + <Mark type={focus.type} x={cx} y={cy} r={focusR} fill={FILL[focus.type]} />
154 + </a>
155 + <text x={cx} y={cy + focusR + 14} textAnchor="middle" fontSize="12" fontWeight={600} fill="var(--color-ink)">
156 + {shortLabel(focus.label, 34)}
157 + </text>
158 + </g>
159 + </svg>
160 + </div>
161 +
162 + <figcaption className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-ink-3">
163 + <span className="ci-kicker">Legend</span>
164 + {NODE_TYPE_ORDER.map((t) => (
165 + <span key={t} className="inline-flex items-center gap-1.5">
166 + <svg width="14" height="14" viewBox="-8 -8 16 16" aria-hidden>
167 + <Mark type={t} x={0} y={0} r={5} fill={FILL[t]} />
168 + </svg>
169 + {NODE_TYPE_LABEL[t]}
170 + </span>
171 + ))}
172 + <span className="inline-flex items-center gap-1.5">
173 + <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>
174 + <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-3)" strokeWidth="1.4" />
175 + </svg>
176 + source-native edge (curated / regulatory)
177 + </span>
178 + <span className="inline-flex items-center gap-1.5">
179 + <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>
180 + <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-4)" strokeWidth="1.2" strokeDasharray="3 3" />
181 + </svg>
182 + derived count (registries)
183 + </span>
184 + <span>mark size = log of edges at the node</span>
185 + {layout.hidden > 0 ? (
186 + <span>
187 + {layout.hidden} more neighbour{layout.hidden === 1 ? '' : 's'} not drawn (cap {maxNodes}) — all listed in the table
188 + </span>
189 + ) : null}
190 + <Link href={focus.href} className="ci-link ml-auto">
191 + Entity page ↗
192 + </Link>
193 + </figcaption>
194 + </figure>
195 + );
196 +}
added apps/web/src/components/home/graph-module.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import Link from 'next/link';
2 +import { Section } from '@/components/ui/section';
3 +import { EmptyState } from '@/components/ui/empty-state';
4 +import { PathChainView } from '@/components/graph/path-chain';
5 +import { defaultFocus, focusCancerIds, loadPaths, resolveFocus } from '@/lib/queries/graph';
6 +import { fmtInt } from '@/lib/format';
7 +
8 +/**
9 + * Home teaser for the knowledge graph: three strongest gene → variant → drug chains for the
10 + * most-connected cancer (chosen from the data at request time, never hardcoded) and a link to /graph.
11 + */
12 +export async function GraphModule({ limit = 3 }: { limit?: number }) {
13 + const ref = await defaultFocus();
14 + const focus = ref ? await resolveFocus(ref) : null;
15 + const chains = focus ? await loadPaths(focus, await focusCancerIds(focus), limit) : [];
16 + return (
17 + <Section
18 + id="graph"
19 + kicker="Knowledge graph"
20 + title={focus ? `Paths through ${focus.label}` : 'Knowledge graph'}
21 + description="Cancer → gene → variant → drug → approval → trials, as the sources state each hop: CIViC evidence level and direction, regulatory authority and date, registry trial counts. No edge is inferred by CancerIndex."
22 + actions={
23 + <Link href={focus ? `/graph?focus=cancer:${encodeURIComponent(focus.ref)}` : '/graph'} className="ci-link">
24 + Explore the graph →
25 + </Link>
26 + }
27 + >
28 + {!focus || chains.length === 0 ? (
29 + <EmptyState compact knows={[{ label: 'Open the graph', href: '/graph' }]}>
30 + No knowledge edge with a sensitivity direction is loaded yet, so no chain can be shown.
31 + </EmptyState>
32 + ) : (
33 + <>
34 + <ol className="m-0 list-none p-0">
35 + {chains.map((c) => (
36 + <PathChainView key={`${c.variant.id}:${c.drug.id}`} chain={c} compact />
37 + ))}
38 + </ol>
39 + <p className="mt-2 text-[12px] text-ink-3">
40 + {fmtInt(chains.length)} of the strongest chains for the cancer with the most knowledge edges ({focus.label}); ranked by source-native evidence level, then support. Not treatment guidance.
41 + </p>
42 + </>
43 + )}
44 + </Section>
45 + );
46 +}
added apps/web/src/lib/graph-model.ts +379 −0
@@ -0,0 +1,379 @@
1 +/**
2 + * Knowledge-graph model + radial layout math (pure: no database, no React). Shared by the web
3 + * queries (`lib/queries/graph.ts`), the SVG renderer and the unit tests.
4 + *
5 + * Every edge carries its cancer context, direction, evidence level, claim category and provenance
6 + * (CLAUDE.md §7). `derived: true` marks links that CancerIndex computes from registry tables
7 + * (trial_conditions, trial_interventions, cancer_gene_frequencies, drug_approvals,
8 + * civic_evidence_items) as opposed to source-native `knowledge_edges` rows. Nothing here is inferred.
9 + */
10 +
11 +export type NodeType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'approval';
12 +
13 +/** Fixed angular order of the sectors (never re-sorted by count so the picture stays stable). */
14 +export const NODE_TYPE_ORDER: readonly NodeType[] = ['cancer', 'gene', 'variant', 'drug', 'trial', 'approval'];
15 +
16 +export const NODE_TYPE_LABEL: Record<NodeType, string> = {
17 + cancer: 'Cancers',
18 + gene: 'Genes',
19 + variant: 'Variants',
20 + drug: 'Drugs',
21 + trial: 'Trials',
22 + approval: 'Approvals',
23 +};
24 +
25 +/** Focus reference forms accepted by `/graph?focus=<type>:<ref>` (approvals are not focusable). */
26 +export type FocusType = Exclude<NodeType, 'approval'>;
27 +export const FOCUS_TYPES: readonly FocusType[] = ['cancer', 'gene', 'variant', 'drug', 'trial'];
28 +
29 +export interface GraphNode {
30 + type: NodeType;
31 + /** Public CI id (CI-CAN-…, CI-GENE-…) or `approval:<row id>` for approval nodes. */
32 + id: string;
33 + /** Reference used in `focus=` (slug / symbol / NCT id). Null for approval nodes. */
34 + ref: string | null;
35 + label: string;
36 + /** Secondary line (gene symbol for a variant, phase/status for a trial, jurisdiction for an approval). */
37 + sublabel?: string | null;
38 + /** Entity page. */
39 + href: string;
40 + /** Number of edges touching this node in the current neighbourhood. */
41 + degree: number;
42 +}
43 +
44 +export interface CancerContext {
45 + id: string;
46 + name: string;
47 + slug: string;
48 +}
49 +
50 +export interface GraphEdge {
51 + /** Stable key (`ke:<id>` for knowledge_edges rows, `dv:<kind>:<ids>` for derived links). */
52 + key: string;
53 + relationshipType: string;
54 + /** `${type}:${id}` of the neighbour node. */
55 + neighborKey: string;
56 + /** True when the focus is the source of the relationship (focus → neighbour). */
57 + outgoing: boolean;
58 + direction: string | null;
59 + /** Source-native evidence level (CIViC A–E, ChEMBL phase 1–4, "FDA ORIG"…), never re-scaled. */
60 + evidenceLevel: string | null;
61 + /** observed_data | published_evidence | curated_evidence | regulatory_status | clinical_guideline | computed_metric */
62 + evidenceCategory: string;
63 + cancerContext: CancerContext[];
64 + supportCount: number;
65 + sourceIds: string[];
66 + sourceSlugs: string[];
67 + provenanceIds: number[];
68 + derived: boolean;
69 + /** Human-readable measurement behind a derived link ("183 / 186 cases (98.4 %)", "417 trials (197 active)"). */
70 + detail?: string | null;
71 + /** ISO date attached to the edge (approval date, trial last update). */
72 + date?: string | null;
73 + /** Third party when the focus is only the *context* of the edge (e.g. variant → drug in this cancer). */
74 + via?: { type: NodeType; id: string; label: string; href: string } | null;
75 +}
76 +
77 +export interface EdgeGroup {
78 + relationshipType: string;
79 + /** Total edges of this type in the database for the focus (before LIMIT). */
80 + total: number;
81 + edges: GraphEdge[];
82 + derived: boolean;
83 +}
84 +
85 +export interface Neighborhood {
86 + focus: GraphNode;
87 + /** Neighbour nodes (focus excluded), unique by `${type}:${id}`. */
88 + nodes: GraphNode[];
89 + groups: EdgeGroup[];
90 + degreeByType: Record<NodeType, number>;
91 +}
92 +
93 +export const nodeKey = (n: Pick<GraphNode, 'type' | 'id'>): string => `${n.type}:${n.id}`;
94 +
95 +export interface FocusRef {
96 + type: FocusType;
97 + ref: string;
98 +}
99 +
100 +/** Parse `focus=<type>:<ref>` (also accepts a bare NCT id, a bare CI id or a bare gene symbol in upper case). */
101 +export function parseFocus(raw: string | null | undefined): FocusRef | null {
102 + const s = (raw ?? '').trim();
103 + if (!s) return null;
104 + const m = /^([a-z]+)\s*:\s*(.*)$/i.exec(s);
105 + if (m) {
106 + const type = m[1]!.toLowerCase();
107 + const ref = m[2]!.trim().slice(0, 200);
108 + if (!ref) return null;
109 + if ((FOCUS_TYPES as readonly string[]).includes(type)) return { type: type as FocusType, ref };
110 + return null;
111 + }
112 + if (/^NCT\d{8}$/i.test(s)) return { type: 'trial', ref: s.toUpperCase() };
113 + const ci = /^CI-(CAN|GENE|VAR|DRUG|TRIAL)-\d+$/i.exec(s);
114 + if (ci) {
115 + const ns = ci[1]!.toUpperCase();
116 + const type: FocusType = ns === 'CAN' ? 'cancer' : ns === 'GENE' ? 'gene' : ns === 'VAR' ? 'variant' : ns === 'DRUG' ? 'drug' : 'trial';
117 + return { type, ref: s.toUpperCase() };
118 + }
119 + if (/^[A-Z][A-Z0-9-]{1,14}$/.test(s)) return { type: 'gene', ref: s };
120 + return { type: 'cancer', ref: s.toLowerCase() };
121 +}
122 +
123 +export const focusHref = (type: NodeType, ref: string | null): string | null => (type === 'approval' || !ref ? null : `/graph?focus=${type}:${encodeURIComponent(ref)}`);
124 +
125 +/** Relationship labels in editorial English (the raw type stays in `title` / tables). */
126 +export const RELATIONSHIP_LABEL: Record<string, string> = {
127 + ASSOCIATED_WITH: 'associated with',
128 + PREDICTS_RESPONSE_TO: 'predicts response to',
129 + CONFERS_RESISTANCE_TO: 'confers resistance to',
130 + PROGNOSTIC_IN: 'prognostic in',
131 + DIAGNOSTIC_OF: 'diagnostic of',
132 + PREDISPOSES_TO: 'predisposes to',
133 + TARGETS: 'targets',
134 + APPROVED_FOR: 'approved for',
135 + INVESTIGATED_FOR: 'investigated for',
136 + INVESTIGATED_IN_TRIALS: 'investigated in trials for',
137 + STUDIED_IN: 'studied in',
138 + ALTERED_IN: 'altered in cohorts of',
139 + HAS_VARIANT: 'has variant',
140 + HAS_EVIDENCE_IN: 'has curated evidence in',
141 + CONDITION_OF: 'condition of',
142 + INTERVENTION_OF: 'intervention of',
143 +};
144 +
145 +export const relationshipLabel = (t: string): string => RELATIONSHIP_LABEL[t] ?? t.toLowerCase().replace(/_/g, ' ');
146 +
147 +/** Display order of relationship groups: regulatory first, then curated, then derived counts. */
148 +export const RELATIONSHIP_ORDER: readonly string[] = [
149 + 'APPROVED_FOR',
150 + 'PREDICTS_RESPONSE_TO',
151 + 'CONFERS_RESISTANCE_TO',
152 + 'PROGNOSTIC_IN',
153 + 'DIAGNOSTIC_OF',
154 + 'PREDISPOSES_TO',
155 + 'ASSOCIATED_WITH',
156 + 'TARGETS',
157 + 'INVESTIGATED_FOR',
158 + 'HAS_VARIANT',
159 + 'HAS_EVIDENCE_IN',
160 + 'ALTERED_IN',
161 + 'INVESTIGATED_IN_TRIALS',
162 + 'STUDIED_IN',
163 + 'CONDITION_OF',
164 + 'INTERVENTION_OF',
165 +];
166 +
167 +export function sortGroups<T extends { relationshipType: string }>(groups: T[]): T[] {
168 + const rank = (t: string) => {
169 + const i = RELATIONSHIP_ORDER.indexOf(t);
170 + return i === -1 ? RELATIONSHIP_ORDER.length : i;
171 + };
172 + return [...groups].sort((a, b) => rank(a.relationshipType) - rank(b.relationshipType) || a.relationshipType.localeCompare(b.relationshipType));
173 +}
174 +
175 +/** CIViC A–E rank (lower is stronger); other native scales rank after A–E, unknown last. */
176 +export function evidenceLevelRank(level: string | null | undefined): number {
177 + if (!level) return 99;
178 + const l = level.trim().toUpperCase();
179 + const civic = ['A', 'B', 'C', 'D', 'E'].indexOf(l);
180 + if (civic !== -1) return civic;
181 + if (l === 'FDA ORIG' || l.startsWith('FDA')) return 0;
182 + const phase = Number(l);
183 + if (Number.isFinite(phase)) return phase >= 4 ? 1 : phase >= 3 ? 2 : phase >= 2 ? 3 : 4; // ChEMBL max phase
184 + return 50;
185 +}
186 +
187 +/** Edge stroke family: `solid` for source-native curated/regulatory/published claims, `dashed` for derived or observed counts. */
188 +export function edgeStroke(e: Pick<GraphEdge, 'derived' | 'evidenceCategory'>): 'solid' | 'dashed' {
189 + if (e.derived) return 'dashed';
190 + return e.evidenceCategory === 'observed_data' || e.evidenceCategory === 'computed_metric' ? 'dashed' : 'solid';
191 +}
192 +
193 +// ---------------------------------------------------------------------------------------------
194 +// Layout
195 +// ---------------------------------------------------------------------------------------------
196 +
197 +export interface LayoutOptions {
198 + /** Square viewBox side (default 760). */
199 + size?: number;
200 + /** Hard cap on drawn neighbours (default 60); the table below the graph still lists everything. */
201 + maxNodes?: number;
202 + /** Angular gap between sectors, radians (default 0.16 ≈ 9°). */
203 + sectorGap?: number;
204 + /** Outer margin reserved for radial labels (default 140 ≈ an 18-character label at 11 px plus the mark). */
205 + labelMargin?: number;
206 +}
207 +
208 +export interface PlacedNode {
209 + node: GraphNode;
210 + x: number;
211 + y: number;
212 + /** Mark radius (log of degree). */
213 + r: number;
214 + /** Polar angle in radians (0 = east, clockwise positive in SVG space). */
215 + angle: number;
216 + /** Ring radius from the centre. */
217 + ring: number;
218 +}
219 +
220 +export interface Sector {
221 + type: NodeType;
222 + start: number;
223 + end: number;
224 + count: number;
225 + /** Ring radius used by this sector. */
226 + ring: number;
227 +}
228 +
229 +export interface RadialLayout {
230 + size: number;
231 + cx: number;
232 + cy: number;
233 + focus: { x: number; y: number; r: number };
234 + nodes: PlacedNode[];
235 + sectors: Sector[];
236 + /** Neighbours that exist but were not drawn because of `maxNodes`. */
237 + hidden: number;
238 +}
239 +
240 +const TAU = Math.PI * 2;
241 +
242 +export function nodeRadius(degree: number): number {
243 + const d = Math.max(0, degree);
244 + return Math.min(13, 4 + 2.2 * Math.log2(d + 1));
245 +}
246 +
247 +/** Deterministic node ordering: degree desc, then label, then id. */
248 +export function compareNodes(a: GraphNode, b: GraphNode): number {
249 + return b.degree - a.degree || a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
250 +}
251 +
252 +/**
253 + * Pick at most `max` nodes while keeping every entity type represented: round-robin over the types
254 + * (in fixed order), each type contributing its highest-degree node first. Deterministic.
255 + */
256 +export function selectNodes(nodes: GraphNode[], max: number): { drawn: GraphNode[]; hidden: number } {
257 + const byType = new Map<NodeType, GraphNode[]>();
258 + for (const t of NODE_TYPE_ORDER) byType.set(t, []);
259 + for (const n of nodes) byType.get(n.type)?.push(n);
260 + for (const list of byType.values()) list.sort(compareNodes);
261 + const drawn: GraphNode[] = [];
262 + let progressed = true;
263 + while (drawn.length < max && progressed) {
264 + progressed = false;
265 + for (const t of NODE_TYPE_ORDER) {
266 + if (drawn.length >= max) break;
267 + const next = byType.get(t)!.shift();
268 + if (next) {
269 + drawn.push(next);
270 + progressed = true;
271 + }
272 + }
273 + }
274 + return { drawn, hidden: Math.max(0, nodes.length - drawn.length) };
275 +}
276 +
277 +/**
278 + * Radial layout: focus at the centre, neighbours on sector arcs grouped by entity type in the fixed
279 + * order cancer → gene → variant → drug → trial → approval (clockwise from the top). Sector width is
280 + * proportional to node count with a minimum so small groups stay legible; sectors never overlap;
281 + * each sector sits on its own ring radius (alternating three radii) so labels at sector borders do
282 + * not collide. Positions are a pure function of the input.
283 + */
284 +export function layoutRadial(nodesIn: GraphNode[], opts: LayoutOptions = {}): RadialLayout {
285 + const size = opts.size ?? 760;
286 + const maxNodes = opts.maxNodes ?? 60;
287 + const gap = opts.sectorGap ?? 0.16;
288 + const labelMargin = opts.labelMargin ?? 140;
289 + const cx = size / 2;
290 + const cy = size / 2;
291 + const { drawn, hidden } = selectNodes(nodesIn, maxNodes);
292 +
293 + const counts = new Map<NodeType, GraphNode[]>();
294 + for (const n of drawn) counts.set(n.type, [...(counts.get(n.type) ?? []), n]);
295 + const present = NODE_TYPE_ORDER.filter((t) => (counts.get(t)?.length ?? 0) > 0);
296 + const total = drawn.length;
297 +
298 + const outerRing = size / 2 - labelMargin;
299 + const rings = [outerRing, outerRing * 0.84, outerRing * 0.92];
300 +
301 + const sectors: Sector[] = [];
302 + const nodes: PlacedNode[] = [];
303 + if (present.length === 0) return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden };
304 +
305 + // Angular budget: full circle minus one gap per sector; each sector gets a share proportional to
306 + // its count, floored at `minShare` so a single node still gets breathing room.
307 + const usable = TAU - gap * present.length;
308 + const minShare = Math.min(0.35, usable / present.length / 2);
309 + const rawShares = present.map((t) => Math.max(minShare, (usable * counts.get(t)!.length) / total));
310 + const shareSum = rawShares.reduce((a, b) => a + b, 0);
311 + const shares = rawShares.map((s) => (s * usable) / shareSum);
312 +
313 + let cursor = -Math.PI / 2 + gap / 2; // start at the top, clockwise
314 + present.forEach((t, i) => {
315 + const list = counts.get(t)!.sort(compareNodes);
316 + const span = shares[i]!;
317 + const ring = rings[i % rings.length]!;
318 + const start = cursor;
319 + const end = cursor + span;
320 + sectors.push({ type: t, start, end, count: list.length, ring });
321 + // Nodes are spread over the sector interior; a lone node sits at the sector's centre.
322 + const n = list.length;
323 + list.forEach((node, k) => {
324 + const frac = n === 1 ? 0.5 : (k + 0.5) / n;
325 + const angle = start + frac * span;
326 + nodes.push({ node, angle, ring, r: nodeRadius(node.degree), x: cx + ring * Math.cos(angle), y: cy + ring * Math.sin(angle) });
327 + });
328 + cursor = end + gap;
329 + });
330 +
331 + return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden };
332 +}
333 +
334 +/** Radial label transform: text drawn along the spoke, flipped on the left half so it never reads upside down. */
335 +export function labelPlacement(p: PlacedNode, offset = 6): { x: number; y: number; rotate: number; anchor: 'start' | 'end' } {
336 + const deg = (p.angle * 180) / Math.PI;
337 + const left = Math.cos(p.angle) < 0;
338 + const dist = p.r + offset;
339 + return { x: p.x + dist * Math.cos(p.angle), y: p.y + dist * Math.sin(p.angle), rotate: left ? deg + 180 : deg, anchor: left ? 'end' : 'start' };
340 +}
341 +
342 +/** Truncate a label for the SVG (full text goes in `<title>`). */
343 +export function shortLabel(s: string, max = 18): string {
344 + const t = s.trim();
345 + return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`;
346 +}
347 +
348 +/** Perpendicular offsets so several relationships between the same pair render as distinct parallel spokes. */
349 +export function parallelOffsets(n: number, step = 3): number[] {
350 + const out: number[] = [];
351 + for (let i = 0; i < n; i++) out.push((i - (n - 1) / 2) * step);
352 + return out;
353 +}
354 +
355 +// ---------------------------------------------------------------------------------------------
356 +// Paths (cancer focus): cancer → gene → variant → drug → approval → trials
357 +// ---------------------------------------------------------------------------------------------
358 +
359 +export interface PathChain {
360 + cancer: { id: string; slug: string; name: string };
361 + gene: { id: string; symbol: string; frequency: number | null; casesAffected: number | null; casesProfiled: number | null; cohorts: number };
362 + variant: { id: string; slug: string; name: string };
363 + drug: { id: string; slug: string; name: string };
364 + edge: { evidenceLevel: string | null; direction: string | null; supportCount: number; sourceIds: string[]; provenanceIds: number[]; contextIds: string[]; contextNames: string[] };
365 + approval: { id: number; jurisdiction: string; authority: string; approvalDate: string | null; status: string; cancerId: string | null; cancerName: string | null; tumorAgnostic: boolean; total: number } | null;
366 + trials: { total: number; active: number } | null;
367 +}
368 +
369 +/** Rank chains: strongest evidence level, then support, then alteration frequency, then names (stable). */
370 +export function compareChains(a: PathChain, b: PathChain): number {
371 + return (
372 + evidenceLevelRank(a.edge.evidenceLevel) - evidenceLevelRank(b.edge.evidenceLevel) ||
373 + b.edge.supportCount - a.edge.supportCount ||
374 + (b.gene.frequency ?? -1) - (a.gene.frequency ?? -1) ||
375 + a.gene.symbol.localeCompare(b.gene.symbol) ||
376 + a.variant.name.localeCompare(b.variant.name) ||
377 + a.drug.name.localeCompare(b.drug.name)
378 + );
379 +}
added apps/web/src/lib/queries/graph.ts +852 −0
@@ -0,0 +1,852 @@
1 +import 'server-only';
2 +import { run, sql, safe } from '@/lib/db';
3 +import type { SQL } from 'drizzle-orm';
4 +import { getDescendantIds } from '@/lib/queries/cancers';
5 +import { ACTIVE_STATUSES } from '@/lib/queries/trials';
6 +import { type CancerContext, type EdgeGroup, type FocusRef, type GraphEdge, type GraphNode, type Neighborhood, type NodeType, type PathChain, compareChains, nodeKey, sortGroups } from '@/lib/graph-model';
7 +
8 +/**
9 + * Knowledge-graph neighbourhood queries. Two families of links:
10 + * - source-native `knowledge_edges` rows (CIViC, ChEMBL, openFDA…) — never inferred by CancerIndex,
11 + * aggregated for display per (neighbour, relationship, direction, evidence level, source);
12 + * - derived relational links computed at query time from registry tables (trial_conditions,
13 + * trial_interventions, cancer_gene_frequencies, drug_approvals, civic_evidence_items) — always
14 + * flagged `derived: true` with the count / measurement that backs them.
15 + * Schema is frozen: everything is derived at query time with per-group LIMITs.
16 + *
17 + * The public API (`apps/api/src/routes/graph.ts`) duplicates this SQL: this module is `server-only`
18 + * and depends on the web `@/lib/db` helpers, so it cannot be imported from the Fastify app.
19 + */
20 +
21 +export const DEFAULT_GROUP_LIMIT = 25;
22 +export const EXPANDED_GROUP_LIMIT = 200;
23 +export const TRIAL_GROUP_LIMIT = 10;
24 +/** Cohort thresholds for the derived gene ↔ cancer link (frequency and cases affected). */
25 +export const FREQ_MIN = 0.05;
26 +export const CASES_MIN = 20;
27 +export const PATHS_LIMIT = 8;
28 +/** Cap on descendant ids rolled into a cancer focus (very broad families are truncated to their first N ids). */
29 +export const MAX_DESCENDANTS = 600;
30 +
31 +const inList = (ids: string[]): SQL => sql`(${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`;
32 +const activeList = (): SQL => sql`(${sql.join(ACTIVE_STATUSES.map((s) => sql`${s}`), sql`, `)})`;
33 +const n = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
34 +const groupLimit = (rel: string, more: string | null | undefined, base = DEFAULT_GROUP_LIMIT): number => (more && more.toUpperCase() === rel ? EXPANDED_GROUP_LIMIT : base);
35 +
36 +/** Native-scale rank used only for ORDER BY (never shown; the native level is what the UI displays). */
37 +const 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`;
38 +const 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`;
39 +
40 +const hrefFor = (type: NodeType, ref: string): string => {
41 + switch (type) {
42 + case 'cancer':
43 + return `/cancer/${ref}`;
44 + case 'gene':
45 + return `/gene/${ref}`;
46 + case 'variant':
47 + return `/variant/${ref}`;
48 + case 'drug':
49 + return `/drug/${ref}`;
50 + case 'trial':
51 + return `/trial/${ref}`;
52 + default:
53 + return ref;
54 + }
55 +};
56 +
57 +// ---------------------------------------------------------------------------------------------
58 +// Focus resolution
59 +// ---------------------------------------------------------------------------------------------
60 +
61 +export interface FocusNode extends GraphNode {
62 + type: Exclude<NodeType, 'approval'>;
63 + ref: string;
64 +}
65 +
66 +export async function resolveFocus(f: FocusRef): Promise<FocusNode | null> {
67 + const ref = f.ref.trim();
68 + const isCi = /^CI-[A-Z]+-\d+$/i.test(ref);
69 + type Row = { id: string; ref: string; label: string; sublabel: string | null };
70 + let rows: Row[] = [];
71 + switch (f.type) {
72 + case 'cancer':
73 + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, canonical_name AS label, entity_type AS sublabel FROM cancers WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []);
74 + break;
75 + case 'gene':
76 + rows = await safe(
77 + () =>
78 + run<Row>(sql`SELECT g.id, g.symbol AS ref, g.symbol AS label, g.name AS sublabel FROM genes g WHERE ${isCi ? sql`g.id = ${ref.toUpperCase()}` : sql`upper(g.symbol) = upper(${ref}) OR g.hgnc_id = ${ref}`}
79 + UNION ALL SELECT g.id, g.symbol, g.symbol, g.name FROM genes g JOIN gene_aliases a ON a.gene_id = g.id WHERE upper(a.alias) = upper(${ref}) LIMIT 1`),
80 + [],
81 + );
82 + break;
83 + case 'variant':
84 + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, coalesce(gene_symbol || ' ', '') || name AS label, variant_type AS sublabel FROM variants WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []);
85 + break;
86 + case 'drug':
87 + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, name AS label, kind AS sublabel FROM drugs WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []);
88 + break;
89 + case 'trial':
90 + rows = await safe(() => run<Row>(sql`SELECT id, nct_id AS ref, brief_title AS label, overall_status AS sublabel FROM clinical_trials WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`upper(nct_id) = upper(${ref})`} LIMIT 1`), []);
91 + break;
92 + }
93 + const r = rows[0];
94 + if (!r) return null;
95 + return { type: f.type, id: r.id, ref: r.ref, label: r.label, sublabel: r.sublabel, href: hrefFor(f.type, r.ref), degree: 0 };
96 +}
97 +
98 +// ---------------------------------------------------------------------------------------------
99 +// Source-native knowledge edges
100 +// ---------------------------------------------------------------------------------------------
101 +
102 +interface KeRow {
103 + relationship_type: string;
104 + outgoing: boolean;
105 + ctx_only: boolean;
106 + n_type: NodeType;
107 + n_id: string;
108 + n_ref: string | null;
109 + n_label: string | null;
110 + n_sublabel: string | null;
111 + via_type: NodeType | null;
112 + via_id: string | null;
113 + via_ref: string | null;
114 + via_label: string | null;
115 + direction: string | null;
116 + evidence_level: string | null;
117 + evidence_category: string;
118 + source_id: string;
119 + source_slug: string;
120 + support: string;
121 + edge_ids: number[];
122 + provenance_ids: number[];
123 + context_ids: string[];
124 + last_seen: Date | null;
125 + total: string;
126 + rn: string;
127 +}
128 +
129 +/**
130 + * Edges where the focus is the source or the target (both directions), plus — for a cancer focus —
131 + * edges where the cancer is only the *context* (variant → drug in this cancer), aggregated per
132 + * (neighbour, relationship, direction, level, source). Per-relationship LIMIT via row_number().
133 + */
134 +async function knowledgeEdges(focus: FocusNode, more: string | null): Promise<KeRow[]> {
135 + const t = focus.type;
136 + const id = focus.id;
137 + 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``;
138 + return safe(
139 + () =>
140 + run<KeRow>(sql`
141 + WITH e AS (
142 + SELECT ke.relationship_type,
143 + (ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) AS outgoing,
144 + (ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id}) AS ctx_only,
145 + CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_type ELSE ke.source_entity_type END AS n_type,
146 + CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_id ELSE ke.source_entity_id END AS n_id,
147 + CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_type END AS via_type,
148 + CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_id END AS via_id,
149 + 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,
150 + ${LEVEL_RANK} AS lvl
151 + FROM knowledge_edges ke
152 + 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})
153 + ), a AS (
154 + 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,
155 + sum(support_count) AS support, array_agg(id ORDER BY id) AS edge_ids,
156 + (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,
157 + (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,
158 + max(last_seen_at) AS last_seen
159 + FROM e GROUP BY 1,2,3,4,5,6,7,8,9,10,11
160 + ), r AS (
161 + 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,
162 + count(*) OVER (PARTITION BY a.relationship_type) AS total
163 + FROM a
164 + )
165 + 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, s.slug AS source_slug,
166 + r.support, r.edge_ids, r.provenance_ids, r.context_ids, r.last_seen, r.total, r.rn,
167 + coalesce(c.slug, g.symbol, v.slug, d.slug) AS n_ref,
168 + coalesce(c.canonical_name, g.symbol, coalesce(v.gene_symbol || ' ', '') || v.name, d.name) AS n_label,
169 + coalesce(c.entity_type, g.name, v.variant_type, d.kind) AS n_sublabel,
170 + coalesce(vc.slug, vg.symbol, vv.slug, vd.slug) AS via_ref,
171 + coalesce(vc.canonical_name, vg.symbol, coalesce(vv.gene_symbol || ' ', '') || vv.name, vd.name) AS via_label
172 + FROM r
173 + JOIN sources s ON s.id = r.source_id
174 + LEFT JOIN cancers c ON r.n_type = 'cancer' AND c.id = r.n_id
175 + LEFT JOIN genes g ON r.n_type = 'gene' AND g.id = r.n_id
176 + LEFT JOIN variants v ON r.n_type = 'variant' AND v.id = r.n_id
177 + LEFT JOIN drugs d ON r.n_type = 'drug' AND d.id = r.n_id
178 + LEFT JOIN cancers vc ON r.via_type = 'cancer' AND vc.id = r.via_id
179 + LEFT JOIN genes vg ON r.via_type = 'gene' AND vg.id = r.via_id
180 + LEFT JOIN variants vv ON r.via_type = 'variant' AND vv.id = r.via_id
181 + LEFT JOIN drugs vd ON r.via_type = 'drug' AND vd.id = r.via_id
182 + WHERE r.rn <= CASE WHEN r.relationship_type = ${(more ?? '').toUpperCase()}::text THEN ${EXPANDED_GROUP_LIMIT}::int ELSE ${DEFAULT_GROUP_LIMIT}::int END
183 + ORDER BY r.relationship_type, r.rn`),
184 + [] as KeRow[],
185 + );
186 +}
187 +
188 +// ---------------------------------------------------------------------------------------------
189 +// Derived links (registry counts) — each returns ready-made edges + nodes
190 +// ---------------------------------------------------------------------------------------------
191 +
192 +interface Derived {
193 + relationshipType: string;
194 + total: number;
195 + edges: GraphEdge[];
196 + nodes: GraphNode[];
197 +}
198 +
199 +const SRC = { clinicaltrials: 'CI-SOURCE-00000004', civic: 'CI-SOURCE-00000006', gdc: 'CI-SOURCE-00000008', openfda: 'CI-SOURCE-00000016', cbioportal: 'CI-SOURCE-00000017' } as const;
200 +
201 +function pct(v: number): string {
202 + return `${(v * 100).toFixed(v >= 0.1 ? 0 : 1)} %`;
203 +}
204 +
205 +/** cancer ⇄ gene through cohort alteration frequencies (largest cohort per pair — biggest denominator, not highest frequency; thresholds applied to that cohort row). */
206 +async function frequencyLinks(side: 'cancer' | 'gene', focus: FocusNode, ids: string[], limit: number): Promise<Derived> {
207 + 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; source_slug: string; provenance_id: number; cohorts: string; total: string };
208 + 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`;
209 + const part = side === 'cancer' ? sql`f.gene_id` : sql`f.cancer_id`;
210 + const order = side === 'cancer' ? sql`g.is_cancer_gene DESC, f.frequency DESC, g.symbol` : sql`f.frequency DESC, c.canonical_name`;
211 + const rows = await safe(
212 + () =>
213 + run<Row>(sql`
214 + WITH f AS (
215 + 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,
216 + count(*) OVER (PARTITION BY ${part}) AS cohorts,
217 + row_number() OVER (PARTITION BY ${part} ORDER BY f.cases_profiled DESC, f.frequency DESC, f.id) AS rn
218 + FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id
219 + WHERE ${where} AND f.frequency >= ${FREQ_MIN} AND f.cases_affected >= ${CASES_MIN}
220 + )
221 + SELECT f.*, g.symbol, g.is_cancer_gene, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, count(*) OVER() AS total
222 + FROM f JOIN genes g ON g.id = f.gene_id JOIN cancers c ON c.id = f.cancer_id JOIN sources s ON s.id = f.source_id
223 + WHERE f.rn = 1 ORDER BY ${order} LIMIT ${limit}`),
224 + [] as Row[],
225 + );
226 + const edges: GraphEdge[] = [];
227 + const nodes: GraphNode[] = [];
228 + for (const r of rows) {
229 + const neighbor: GraphNode =
230 + side === 'cancer'
231 + ? { type: 'gene', id: r.gene_id, ref: r.symbol, label: r.symbol, sublabel: r.is_cancer_gene ? 'cancer gene' : null, href: hrefFor('gene', r.symbol), degree: 0 }
232 + : { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, sublabel: null, href: hrefFor('cancer', r.cancer_slug), degree: 0 };
233 + nodes.push(neighbor);
234 + edges.push({
235 + key: `dv:freq:${r.gene_id}:${r.cancer_id}`,
236 + relationshipType: 'ALTERED_IN',
237 + neighborKey: nodeKey(neighbor),
238 + outgoing: side === 'gene',
239 + direction: null,
240 + evidenceLevel: null,
241 + evidenceCategory: 'observed_data',
242 + cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }],
243 + supportCount: n(r.cohorts),
244 + sourceIds: [r.source_id],
245 + sourceSlugs: [r.source_slug],
246 + provenanceIds: [r.provenance_id],
247 + derived: true,
248 + detail: `${r.cases_affected.toLocaleString('en-US')} / ${r.cases_profiled.toLocaleString('en-US')} cases (${pct(r.frequency)}) · ${r.alteration_type} · ${r.study_id}${n(r.cohorts) > 1 ? ` · largest of ${n(r.cohorts)} cohorts` : ''}`,
249 + });
250 + }
251 + return { relationshipType: 'ALTERED_IN', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
252 +}
253 +
254 +/** cancer → trials (registry, deduplicated per trial; most recently updated first). */
255 +async function cancerTrialLinks(focus: FocusNode, ids: string[], limit: number): Promise<Derived> {
256 + 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 };
257 + const rows = await safe(
258 + () =>
259 + run<Row>(sql`
260 + WITH m AS (
261 + 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
262 + )
263 + 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,
264 + count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active
265 + FROM m JOIN clinical_trials t ON t.id = m.trial_id JOIN cancers c ON c.id = m.cancer_id
266 + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`),
267 + [] as Row[],
268 + );
269 + const edges: GraphEdge[] = [];
270 + const nodes: GraphNode[] = [];
271 + for (const r of rows) {
272 + const node: GraphNode = { type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.map((p) => p.replace('PHASE', 'Phase ').replace('EARLY_', 'early ')).join('/'), r.overall_status?.toLowerCase().replace(/_/g, ' ')].filter(Boolean).join(' · ') || null, href: hrefFor('trial', r.nct_id), degree: 0 };
273 + nodes.push(node);
274 + edges.push({
275 + key: `dv:trial:${r.id}`,
276 + relationshipType: 'STUDIED_IN',
277 + neighborKey: nodeKey(node),
278 + outgoing: true,
279 + direction: null,
280 + evidenceLevel: null,
281 + evidenceCategory: 'observed_data',
282 + cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }],
283 + supportCount: 1,
284 + sourceIds: [SRC.clinicaltrials],
285 + sourceSlugs: ['clinicaltrials'],
286 + provenanceIds: [],
287 + derived: true,
288 + detail: `${r.brief_title} · condition mapped ${r.match_type}`,
289 + date: r.last_update_posted_date,
290 + });
291 + }
292 + const total = rows.length ? n(rows[0]!.total) : 0;
293 + const active = rows.length ? n(rows[0]!.active) : 0;
294 + for (const e of edges) e.detail = `${e.detail} · ${total.toLocaleString('en-US')} trials mapped (${active.toLocaleString('en-US')} active)`;
295 + return { relationshipType: 'STUDIED_IN', total, edges, nodes };
296 +}
297 +
298 +/** cancer → drugs through registered trials (trial_interventions.drug_id × trial_conditions.cancer_id). */
299 +async function cancerDrugTrialLinks(focus: FocusNode, ids: string[], limit: number): Promise<Derived> {
300 + type Row = { drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string };
301 + const rows = await safe(
302 + () =>
303 + run<Row>(sql`
304 + SELECT ti.drug_id, d.slug, d.name, d.kind, count(DISTINCT ti.trial_id) AS trials,
305 + 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 total
306 + FROM trial_conditions tc JOIN trial_interventions ti ON ti.trial_id = tc.trial_id AND ti.drug_id IS NOT NULL
307 + JOIN clinical_trials t ON t.id = tc.trial_id JOIN drugs d ON d.id = ti.drug_id
308 + WHERE tc.cancer_id IN ${inList(ids)}
309 + GROUP BY ti.drug_id, d.slug, d.name, d.kind ORDER BY trials DESC, d.name LIMIT ${limit}`),
310 + [] as Row[],
311 + );
312 + return drugTrialRows(focus, rows, 'INVESTIGATED_IN_TRIALS', true);
313 +}
314 +
315 +function drugTrialRows(focus: FocusNode, rows: Array<{ drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string }>, rel: string, outgoing: boolean): Derived {
316 + const edges: GraphEdge[] = [];
317 + const nodes: GraphNode[] = [];
318 + for (const r of rows) {
319 + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 };
320 + nodes.push(node);
321 + edges.push({
322 + key: `dv:drugtrials:${r.drug_id}:${focus.id}`,
323 + relationshipType: rel,
324 + neighborKey: nodeKey(node),
325 + outgoing,
326 + direction: null,
327 + evidenceLevel: null,
328 + evidenceCategory: 'observed_data',
329 + cancerContext: focus.type === 'cancer' ? [{ id: focus.id, name: focus.label, slug: focus.ref }] : [],
330 + supportCount: n(r.trials),
331 + sourceIds: [SRC.clinicaltrials],
332 + sourceSlugs: ['clinicaltrials'],
333 + provenanceIds: [],
334 + derived: true,
335 + detail: `${n(r.trials).toLocaleString('en-US')} trials (${n(r.active).toLocaleString('en-US')} active)${focus.type === 'cancer' ? ' · roll-up of the cancer and its descendants' : ''}`,
336 + date: r.last,
337 + });
338 + }
339 + return { relationshipType: rel, total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
340 +}
341 +
342 +/** cancer ← drug through regulatory approvals (jurisdiction-aware, dated); rows already present as APPROVED_FOR knowledge edges are skipped. */
343 +async function approvalLinks(side: 'cancer' | 'drug', focus: FocusNode, ids: string[], limit: number): Promise<Derived> {
344 + 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; source_slug: string; provenance_id: number; total: string };
345 + 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))`;
346 + const rows = await safe(
347 + () =>
348 + run<Row>(sql`
349 + 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,
350 + a.source_id, s.slug AS source_slug, a.provenance_id, count(*) OVER() AS total
351 + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id
352 + WHERE ${where} ORDER BY a.approval_date DESC NULLS LAST, a.id LIMIT ${limit}`),
353 + [] as Row[],
354 + );
355 + const edges: GraphEdge[] = [];
356 + const nodes: GraphNode[] = [];
357 + for (const r of rows) {
358 + let node: GraphNode;
359 + if (side === 'cancer') node = { type: 'drug', id: r.drug_id, ref: r.drug_slug, label: r.drug_name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.drug_slug), degree: 0 };
360 + else if (r.cancer_id && r.cancer_slug && r.cancer_name) node = { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, sublabel: null, href: hrefFor('cancer', r.cancer_slug), degree: 0 };
361 + else node = { 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`, degree: 0 };
362 + nodes.push(node);
363 + edges.push({
364 + key: `dv:approval:${r.id}`,
365 + relationshipType: 'APPROVED_FOR',
366 + neighborKey: nodeKey(node),
367 + outgoing: side === 'drug',
368 + direction: null,
369 + evidenceLevel: r.status,
370 + evidenceCategory: 'regulatory_status',
371 + cancerContext: r.cancer_id && r.cancer_slug && r.cancer_name ? [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }] : [],
372 + supportCount: 1,
373 + sourceIds: [r.source_id],
374 + sourceSlugs: [r.source_slug],
375 + provenanceIds: [r.provenance_id],
376 + derived: true,
377 + detail: `${r.authority} (${r.jurisdiction}) · ${r.status}${r.tumor_agnostic ? ' · tumour-agnostic' : ''}${r.approval_date ? ` · ${r.approval_date}` : ' · date not published'} · ${r.indication.length > 140 ? `${r.indication.slice(0, 139)}…` : r.indication}`,
378 + date: r.approval_date,
379 + });
380 + }
381 + return { relationshipType: 'APPROVED_FOR', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
382 +}
383 +
384 +/** gene → variants (top by CIViC evidence count; the variant list itself is structural HGNC/ClinVar/CIViC data). */
385 +async function geneVariantLinks(focus: FocusNode, limit: number): Promise<Derived> {
386 + type Row = { id: string; slug: string; name: string; variant_type: string | null; ev: string; context_ids: string[] | null; provenance_ids: number[] | null; total: string };
387 + const rows = await safe(
388 + () =>
389 + run<Row>(sql`
390 + WITH ev AS (
391 + 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_ids
392 + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.variant_ids) vid
393 + WHERE e.status = 'ACCEPTED' AND (${focus.id} = ANY(e.gene_ids) OR ${focus.label} = ANY(e.gene_symbols)) GROUP BY vid
394 + )
395 + 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 total
396 + FROM variants v LEFT JOIN ev ON ev.vid = v.id WHERE v.gene_id = ${focus.id}
397 + ORDER BY coalesce(ev.ev, 0) DESC, v.name LIMIT ${limit}`),
398 + [] as Row[],
399 + );
400 + const edges: GraphEdge[] = [];
401 + const nodes: GraphNode[] = [];
402 + for (const r of rows) {
403 + const node: GraphNode = { type: 'variant', id: r.id, ref: r.slug, label: r.name, sublabel: r.variant_type, href: hrefFor('variant', r.slug), degree: 0 };
404 + nodes.push(node);
405 + const ev = n(r.ev);
406 + edges.push({
407 + key: `dv:variant:${r.id}`,
408 + relationshipType: 'HAS_VARIANT',
409 + neighborKey: nodeKey(node),
410 + outgoing: true,
411 + direction: null,
412 + evidenceLevel: null,
413 + evidenceCategory: ev > 0 ? 'curated_evidence' : 'observed_data',
414 + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })),
415 + supportCount: ev,
416 + sourceIds: [SRC.civic],
417 + sourceSlugs: ['civic'],
418 + provenanceIds: r.provenance_ids ?? [],
419 + derived: true,
420 + detail: ev > 0 ? `${ev.toLocaleString('en-US')} accepted CIViC evidence items` : 'no accepted CIViC evidence item (variant record only)',
421 + });
422 + }
423 + return { relationshipType: 'HAS_VARIANT', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
424 +}
425 +
426 +/** variant → cancers with accepted CIViC evidence, aggregated by level (A–E) and direction. */
427 +async function variantEvidenceLinks(focus: FocusNode, limit: number): Promise<Derived> {
428 + type Row = { cancer_id: string; slug: string; name: string; items: string; levels: string[]; best: string | null; sens: string; res: string; supports: string; does_not: string; provenance_ids: number[]; total: string };
429 + const rows = await safe(
430 + () =>
431 + run<Row>(sql`
432 + SELECT e.cancer_id, c.slug, c.canonical_name AS name, count(*) AS items,
433 + array_agg(DISTINCT e.evidence_level ORDER BY e.evidence_level) FILTER (WHERE e.evidence_level IS NOT NULL) AS levels,
434 + min(e.evidence_level) AS best,
435 + count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,
436 + count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not,
437 + (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total
438 + FROM civic_evidence_items e JOIN cancers c ON c.id = e.cancer_id
439 + WHERE e.status = 'ACCEPTED' AND ${focus.id} = ANY(e.variant_ids)
440 + GROUP BY e.cancer_id, c.slug, c.canonical_name ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, c.canonical_name LIMIT ${limit}`),
441 + [] as Row[],
442 + );
443 + const edges: GraphEdge[] = [];
444 + const nodes: GraphNode[] = [];
445 + for (const r of rows) {
446 + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 };
447 + nodes.push(node);
448 + const sens = n(r.sens);
449 + const res = n(r.res);
450 + edges.push({
451 + key: `dv:civic:${focus.id}:${r.cancer_id}`,
452 + relationshipType: 'HAS_EVIDENCE_IN',
453 + neighborKey: nodeKey(node),
454 + outgoing: true,
455 + direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : n(r.supports) && !n(r.does_not) ? 'supports' : n(r.does_not) ? 'does not support' : null,
456 + evidenceLevel: r.best,
457 + evidenceCategory: 'curated_evidence',
458 + cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }],
459 + supportCount: n(r.items),
460 + sourceIds: [SRC.civic],
461 + sourceSlugs: ['civic'],
462 + provenanceIds: r.provenance_ids ?? [],
463 + derived: true,
464 + detail: `${n(r.items).toLocaleString('en-US')} accepted items · levels ${(r.levels ?? []).join(', ') || '—'} · ${sens} sensitivity / ${res} resistance · ${n(r.supports)} supports / ${n(r.does_not)} does not support`,
465 + });
466 + }
467 + return { relationshipType: 'HAS_EVIDENCE_IN', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
468 +}
469 +
470 +/** variant → drugs from CIViC predictive items that have no PREDICTS_RESPONSE_TO knowledge edge yet (gap filler, flagged derived). */
471 +async function variantDrugCivicLinks(focus: FocusNode, limit: number): Promise<Derived> {
472 + type Row = { drug_id: string; slug: string; name: string; kind: string | null; items: string; best: string | null; sens: string; res: string; context_ids: string[]; provenance_ids: number[]; total: string };
473 + const rows = await safe(
474 + () =>
475 + run<Row>(sql`
476 + SELECT tid AS drug_id, d.slug, d.name, d.kind, count(*) AS items, min(e.evidence_level) AS best,
477 + count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,
478 + (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 total
479 + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid
480 + WHERE e.status = 'ACCEPTED' AND e.evidence_type = 'PREDICTIVE' AND ${focus.id} = ANY(e.variant_ids)
481 + 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)
482 + GROUP BY tid, d.slug, d.name, d.kind ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, d.name LIMIT ${limit}`),
483 + [] as Row[],
484 + );
485 + const edges: GraphEdge[] = [];
486 + const nodes: GraphNode[] = [];
487 + for (const r of rows) {
488 + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 };
489 + nodes.push(node);
490 + const sens = n(r.sens);
491 + const res = n(r.res);
492 + edges.push({
493 + key: `dv:civicdrug:${focus.id}:${r.drug_id}`,
494 + relationshipType: 'PREDICTS_RESPONSE_TO',
495 + neighborKey: nodeKey(node),
496 + outgoing: true,
497 + direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : null,
498 + evidenceLevel: r.best,
499 + evidenceCategory: 'curated_evidence',
500 + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })),
501 + supportCount: n(r.items),
502 + sourceIds: [SRC.civic],
503 + sourceSlugs: ['civic'],
504 + provenanceIds: r.provenance_ids ?? [],
505 + derived: true,
506 + detail: `${n(r.items)} accepted predictive items (aggregated from CIViC, no knowledge edge yet)`,
507 + });
508 + }
509 + return { relationshipType: 'PREDICTS_RESPONSE_TO', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
510 +}
511 +
512 +/** drug → trials (registry, most recently updated first) with the trial's mapped cancers as context. */
513 +async function drugTrialLinks(focus: FocusNode, limit: number): Promise<Derived> {
514 + 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 };
515 + const rows = await safe(
516 + () =>
517 + run<Row>(sql`
518 + SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date,
519 + (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,
520 + count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active
521 + FROM (SELECT DISTINCT trial_id FROM trial_interventions WHERE drug_id = ${focus.id}) ti JOIN clinical_trials t ON t.id = ti.trial_id
522 + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`),
523 + [] as Row[],
524 + );
525 + const edges: GraphEdge[] = [];
526 + const nodes: GraphNode[] = [];
527 + const total = rows.length ? n(rows[0]!.total) : 0;
528 + const active = rows.length ? n(rows[0]!.active) : 0;
529 + for (const r of rows) {
530 + const node: GraphNode = { type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.map((p) => p.replace('PHASE', 'Phase ').replace('EARLY_', 'early ')).join('/'), r.overall_status?.toLowerCase().replace(/_/g, ' ')].filter(Boolean).join(' · ') || null, href: hrefFor('trial', r.nct_id), degree: 0 };
531 + nodes.push(node);
532 + edges.push({
533 + key: `dv:drugtrial:${r.id}`,
534 + relationshipType: 'STUDIED_IN',
535 + neighborKey: nodeKey(node),
536 + outgoing: true,
537 + direction: null,
538 + evidenceLevel: null,
539 + evidenceCategory: 'observed_data',
540 + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })),
541 + supportCount: 1,
542 + sourceIds: [SRC.clinicaltrials],
543 + sourceSlugs: ['clinicaltrials'],
544 + provenanceIds: [],
545 + derived: true,
546 + detail: `${r.brief_title} · ${total.toLocaleString('en-US')} trials list this drug (${active.toLocaleString('en-US')} active)`,
547 + date: r.last_update_posted_date,
548 + });
549 + }
550 + return { relationshipType: 'STUDIED_IN', total, edges, nodes };
551 +}
552 +
553 +/** drug → cancers through registered trials. */
554 +async function drugCancerTrialLinks(focus: FocusNode, limit: number): Promise<Derived> {
555 + type Row = { cancer_id: string; slug: string; name: string; trials: string; active: string; last: string | null; total: string };
556 + const rows = await safe(
557 + () =>
558 + run<Row>(sql`
559 + SELECT tc.cancer_id, c.slug, c.canonical_name AS name, count(DISTINCT tc.trial_id) AS trials,
560 + 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 total
561 + FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IS NOT NULL
562 + JOIN clinical_trials t ON t.id = ti.trial_id JOIN cancers c ON c.id = tc.cancer_id
563 + WHERE ti.drug_id = ${focus.id}
564 + GROUP BY tc.cancer_id, c.slug, c.canonical_name ORDER BY trials DESC, c.canonical_name LIMIT ${limit}`),
565 + [] as Row[],
566 + );
567 + const edges: GraphEdge[] = [];
568 + const nodes: GraphNode[] = [];
569 + for (const r of rows) {
570 + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 };
571 + nodes.push(node);
572 + edges.push({
573 + key: `dv:drugtrials:${focus.id}:${r.cancer_id}`,
574 + relationshipType: 'INVESTIGATED_IN_TRIALS',
575 + neighborKey: nodeKey(node),
576 + outgoing: true,
577 + direction: null,
578 + evidenceLevel: null,
579 + evidenceCategory: 'observed_data',
580 + cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }],
581 + supportCount: n(r.trials),
582 + sourceIds: [SRC.clinicaltrials],
583 + sourceSlugs: ['clinicaltrials'],
584 + provenanceIds: [],
585 + derived: true,
586 + detail: `${n(r.trials).toLocaleString('en-US')} trials (${n(r.active).toLocaleString('en-US')} active) · conditions mapped to this cancer only (no hierarchy roll-up)`,
587 + date: r.last,
588 + });
589 + }
590 + return { relationshipType: 'INVESTIGATED_IN_TRIALS', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes };
591 +}
592 +
593 +/** trial → mapped conditions (cancers) and interventions (drugs). */
594 +async function trialLinks(focus: FocusNode, limit: number): Promise<Derived[]> {
595 + type CRow = { cancer_id: string; slug: string; name: string; match_type: string; condition_text: string; total: string };
596 + type DRow = { drug_id: string; slug: string; name: string; kind: string | null; match_type: string; intervention_type: string | null; iname: string; total: string };
597 + const [conds, ints] = await Promise.all([
598 + safe(() => run<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}`), [] as CRow[]),
599 + safe(() => run<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}`), [] as DRow[]),
600 + ]);
601 + const c: Derived = { relationshipType: 'CONDITION_OF', total: conds.length ? n(conds[0]!.total) : 0, edges: [], nodes: [] };
602 + for (const r of conds) {
603 + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 };
604 + c.nodes.push(node);
605 + c.edges.push({ key: `dv:cond:${focus.id}:${r.cancer_id}`, relationshipType: 'CONDITION_OF', neighborKey: nodeKey(node), 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], sourceSlugs: ['clinicaltrials'], provenanceIds: [], derived: true, detail: `registry condition “${r.condition_text}” mapped ${r.match_type}` });
606 + }
607 + const d: Derived = { relationshipType: 'INTERVENTION_OF', total: ints.length ? n(ints[0]!.total) : 0, edges: [], nodes: [] };
608 + for (const r of ints) {
609 + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 };
610 + d.nodes.push(node);
611 + d.edges.push({ key: `dv:int:${focus.id}:${r.drug_id}`, relationshipType: 'INTERVENTION_OF', neighborKey: nodeKey(node), outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [], supportCount: 1, sourceIds: [SRC.clinicaltrials], sourceSlugs: ['clinicaltrials'], provenanceIds: [], derived: true, detail: `registry intervention “${r.iname}” (${r.intervention_type ?? 'type not stated'}) mapped ${r.match_type}` });
612 + }
613 + return [c, d];
614 +}
615 +
616 +// ---------------------------------------------------------------------------------------------
617 +// Assembly
618 +// ---------------------------------------------------------------------------------------------
619 +
620 +async function cancerNames(ids: Iterable<string>): Promise<Map<string, CancerContext>> {
621 + const uniq = [...new Set(ids)].filter(Boolean);
622 + if (uniq.length === 0) return new Map();
623 + const rows = await safe(() => run<{ id: string; slug: string; name: string }>(sql`SELECT id, slug, canonical_name AS name FROM cancers WHERE id IN ${inList(uniq)}`), [] as Array<{ id: string; slug: string; name: string }>);
624 + return new Map(rows.map((r) => [r.id, { id: r.id, slug: r.slug, name: r.name }]));
625 +}
626 +
627 +/** Descendant ids (inclusive) for a cancer focus, capped so very broad families stay bounded. */
628 +export async function focusCancerIds(focus: FocusNode): Promise<string[]> {
629 + if (focus.type !== 'cancer') return [];
630 + const ids = await getDescendantIds(focus.id);
631 + return ids.length > MAX_DESCENDANTS ? [focus.id, ...ids.filter((i) => i !== focus.id).slice(0, MAX_DESCENDANTS - 1)] : ids;
632 +}
633 +
634 +export interface NeighborhoodOptions {
635 + /** Relationship type whose group is expanded to EXPANDED_GROUP_LIMIT. */
636 + more?: string | null;
637 + includeDerived?: boolean;
638 +}
639 +
640 +export async function loadNeighborhood(focus: FocusNode, opts: NeighborhoodOptions = {}): Promise<Neighborhood & { cancerIds: string[] }> {
641 + const more = opts.more ?? null;
642 + const includeDerived = opts.includeDerived ?? true;
643 + const cancerIds = await focusCancerIds(focus);
644 + const lim = (rel: string, base = DEFAULT_GROUP_LIMIT) => groupLimit(rel, more, base);
645 +
646 + const derivedTasks: Array<Promise<Derived | Derived[]>> = [];
647 + if (includeDerived) {
648 + switch (focus.type) {
649 + case 'cancer':
650 + derivedTasks.push(cancerTrialLinks(focus, cancerIds, lim('STUDIED_IN', TRIAL_GROUP_LIMIT)), frequencyLinks('cancer', focus, cancerIds, lim('ALTERED_IN')), approvalLinks('cancer', focus, cancerIds, lim('APPROVED_FOR')), cancerDrugTrialLinks(focus, cancerIds, lim('INVESTIGATED_IN_TRIALS')));
651 + break;
652 + case 'gene':
653 + derivedTasks.push(geneVariantLinks(focus, lim('HAS_VARIANT')), frequencyLinks('gene', focus, [], lim('ALTERED_IN')));
654 + break;
655 + case 'variant':
656 + derivedTasks.push(variantEvidenceLinks(focus, lim('HAS_EVIDENCE_IN')), variantDrugCivicLinks(focus, lim('PREDICTS_RESPONSE_TO')));
657 + break;
658 + case 'drug':
659 + derivedTasks.push(drugTrialLinks(focus, lim('STUDIED_IN', TRIAL_GROUP_LIMIT)), drugCancerTrialLinks(focus, lim('INVESTIGATED_IN_TRIALS')), approvalLinks('drug', focus, [], lim('APPROVED_FOR')));
660 + break;
661 + case 'trial':
662 + derivedTasks.push(trialLinks(focus, lim('CONDITION_OF')));
663 + break;
664 + }
665 + }
666 + const [ke, ...derivedRaw] = await Promise.all([focus.type === 'trial' ? Promise.resolve([] as KeRow[]) : knowledgeEdges(focus, more), ...derivedTasks]);
667 + const derived = derivedRaw.flat();
668 +
669 + // Source-native edges → GraphEdge + nodes
670 + const nodes = new Map<string, GraphNode>();
671 + const groups = new Map<string, EdgeGroup>();
672 + const ctxIds = new Set<string>();
673 + for (const r of ke) for (const c of r.context_ids ?? []) ctxIds.add(c);
674 + for (const d of derived) for (const e of d.edges) for (const c of e.cancerContext) if (!c.slug) ctxIds.add(c.id);
675 + const names = await cancerNames(ctxIds);
676 + const ctx = (ids: string[] | null | undefined): CancerContext[] => (ids ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }).sort((a, b) => a.name.localeCompare(b.name));
677 +
678 + const addNode = (node: GraphNode) => {
679 + const k = nodeKey(node);
680 + const cur = nodes.get(k);
681 + if (cur) cur.degree += 1;
682 + else nodes.set(k, { ...node, degree: 1 });
683 + };
684 + const addEdge = (e: GraphEdge, total: number, derivedGroup: boolean) => {
685 + const g = groups.get(e.relationshipType) ?? { relationshipType: e.relationshipType, total: 0, edges: [], derived: derivedGroup };
686 + g.edges.push(e);
687 + g.total = Math.max(g.total, total);
688 + if (!derivedGroup) g.derived = false;
689 + groups.set(e.relationshipType, g);
690 + };
691 +
692 + for (const r of ke) {
693 + if (!r.n_ref || !r.n_label) continue; // dangling target (entity not loaded on this environment)
694 + const node: GraphNode = { type: r.n_type, id: r.n_id, ref: r.n_ref, label: r.n_label, sublabel: r.n_sublabel, href: hrefFor(r.n_type, r.n_ref), degree: 0 };
695 + addNode(node);
696 + addEdge(
697 + {
698 + key: `ke:${(r.edge_ids ?? []).join('.')}`,
699 + relationshipType: r.relationship_type,
700 + neighborKey: nodeKey(node),
701 + outgoing: r.outgoing,
702 + direction: r.direction,
703 + evidenceLevel: r.evidence_level,
704 + evidenceCategory: r.evidence_category,
705 + cancerContext: ctx(r.context_ids),
706 + supportCount: n(r.support),
707 + sourceIds: [r.source_id],
708 + sourceSlugs: [r.source_slug],
709 + provenanceIds: (r.provenance_ids ?? []).map(Number),
710 + derived: false,
711 + detail: (r.edge_ids?.length ?? 1) > 1 ? `${r.edge_ids.length} source records aggregated` : null,
712 + date: r.last_seen ? new Date(r.last_seen).toISOString().slice(0, 10) : null,
713 + 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: hrefFor(r.via_type, r.via_ref) } : null,
714 + },
715 + n(r.total),
716 + false,
717 + );
718 + }
719 + for (const d of derived) {
720 + for (let i = 0; i < d.edges.length; i++) {
721 + const e = d.edges[i]!;
722 + const node = d.nodes[i]!;
723 + addNode(node);
724 + e.cancerContext = e.cancerContext.map((c) => (c.slug ? c : (names.get(c.id) ?? c)));
725 + addEdge(e, d.total, true);
726 + }
727 + }
728 +
729 + const degreeByType: Record<NodeType, number> = { cancer: 0, gene: 0, variant: 0, drug: 0, trial: 0, approval: 0 };
730 + for (const node of nodes.values()) degreeByType[node.type] += 1;
731 + const focusOut: GraphNode = { ...focus, degree: [...groups.values()].reduce((a, g) => a + g.total, 0) };
732 + return { focus: focusOut, nodes: [...nodes.values()], groups: sortGroups([...groups.values()]), degreeByType, cancerIds };
733 +}
734 +
735 +// ---------------------------------------------------------------------------------------------
736 +// Paths (cancer focus)
737 +// ---------------------------------------------------------------------------------------------
738 +
739 +/**
740 + * Strongest cancer → gene → variant → drug → approval → trials chains. The variant → drug hop is a
741 + * source-native PREDICTS_RESPONSE_TO edge (direction sensitivity) whose context includes the cancer
742 + * or one of its descendants; the gene hop is the variant's gene with its top cohort frequency in
743 + * the cancer — the cohort with the largest denominator (null when no cohort covers it — shown as "not yet available", never invented); the
744 + * approval hop is the earliest drug_approvals row in the cancer (or tumour-agnostic); the trial
745 + * hop counts registry trials listing the drug for the cancer. Ranked by level, then support.
746 + */
747 +export async function loadPaths(focus: FocusNode, cancerIds: string[], limit = PATHS_LIMIT): Promise<PathChain[]> {
748 + if (focus.type !== 'cancer' || cancerIds.length === 0) return [];
749 + type Row = {
750 + variant_id: string; variant_slug: string; variant_name: string; gene_id: string; symbol: string; drug_id: string; drug_slug: string; drug_name: string;
751 + evidence_level: string | null; direction: string | null; support: string; source_ids: string[]; provenance_ids: number[]; context_ids: string[];
752 + frequency: number | null; cases_affected: number | null; cases_profiled: number | null; cohorts: string | null;
753 + 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;
754 + trials: string | null; active_trials: string | null;
755 + };
756 + const rows = await safe(
757 + () =>
758 + run<Row>(sql`
759 + WITH ids AS (SELECT unnest(ARRAY[${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}]::varchar[]) AS id),
760 + ed AS (
761 + 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,
762 + sum(ke.support_count) AS support, array_agg(DISTINCT ke.source_id) AS source_ids,
763 + (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,
764 + (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_ids
765 + FROM knowledge_edges ke
766 + 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'
767 + AND ke.cancer_context_ids && (SELECT array_agg(id)::text[] FROM ids)
768 + GROUP BY ke.source_entity_id, ke.target_entity_id
769 + ),
770 + fq AS (
771 + 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 cohorts
772 + 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}
773 + ORDER BY f.gene_id, f.cases_profiled DESC, f.frequency DESC
774 + )
775 + 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,
776 + ed.evidence_level, ed.direction, ed.support, ed.source_ids, ed.provenance_ids, ed.context_ids,
777 + fq.frequency, fq.cases_affected, fq.cases_profiled, fq.cohorts,
778 + 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,
779 + tr.trials, tr.active_trials
780 + 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_id
781 + LEFT JOIN fq ON fq.gene_id = g.id
782 + LEFT JOIN LATERAL (
783 + SELECT a.id, a.jurisdiction, a.authority, a.approval_date, a.status, a.cancer_id, a.tumor_agnostic, count(*) OVER() AS approvals
784 + FROM drug_approvals a WHERE a.drug_id = ed.drug_id AND (a.cancer_id IN (SELECT id FROM ids) OR a.tumor_agnostic)
785 + ORDER BY (a.cancer_id IS NOT NULL) DESC, a.approval_date ASC NULLS LAST, a.id LIMIT 1
786 + ) ap ON true
787 + LEFT JOIN cancers ac ON ac.id = ap.cancer_id
788 + LEFT JOIN LATERAL (
789 + SELECT count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active_trials
790 + 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_id
791 + WHERE ti.drug_id = ed.drug_id
792 + ) tr ON true
793 + ORDER BY ed.lvl, ed.support DESC, fq.frequency DESC NULLS LAST, g.symbol, v.name, d.name
794 + LIMIT ${limit}`),
795 + [] as Row[],
796 + );
797 + const names = await cancerNames(rows.flatMap((r) => r.context_ids ?? []));
798 + const chains: PathChain[] = rows.map((r) => ({
799 + cancer: { id: focus.id, slug: focus.ref, name: focus.label },
800 + gene: { id: r.gene_id, symbol: r.symbol, frequency: r.frequency, casesAffected: r.cases_affected, casesProfiled: r.cases_profiled, cohorts: n(r.cohorts) },
801 + variant: { id: r.variant_id, slug: r.variant_slug, name: r.variant_name },
802 + drug: { id: r.drug_id, slug: r.drug_slug, name: r.drug_name },
803 + edge: { evidenceLevel: r.evidence_level, direction: r.direction, supportCount: n(r.support), sourceIds: r.source_ids ?? [], provenanceIds: (r.provenance_ids ?? []).map(Number), contextIds: r.context_ids ?? [], contextNames: (r.context_ids ?? []).map((id) => names.get(id)?.name ?? id) },
804 + 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: n(r.approvals) } : null,
805 + trials: r.trials !== null && r.trials !== undefined ? { total: n(r.trials), active: n(r.active_trials) } : null,
806 + }));
807 + return chains.sort(compareChains);
808 +}
809 +
810 +// ---------------------------------------------------------------------------------------------
811 +// Default focus + example foci (data-driven, never hardcoded)
812 +// ---------------------------------------------------------------------------------------------
813 +
814 +export interface FocusSuggestion {
815 + type: Exclude<NodeType, 'approval' | 'trial' | 'variant'>;
816 + ref: string;
817 + label: string;
818 + edges: number;
819 +}
820 +
821 +/** Most-connected cancers (2), genes (2) and drugs (2) by knowledge-edge count; the first cancer is the default focus. */
822 +export async function suggestedFoci(): Promise<FocusSuggestion[]> {
823 + type Row = { type: FocusSuggestion['type']; ref: string; label: string; edges: string };
824 + const rows = await safe(
825 + () =>
826 + run<Row>(sql`
827 + WITH k AS (
828 + SELECT eid, count(*) AS n FROM (
829 + SELECT source_entity_id AS eid FROM knowledge_edges WHERE status = 'active'
830 + UNION ALL SELECT target_entity_id FROM knowledge_edges WHERE status = 'active'
831 + UNION ALL SELECT unnest(cancer_context_ids) FROM knowledge_edges WHERE status = 'active'
832 + ) x GROUP BY eid
833 + )
834 + (SELECT 'cancer' AS type, c.slug AS ref, c.canonical_name AS label, k.n AS edges FROM k JOIN cancers c ON c.id = k.eid WHERE c.status = 'active' ORDER BY k.n DESC, c.slug LIMIT 2)
835 + UNION ALL (SELECT 'gene', g.symbol, g.symbol, k.n FROM k JOIN genes g ON g.id = k.eid ORDER BY k.n DESC, g.symbol LIMIT 2)
836 + UNION ALL (SELECT 'drug', d.slug, d.name, k.n FROM k JOIN drugs d ON d.id = k.eid ORDER BY k.n DESC, d.slug LIMIT 2)`),
837 + [] as Row[],
838 + );
839 + return rows.map((r) => ({ type: r.type, ref: r.ref, label: r.label, edges: n(r.edges) }));
840 +}
841 +
842 +export async function defaultFocus(): Promise<FocusRef | null> {
843 + const s = (await suggestedFoci()).find((x) => x.type === 'cancer');
844 + return s ? { type: 'cancer', ref: s.ref } : null;
845 +}
846 +
847 +/** Freshness: latest last_seen_at across the focus' knowledge edges. */
848 +export async function edgesFreshness(focus: FocusNode): Promise<Date | null> {
849 + const rows = await safe(() => run<{ t: Date | null }>(sql`SELECT max(last_seen_at) AS t FROM knowledge_edges WHERE source_entity_id = ${focus.id} OR target_entity_id = ${focus.id} OR ${focus.id} = ANY(cancer_context_ids)`), [{ t: null }]);
850 + return rows[0]?.t ?? null;
851 +}
852 +
added apps/web/test/graph-layout.test.ts +184 −0
@@ -0,0 +1,184 @@
1 +import { describe, it, expect } from 'vitest';
2 +import { layoutRadial, selectNodes, nodeRadius, labelPlacement, parallelOffsets, shortLabel, parseFocus, evidenceLevelRank, compareChains, edgeStroke, sortGroups, NODE_TYPE_ORDER, type GraphNode, type NodeType, type PathChain } from '@/lib/graph-model';
3 +
4 +const mk = (type: NodeType, i: number, degree = 1): GraphNode => ({ type, id: `${type}-${i}`, ref: `${type}-${i}`, label: `${type} ${i}`, href: `/${type}/${i}`, degree });
5 +
6 +function many(counts: Partial<Record<NodeType, number>>, degreeOf = (i: number) => 1 + (i % 7)): GraphNode[] {
7 + const out: GraphNode[] = [];
8 + for (const [t, n] of Object.entries(counts) as Array<[NodeType, number]>) for (let i = 0; i < n; i++) out.push(mk(t, i, degreeOf(i)));
9 + return out;
10 +}
11 +
12 +const TAU = Math.PI * 2;
13 +
14 +describe('layoutRadial', () => {
15 + it('places every drawn node inside the viewBox with room for its label', () => {
16 + const nodes = many({ cancer: 20, gene: 15, variant: 10, drug: 12, trial: 10, approval: 3 });
17 + const l = layoutRadial(nodes, { size: 760, maxNodes: 60 });
18 + expect(l.nodes.length).toBe(60);
19 + for (const p of l.nodes) {
20 + expect(p.x - p.r).toBeGreaterThanOrEqual(0);
21 + expect(p.y - p.r).toBeGreaterThanOrEqual(0);
22 + expect(p.x + p.r).toBeLessThanOrEqual(760);
23 + expect(p.y + p.r).toBeLessThanOrEqual(760);
24 + // radial label margin: the label anchor stays inside the box too
25 + const lp = labelPlacement(p);
26 + expect(lp.x).toBeGreaterThanOrEqual(0);
27 + expect(lp.x).toBeLessThanOrEqual(760);
28 + expect(lp.y).toBeGreaterThanOrEqual(0);
29 + expect(lp.y).toBeLessThanOrEqual(760);
30 + // the ring leaves the configured label margin free
31 + expect(Math.hypot(p.x - l.cx, p.y - l.cy)).toBeLessThanOrEqual(760 / 2 - 140 + 0.001);
32 + }
33 + });
34 +
35 + it('keeps sectors in the fixed entity-type order and never overlapping', () => {
36 + const nodes = many({ drug: 9, cancer: 4, trial: 6, gene: 11, approval: 2, variant: 7 });
37 + const l = layoutRadial(nodes);
38 + const types = l.sectors.map((s) => s.type);
39 + expect(types).toEqual(NODE_TYPE_ORDER.filter((t) => types.includes(t)));
40 + for (let i = 1; i < l.sectors.length; i++) {
41 + expect(l.sectors[i]!.start).toBeGreaterThan(l.sectors[i - 1]!.end);
42 + }
43 + const span = l.sectors[l.sectors.length - 1]!.end - l.sectors[0]!.start;
44 + expect(span).toBeLessThan(TAU);
45 + // every node's angle lies inside its own sector
46 + for (const p of l.nodes) {
47 + const s = l.sectors.find((x) => x.type === p.node.type)!;
48 + expect(p.angle).toBeGreaterThanOrEqual(s.start);
49 + expect(p.angle).toBeLessThanOrEqual(s.end);
50 + }
51 + });
52 +
53 + it('gives adjacent nodes enough angular spacing for an 11 px radial label at 60 nodes', () => {
54 + const nodes = many({ cancer: 10, gene: 10, variant: 10, drug: 10, trial: 10, approval: 10 });
55 + const l = layoutRadial(nodes, { size: 760, maxNodes: 60 });
56 + const sorted = [...l.nodes].sort((a, b) => a.angle - b.angle);
57 + for (let i = 1; i < sorted.length; i++) {
58 + const a = sorted[i - 1]!;
59 + const b = sorted[i]!;
60 + if (a.node.type !== b.node.type) continue; // sectors are separated by gaps
61 + const arc = (b.angle - a.angle) * a.ring;
62 + expect(arc).toBeGreaterThan(12);
63 + }
64 + });
65 +
66 + it('is deterministic and independent of input order', () => {
67 + const nodes = many({ cancer: 7, gene: 5, drug: 6 });
68 + const a = layoutRadial(nodes);
69 + const b = layoutRadial([...nodes].reverse());
70 + expect(a.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)])).toEqual(b.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)]));
71 + });
72 +
73 + it('orders nodes inside a sector by degree then label', () => {
74 + const nodes = [mk('gene', 1, 2), mk('gene', 2, 9), mk('gene', 3, 2)];
75 + const l = layoutRadial(nodes);
76 + expect(l.nodes.map((p) => p.node.id)).toEqual(['gene-2', 'gene-1', 'gene-3']);
77 + });
78 +
79 + it('handles an empty neighbourhood and a single node', () => {
80 + const empty = layoutRadial([]);
81 + expect(empty.nodes).toEqual([]);
82 + expect(empty.sectors).toEqual([]);
83 + const one = layoutRadial([mk('drug', 1)]);
84 + expect(one.nodes.length).toBe(1);
85 + expect(one.sectors.length).toBe(1);
86 + // a lone node sits at the centre of its sector
87 + const s = one.sectors[0]!;
88 + expect(one.nodes[0]!.angle).toBeCloseTo((s.start + s.end) / 2, 9);
89 + });
90 +
91 + it('reports hidden nodes when the cap is exceeded', () => {
92 + const l = layoutRadial(many({ cancer: 50, gene: 50 }), { maxNodes: 60 });
93 + expect(l.nodes.length).toBe(60);
94 + expect(l.hidden).toBe(40);
95 + });
96 +});
97 +
98 +describe('selectNodes', () => {
99 + it('keeps every type represented (round-robin by degree) and is deterministic', () => {
100 + const nodes = many({ cancer: 40, gene: 40, approval: 1, trial: 2 });
101 + const { drawn, hidden } = selectNodes(nodes, 10);
102 + expect(drawn.length).toBe(10);
103 + expect(hidden).toBe(73);
104 + expect(drawn.some((n) => n.type === 'approval')).toBe(true);
105 + expect(drawn.filter((n) => n.type === 'trial').length).toBe(2);
106 + const again = selectNodes([...nodes].reverse(), 10);
107 + expect(again.drawn.map((n) => n.id)).toEqual(drawn.map((n) => n.id));
108 + });
109 +});
110 +
111 +describe('helpers', () => {
112 + it('nodeRadius grows with the log of the degree and is capped', () => {
113 + expect(nodeRadius(0)).toBe(4);
114 + expect(nodeRadius(1)).toBeGreaterThan(nodeRadius(0));
115 + expect(nodeRadius(1000)).toBeLessThanOrEqual(13);
116 + });
117 + it('labelPlacement flips text on the left half so it never reads upside down', () => {
118 + const right = labelPlacement({ node: mk('gene', 1), x: 500, y: 380, r: 5, angle: 0, ring: 120 });
119 + const left = labelPlacement({ node: mk('gene', 1), x: 260, y: 380, r: 5, angle: Math.PI, ring: 120 });
120 + expect(right.anchor).toBe('start');
121 + expect(left.anchor).toBe('end');
122 + expect(Math.abs(left.rotate)).toBeCloseTo(360, 5);
123 + });
124 + it('parallelOffsets are centred and symmetric', () => {
125 + expect(parallelOffsets(1)).toEqual([0]);
126 + expect(parallelOffsets(3)).toEqual([-3, 0, 3]);
127 + expect(parallelOffsets(2)).toEqual([-1.5, 1.5]);
128 + });
129 + it('shortLabel truncates with an ellipsis', () => {
130 + expect(shortLabel('Lung Non-Small Cell Carcinoma', 12)).toBe('Lung Non-Sm…');
131 + expect(shortLabel('EGFR')).toBe('EGFR');
132 + });
133 + it('edgeStroke is dashed for derived and observed links, solid for curated / regulatory', () => {
134 + expect(edgeStroke({ derived: true, evidenceCategory: 'regulatory_status' })).toBe('dashed');
135 + expect(edgeStroke({ derived: false, evidenceCategory: 'observed_data' })).toBe('dashed');
136 + expect(edgeStroke({ derived: false, evidenceCategory: 'curated_evidence' })).toBe('solid');
137 + expect(edgeStroke({ derived: false, evidenceCategory: 'regulatory_status' })).toBe('solid');
138 + });
139 + it('sortGroups puts regulatory and curated relationships before derived counts', () => {
140 + const g = sortGroups([{ relationshipType: 'STUDIED_IN' }, { relationshipType: 'ASSOCIATED_WITH' }, { relationshipType: 'APPROVED_FOR' }, { relationshipType: 'ZZ_UNKNOWN' }]);
141 + expect(g.map((x) => x.relationshipType)).toEqual(['APPROVED_FOR', 'ASSOCIATED_WITH', 'STUDIED_IN', 'ZZ_UNKNOWN']);
142 + });
143 +});
144 +
145 +describe('parseFocus', () => {
146 + it('parses type:ref pairs and bare identifiers', () => {
147 + expect(parseFocus('cancer:melanoma')).toEqual({ type: 'cancer', ref: 'melanoma' });
148 + expect(parseFocus('Gene: egfr')).toEqual({ type: 'gene', ref: 'egfr' });
149 + expect(parseFocus('NCT04487080')).toEqual({ type: 'trial', ref: 'NCT04487080' });
150 + expect(parseFocus('CI-DRUG-00000464')).toEqual({ type: 'drug', ref: 'CI-DRUG-00000464' });
151 + expect(parseFocus('TP53')).toEqual({ type: 'gene', ref: 'TP53' });
152 + expect(parseFocus('lung adenocarcinoma')).toEqual({ type: 'cancer', ref: 'lung adenocarcinoma' });
153 + });
154 + it('rejects unknown types and empty input', () => {
155 + expect(parseFocus('publication:123')).toBeNull();
156 + expect(parseFocus('')).toBeNull();
157 + expect(parseFocus('drug:')).toBeNull();
158 + });
159 +});
160 +
161 +describe('evidence ranking', () => {
162 + it('ranks CIViC A–E, then native phase scales, unknown last', () => {
163 + expect(evidenceLevelRank('A')).toBeLessThan(evidenceLevelRank('B'));
164 + expect(evidenceLevelRank('E')).toBeLessThan(evidenceLevelRank('INHIBITOR'));
165 + expect(evidenceLevelRank('4')).toBeLessThan(evidenceLevelRank('1'));
166 + expect(evidenceLevelRank(null)).toBe(99);
167 + });
168 + it('compareChains orders by level, support, frequency, then names (stable)', () => {
169 + const base: PathChain = {
170 + cancer: { id: 'c', slug: 'c', name: 'C' },
171 + gene: { id: 'g', symbol: 'EGFR', frequency: 0.2, casesAffected: 20, casesProfiled: 100, cohorts: 1 },
172 + variant: { id: 'v', slug: 'v', name: 'L858R' },
173 + drug: { id: 'd', slug: 'd', name: 'osimertinib' },
174 + edge: { evidenceLevel: 'B', direction: 'sensitivity', supportCount: 3, sourceIds: [], provenanceIds: [], contextIds: [], contextNames: [] },
175 + approval: null,
176 + trials: null,
177 + };
178 + const a = { ...base, edge: { ...base.edge, evidenceLevel: 'A', supportCount: 1 } };
179 + const b = { ...base, edge: { ...base.edge, supportCount: 10 } };
180 + const c = { ...base, gene: { ...base.gene, symbol: 'KRAS', frequency: 0.5 } };
181 + const sorted = [base, c, b, a].sort(compareChains);
182 + expect(sorted.map((x) => `${x.edge.evidenceLevel}/${x.edge.supportCount}/${x.gene.symbol}`)).toEqual(['A/1/EGFR', 'B/10/EGFR', 'B/3/KRAS', 'B/3/EGFR']);
183 + });
184 +});
added docs/methodology/knowledge-graph.md +153 −0
@@ -0,0 +1,153 @@
1 +# Knowledge graph — cancer · gene · variant · drug · trial
2 +
3 +The knowledge graph at `/graph` (API: `GET /v1/graph/:type/:id`) shows the **contextual neighbourhood
4 +of one entity**: which cancers, genes, variants, drugs and trials a source connects to it, with the
5 +cancer context, direction, evidence level, claim category and provenance of every link
6 +(CLAUDE.md non-negotiable 7, SPEC §29, §244). It is a reading aid over data CancerIndex already
7 +holds — it never adds knowledge of its own.
8 +
9 +## Nodes
10 +
11 +| Type | Public id | Focus reference (`/graph?focus=`) | Entity page |
12 +|---|---|---|---|
13 +| cancer | `CI-CAN-…` | `cancer:<slug>` | `/cancer/<slug>` |
14 +| gene | `CI-GENE-…` | `gene:<HGNC symbol>` (aliases and `HGNC:n` resolve) | `/gene/<symbol>` |
15 +| variant | `CI-VAR-…` | `variant:<slug>` | `/variant/<slug>` |
16 +| drug | `CI-DRUG-…` | `drug:<slug>` | `/drug/<slug>` |
17 +| trial | `CI-TRIAL-…` | `trial:<NCT id>` | `/trial/<nct>` |
18 +| approval | `drug_approvals.id` (display only) | not focusable | `/drug/<slug>#approvals` |
19 +
20 +A bare NCT id, a bare CI id or an upper-case symbol is also accepted as `focus`. Approval nodes
21 +appear only for a **drug focus** when an approval record has no mapped cancer (the indication text
22 +is the label); approvals with a mapped cancer are drawn as `APPROVED_FOR` edges to that cancer.
23 +
24 +The **default focus** is the cancer with the most knowledge edges (source, target or context),
25 +computed at request time. The six "most connected" quick links (two cancers, two genes, two
26 +drugs) come from the same count. Nothing is hardcoded.
27 +
28 +## Edges
29 +
30 +Every edge — drawn or tabulated — carries:
31 +
32 +- `relationshipType` (source-native or derived, see below), `outgoing` (focus → neighbour or
33 + neighbour → focus), `direction` (supports / sensitivity / resistance / unknown / mixed);
34 +- `evidenceLevel` **as stated by the source** (CIViC A–E, ChEMBL max phase 1–4 or mechanism
35 + action type, openFDA application type, approval status). Scales are never converted into one
36 + another; they are only *ordered* for display (A, FDA ≻ B, phase 4 ≻ C, phase 3 ≻ D, phase 2 ≻ E,
37 + phase 1 ≻ others ≻ unknown);
38 +- `evidenceCategory` → claim badge (observed / published / curated / regulatory / guideline /
39 + computed), never merged;
40 +- `cancerContext` (`cancer_context_ids` resolved to names — the disease the source stated, which
41 + may be a descendant of the focus), `supportCount`, `sourceIds`, `provenanceIds`, `derived`.
42 +
43 +### Source-native edges (`knowledge_edges`, `derived: false`, solid spokes)
44 +
45 +Rows written by connectors, one per source record, with `status = 'active'`:
46 +
47 +| Relationship | Direction of the row | Source | Native level |
48 +|---|---|---|---|
49 +| `PREDICTS_RESPONSE_TO` | variant → drug (cancer in context) | CIViC | A–E |
50 +| `ASSOCIATED_WITH` | gene → cancer | CIViC | A–E |
51 +| `PROGNOSTIC_IN` / `DIAGNOSTIC_OF` / `PREDISPOSES_TO` | variant → cancer | CIViC | A–E |
52 +| `INVESTIGATED_FOR` | drug → cancer | ChEMBL (max phase), CIViC | 1–4 / A–E |
53 +| `TARGETS` | drug → gene | ChEMBL mechanisms | action type (e.g. INHIBITOR) |
54 +| `APPROVED_FOR` | drug → cancer | openFDA | application type (FDA ORIG) |
55 +
56 +For display the rows are **aggregated per (neighbour, relationship, direction, level, source)**:
57 +`supportCount` is the sum of the rows' `support_count`, `provenanceIds` the union of their
58 +provenance rows, `cancerContext` the union of their contexts, and the table says
59 +"*n* source records aggregated". Aggregation groups identical claims; it never combines
60 +different directions or levels into one.
61 +
62 +For a **cancer focus**, edges where the cancer is only the *context* (variant → drug **in this
63 +cancer**) are included too: the neighbour is the variant, the drug is shown as "→ predicts response
64 +to <drug>" (`via` in the API). These are the edges the "Paths" panel chains.
65 +
66 +### Derived registry links (`derived: true`, dashed spokes)
67 +
68 +Counts and measurements read from registry tables at query time. They state that two entities
69 +co-occur in a registry, **not** that a source asserted a biological or clinical relationship.
70 +
71 +| Relationship | Focus → neighbour | Computed from | Shown as |
72 +|---|---|---|---|
73 +| `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`) |
74 +| `INVESTIGATED_IN_TRIALS` | cancer ↔ drug | `trial_interventions.drug_id × trial_conditions.cancer_id` | trial count and active count per pair |
75 +| `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 |
76 +| `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 |
77 +| `HAS_VARIANT` | gene → variant | `variants.gene_id` | ordered by accepted CIViC evidence items (curated when > 0, otherwise the bare variant record) |
78 +| `HAS_EVIDENCE_IN` | variant → cancer | `civic_evidence_items` (`ACCEPTED`) | items, levels present, sensitivity / resistance counts, supports / does-not-support counts |
79 +| `PREDICTS_RESPONSE_TO` (derived) | variant → drug | `civic_evidence_items` predictive items **without** a knowledge edge yet | gap filler, flagged derived |
80 +| `CONDITION_OF` / `INTERVENTION_OF` | cancer → trial / drug → trial (trial focus) | `trial_conditions`, `trial_interventions` | registry text and `match_type` of the mapping |
81 +
82 +Registry counts have no `provenance` row of their own (they are not a published number); the
83 +edge names the registry source and the query definition above, and cohort frequencies and
84 +approvals carry the provenance id of the underlying row.
85 +
86 +Cancer foci roll up **descendants** (all hierarchy types, depth ≤ 12, capped at 600 ids) for
87 +trials, cohort frequencies and approvals, and the edge's `cancerContext` names the descendant that
88 +actually matched. Source-native edges are not rolled up: they must name the focus itself (or have it
89 +in context).
90 +
91 +## Caps and truncation
92 +
93 +- Per relationship type: 25 aggregated edges (10 for `STUDIED_IN`), ranked by native level, then
94 + support, then recency. `?more=<RELATIONSHIP>` raises **one** group to 200. Each group shows
95 + "*shown* of *total*"; the API returns `groups: { [relationshipType]: total }` and `truncated`.
96 +- Drawn nodes: at most **60**. Selection is a deterministic round-robin over entity types (each type
97 + contributes its highest-degree node in turn) so every type present stays visible; the figure
98 + caption states how many neighbours are not drawn. The table lists every fetched edge.
99 +- Spokes: at most 3 relationships per neighbour are drawn as parallel lines; all are in the table.
100 +- Paths: 8 chains (`?limit` ≤ 50 in the API).
101 +
102 +## Layout rules (`apps/web/src/lib/graph-model.ts`, unit-tested)
103 +
104 +- Focus at the centre. Neighbours sit on arcs grouped by entity type in the **fixed** clockwise
105 + order cancer → gene → variant → drug → trial → approval, starting at the top; sectors never
106 + reorder by size, so the same focus always draws the same picture.
107 +- Sector width ∝ node count with a minimum share; a fixed gap separates sectors; every sector uses
108 + one of three alternating ring radii so labels at sector borders do not collide.
109 +- Inside a sector nodes are ordered by degree (edges at the node), then label; a lone node sits at
110 + the sector's centre. Mark radius = 4 + 2.2·log₂(degree + 1), capped at 13.
111 +- Labels run along the spoke (rotated), flipped on the left half, truncated at 18 characters
112 + (full text in `<title>`). Entity type is encoded by sector position and caption, mark shape
113 + (circle / square / triangle / rounded square / hexagon / diamond) and a muted fill from the
114 + design tokens — the picture is readable without colour.
115 +- Solid spoke = source-native curated / regulatory / published claim; dashed = derived count or
116 + observed data. Every spoke has a `<title>`: relationship · direction · level · context · source.
117 +- Server-rendered SVG only (no client graph library); the SVG scrolls horizontally below 560 px
118 + and the edge table is the accessible equivalent.
119 +
120 +## Paths (cancer focus)
121 +
122 +`cancer → gene → variant → drug → approval → trials`, built only from what exists:
123 +
124 +1. variant → drug: a source-native `PREDICTS_RESPONSE_TO` edge with `direction = 'sensitivity'`
125 + whose context contains the cancer or a descendant (aggregated per variant–drug pair);
126 +2. gene: the variant's gene; its cohort frequency in the cancer is the **largest-denominator**
127 + cohort row with cases affected ≥ 20 — shown as "not yet available" when no cohort covers it;
128 +3. approval: the earliest `drug_approvals` row for the drug in the cancer (or tumour-agnostic),
129 + with authority, jurisdiction, status and date as published; `null` when none;
130 +4. trials: registry trials listing the drug with a condition mapped to the cancer (total / active).
131 +
132 +Ranking: native evidence level, then support count, then cohort frequency, then names (stable).
133 +Each hop keeps its own claim category. Chains are descriptive; they are not treatment guidance.
134 +
135 +## What the graph never does
136 +
137 +- **No inferred edges.** Two entities are connected only when a source states the relationship or a
138 + registry row literally joins them. No transitive closure, no similarity, no co-citation.
139 +- **No LLM edges.** Nothing in the graph is generated or ranked by a language model.
140 +- **No re-scaled evidence.** CIViC letters, ChEMBL phases and FDA application types stay in their
141 + native form; ordering for display is documented above and never shown as a score.
142 +- **No silent merging.** Different directions, levels or sources are separate rows; derived counts
143 + are never mixed with curated claims; claim badges are never collapsed.
144 +- **No fabricated hops.** A missing frequency, approval or trial count is shown as missing.
145 +
146 +## Implementation
147 +
148 +- Web: `apps/web/src/lib/graph-model.ts` (types, layout, ranking — pure), `apps/web/src/lib/queries/graph.ts`
149 + (SQL), `apps/web/src/components/graph/{radial-graph,path-chain,graph-link}.tsx`,
150 + `apps/web/src/app/graph/page.tsx`, home teaser `apps/web/src/components/home/graph-module.tsx`.
151 +- API: `apps/api/src/routes/graph.ts` — same SQL, duplicated on purpose (the web query module is
152 + `server-only`); keep thresholds and ordering in step.
153 +- Tests: `apps/web/test/graph-layout.test.ts`.
154