KG Anchor Scoping — Patient-Centric KG Filters (Planning Doc)
Status: PLANNING (not yet started)
Created: 2026-05-09
Owner: whafa
KG composition pipeline ref: an internal agent path (Phase F follow-on)
Companion: pdr_phase8.5_variant_kg_planning.md (variant entry-point — F depends on D1-D3 ingest for full anchor coverage)
This document plans a feature that lets us slice the canonical KG by a patient’s mechanistic neighborhood — for visualization, inference candidate filtering, explainability, ablation, and per-patient fine-tune. It does NOT scope the production training graph; that stays full-coverage to preserve discovery power. The framing here was sharpened by a question from whafa on 2026-05-09: “we can’t base the KG solely on her data, right? The whole point is to find alternative treatments…” which is exactly right and rules out the naive design.
1. The discovery vs. exploitation tension (the framing)
Drug repurposing is fundamentally a discovery problem. The whole point is to find drugs that nobody has thought to try for Maureen yet — drugs that may have no direct connection to anything in her current medical record. If the model has never seen Drug X during training, it cannot rank Drug X. So:
The training graph must include every candidate drug we want to be able to predict.
This rules out the naive “scope the composition to entities reachable from her record within N hops” approach as a definition of the production training set. That would prune most of the candidate space and turn discovery into “predict edges between things she’s already touched” — which is the opposite of what we want.
But patient-centric subgraphs are still enormously useful for several other purposes — they just operate at different stages of the pipeline:
| Use case | Stage | What it needs |
|---|---|---|
| Inspection / visualization | Anytime | Show her mechanistic neighborhood; explore the graph around her case |
| Inference candidate filtering | Post-prediction | ”Of all 12K drugs the model ranked, show me the top hits that share mechanism with what we know about her.” |
| Explainability / path tracing | Post-prediction | ”WHY did the model rank Drug X high?” → trace the path from her anchors through Drug X’s neighborhood |
| Per-patient fine-tune (PDR2 style) | Training (specific) | Foundation model trained on full graph; fine-tune on a patient subgraph for personalization |
| Ablation studies | Research | Train at varying hop counts to measure information locality — research, not production |
| Tractability fallback | When forced | When an experiment really needs a smaller graph (memory/latency budget) |
This planning doc designs the anchor-scoping mechanism that supports all of these use cases without compromising the production training composition.
2. What this is NOT
- NOT a way to define the production training set. That stays on the full Tier 1 MVP composition (
tier1_mvp_v1= RTX-KG2 + CIViC + OncoKB + PharmGKB). - NOT a replacement for full-graph training. Discovery requires breadth.
- NOT patient-only. Anchor sets are reusable named objects; “Maureen’s record” is one anchor set, but we’ll define others (e.g., “BRCA-positive cancers”, “PARP inhibitor pathway”, a manually curated CURIE list).
3. What this IS
A new orthogonal scoping primitive that sits ALONGSIDE compositions and can be applied at five different stages:
┌─────────────────────────────────┐
│ mods_kg_composition │ ← unchanged
│ (sources × domains × predicates)│ defines training graph
└─────────────────────────────────┘
⊥
AND
⊤
┌─────────────────────────────────┐
│ mods_kg_anchor_set │ ← NEW
│ (patient OR manual curies) │ defines a slice
└─────────────────────────────────┘
│
materialize
↓
┌─────────────────────────────────┐
│ N-hop reachable entity set │
└─────────────────────────────────┘
│
▼
┌────────┬────────┬────────┬────────┬────────┐
view inference explain fine- ablation
filter path tune
The composition defines WHAT graph data exists. The anchor set defines a SLICE through it. Different consumers apply the slice at different stages.
4. Closure criteria
- Schema supports named, reusable anchor sets — both patient-derived and manually-curated (CURIE list).
- Anchor resolver can take a patient_id and produce the canonical-CURIE set of all entities mentioned in their medical record (variants, drugs, diseases, biomarkers, gene expressions).
- BFS service can materialize the N-hop reachable set in Postgres, with hub guards, in seconds for typical anchor sets and N ≤ 3.
- Inspection API + dashboard lets a human see “Maureen’s 2-hop subgraph: 4,103 entities by domain breakdown, 47 drugs not yet tried.”
- Inference filter can post-filter a model’s ranked predictions by anchor reachability.
- Reproducibility — locked compositions that reference an anchor set get the resolved CURIE list snapshotted (
anchor_set_hash) so re-materialization is deterministic. - Ablation tooling — script that varies max_hops {1, 2, 3, 4} for the same anchor and reports the entity / relation / drug-candidate counts per ring.
5. Sub-phases
F.1 — Anchor resolver (1 day) — foundation
- New service
backend/mods_kg_anchor_service.py -
resolve_patient_anchors(patient_id) -> List[str]— extracts canonical CURIEs from:mods_genomic_alteration(variants — uses ClinGen client when available)mods_treatment_drug(drugs)mods_cancer_profile(diseases)mods_biomarker_value(biomarkers — names → CURIEs via NodeNorm)mods_gene_expression(genes from RGCC/Caris if available)mods_therapy_association(drug-biomarker links)- Future:
mods_genotypePRS-relevant variants
- Goes through the same
EntityResolver.upstreamchain (ClinGen → NodeNorm → synthetic) so resolution rules stay consistent. - Returns both
resolved_curies(canonical) andunresolved(free text we couldn’t anchor) — for transparency.
F.2 — Anchor set storage (0.5 day)
- New table
mods_kg_anchor_set:CREATE TABLE mods_kg_anchor_set ( id TEXT PRIMARY KEY, -- 'maureen_record_v1', 'brca_positive_v1' display_name TEXT NOT NULL, description TEXT, strategy TEXT NOT NULL, -- 'patient_record' | 'manual_curies' patient_id UUID REFERENCES mods_patient(id) NULL, manual_curies JSONB NULL, -- [{curie:'HGNC:1097', source:'manual'}] resolved_curies JSONB NULL, -- snapshotted at creation/lock; for reproducibility resolved_at TIMESTAMPTZ, notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); - Optional column on
mods_kg_composition:anchor_set_id(nullable FK) — for compositions that bake in an anchor scope (used by per-patient fine-tune). Most compositions leave this NULL. -
mods_kg_composition.anchor_max_hops INT NULL— hop limit when an anchor is attached. - Migration in
backend/migrations.py(next available number after deploy).
F.3 — Reachability service (1-2 days) — the engine
- New module
backend/mods_kg_reachability_service.py -
compute_reachable(anchor_set_id, max_hops, max_degree) -> {entity_ids, by_hop_ring} - Postgres recursive CTE walks
mods_kg_relationfrom anchor entity_ids outward. - Hub guard: skip nodes whose total degree exceeds
max_degree(default 2000). Without this, BFS through “HOMO_SAPIENS” or “Cancer” engulfs the entire KG. - Optionally predicate-typed BFS (only follow certain relation types). Future enhancement; not in F.
- Cache hot results in
mods_kg_reachable_cache(anchor_set_id, max_hops) → entity_ids JSONB. Invalidate on anchor resolution change. - Stats: per-hop entity count, per-domain breakdown.
F.4 — Inspection API + dashboard (1 day) — make it visible
- REST endpoints under
/api/mods/kg/anchors:POST /anchors— create an anchor setGET /anchors— listGET /anchors/{id}— show resolved curies + unresolvedGET /anchors/{id}/reachable?max_hops=N— entity counts by hop ring + domainGET /anchors/{id}/reachable/entities?max_hops=N&domain=drug— sample entities
- New dashboard sub-tab on KG Composition: “Patient anchors”. Shows:
- Anchor sets (Maureen + any others created)
- Per anchor: resolved CURIE count, unresolved count, “view neighborhood” button
- Hop-ring chart (entity count vs. hop) — the locality curve
- Per-hop domain breakdown
- “View in Explorer” → loads the subgraph into the existing KG Explorer
- MCP tools:
mods_kg_anchor_summary,mods_kg_anchor_reachable_count
F.5 — Inference candidate filtering (1 day) — the actual product use case
- When ranking model predictions, accept an optional
anchor_set_id+max_hopsfilter. - After model produces top-K candidates, filter to those reachable from anchor within max_hops.
- Returns BOTH the unfiltered top-K and the filtered top-K so user can compare.
- UI: in the Research Dashboard’s prediction view, add an “anchor scope” toggle:
[ ] Show all ranked drugs (default) [x] Filter to drugs reachable from Maureen's network within [2 ▾] hops
F.6 — Explainability / path tracing (2 days) — the trust use case
- For a (patient, predicted_drug) pair, find shortest paths from any anchor entity to the drug.
- Reuse
mods_kg_service.find_pathslogic (already implemented for KG Pathways feature) with anchor-set-as-source. - Returns top-3 paths ranked by length / weight / corroboration.
- UI: prediction row → “Why?” → opens path explorer.
F.7 — Per-patient fine-tune support (2 days) — PDR2 personalization
- Allow a composition to be defined as
<base_composition> ∩ <anchor_reachable>via the existing composition_id FK + the new anchor_set_id column. - When materialized for training export, the export script applies the anchor filter as the final step.
- Naming:
tier1_mvp_v1_anchored_maureen_2hop(auto-generated names get unwieldy; UI generates human-friendly displays). - Foundation model trained on full
tier1_mvp_v1(broad embeddings); per-patient run fine-tunes on the anchored slice.
F.8 — Ablation tooling (1 day) — the research lever
- Script
backend/scripts/anchor_ablation.py:- Takes anchor_set_id + max range (e.g., 1..4)
- For each N: materialize, count entities + relations + drug candidates + per-domain breakdown
- Output: a table that lets you eyeball “where does diminishing returns kick in?”
- Example output:
maureen_1hop: 47 entities, 89 relations, 8 drug candidates maureen_2hop: 4,103 entities, 32,841 relations, 412 drug candidates maureen_3hop: 287,492 entities, 4.1M relations, 8,940 drug candidates maureen_4hop: 4.8M entities, 89M relations, 11,800 drug candidates ← plateauing
F.9 — Documentation + writeup (0.5 day)
- Update
docs/KG_CATALOG.mdwith the anchor scoping section. - Writeup following
pdr_phase0_gpkg_writeup.mdtemplate.
6. Critical design decisions to lock before F.1 starts
-
Anchor resolution timing. Snapshot CURIEs at anchor creation, OR re-resolve on every materialization?
- Recommendation: Snapshot at lock time (analog of how compositions lock). Provide an explicit “re-resolve” action that updates
resolved_curiesand bumps the resolve timestamp. Locked downstream compositions can then opt to use the snapshot OR follow the latest, but default is the snapshot for reproducibility.
- Recommendation: Snapshot at lock time (analog of how compositions lock). Provide an explicit “re-resolve” action that updates
-
Hub guard threshold. What’s the default
max_degree? Too low and we miss real mechanism; too high and BFS explodes.- Recommendation: Default 2000 (matches the existing
find_pathsguard inmods_kg_service.py). Configurable per anchor set.
- Recommendation: Default 2000 (matches the existing
-
Predicate-typed BFS. Should we let users say “follow only
biolink:treatsandbiolink:has_target”?- Recommendation: Defer to F+1. Default is “all relations.” When we add predicate-typed BFS, it makes 3-hop subgraphs much smaller and more interpretable.
-
Multiple anchor sets per composition? Could we want “intersection of (Maureen 2-hop) AND (PARP-pathway 2-hop)”?
- Recommendation: Defer. F supports one anchor per composition. Boolean combinations are an obvious follow-on if needed.
-
How does this interact with the variant-overlay connectors (D1-D3)?
- Anchor resolver produces high-quality CURIEs only when her variants are graph-native. Until D1-D3 are ingested, BRAF V600E etc. resolve to synthetic CURIEs and the 1-hop ring around them is tiny. F is fully usable only after D1-D3 land. Anchor coverage report should explicitly show ”% of medical-record entities resolved to non-synthetic CURIEs” so this gap is visible.
-
How does this interact with Phase 12 (patient-as-node)?
- When the patient becomes a graph node with
has_variant/received_treatment/diagnosed_withedges, the anchor resolver gets simpler — it just returns the patient’s direct neighbors. The schema in F.2 supports both pre-Phase-12 (CURIE list) and post-Phase-12 (patient_id traversal) cleanly. No re-architecture needed when Phase 12 lands.
- When the patient becomes a graph node with
7. Open questions
-
Anchor sets across patients. “BRCA-positive metastatic CRC” is a useful research anchor that doesn’t correspond to one patient. Worth supporting via
manual_curies. Done in F.2; just confirming. -
Ablation outside Maureen. Once we have one or two more patients, the ablation tooling should be patient-agnostic. F.8 designs for that.
-
Inference filtering UX — soft vs. hard filter. Soft = “show all but highlight in-scope ones.” Hard = “exclude out-of-scope from the list.” Soft preserves discovery; hard sharpens focus. Maybe both as toggles?
-
Caching invalidation. The reachable cache becomes stale when the underlying KG changes (new connector ingest, etc.). Invalidate on every ingest, OR timestamp-based, OR explicit “re-materialize” button?
- Probably: invalidate by source, with a “last-updated-at” check. New ingest of CIViC invalidates only those caches whose anchor set has CIViC-derived CURIEs.
-
Drug candidate definition. When the inference filter says “show drugs in scope,” do we mean:
- Any entity with
domain='drug'reachable from anchor within N hops? - OR: any entity with
domain='drug'AND atreats/predicts_sensitivity_toedge to her diseases / variants? - The second is much narrower and more useful for ranking. Probably want both as alternatives.
- Any entity with
8. Cost estimate
| Sub-phase | Effort |
|---|---|
| F.1 anchor resolver | 1 day |
| F.2 schema | 0.5 day |
| F.3 reachability service | 1-2 days |
| F.4 inspection API + dashboard | 1 day |
| F.5 inference filter | 1 day |
| F.6 explainability paths | 2 days |
| F.7 fine-tune support | 2 days |
| F.8 ablation tooling | 1 day |
| F.9 docs | 0.5 day |
| Total | ~10 days = ~2 weeks |
Phases stack — F.1-F.4 deliver real visualizable value alone (Tier 1 deliverable: “see Maureen’s neighborhood in the dashboard”). F.5 unlocks the actual personalization filter at inference. F.6-F.8 are research enhancements.
9. Suggested next steps
- Wait for legacy backfill + at least one variant connector to land. F is more useful when her variants resolve to canonical CURIEs.
- Decide on the bulk-insert speedup for the EntityResolver first. Current per-row insertion makes anchor resolution slow when running over many medical record rows. The same optimization helps both.
- Validate F.1 anchor resolver against Maureen’s actual records before committing to F.2 schema. Spike: just hand-resolve 10-20 of her variants/drugs/diseases through
EntityResolver, count how many get canonical vs. synthetic CURIEs. If <50% canonical, the F.5 inference filter will be too leaky and we should land more variant connectors first.
10. Cross-references
an internal agent path— the parent KG composition pipeline plan (Phases A-E)- pdr_phase8.5_variant_kg_planning.md — variant KG extension; F depends on D1-D3 connector ingest
- temporal_patient_modeling_planning.md — Phase 13; trajectory modeling per patient. Anchor scoping helps define the temporal window.
backend/mods_kg_composition_service.py— composition layer F extendsbackend/kg_normalize/entity_resolver.py— resolver chain F.1 reusesbackend/mods_kg_service.py—find_pathslogic F.6 reuses