KG Orphan Detection and Repair (orchkg)

Runbook for the session-254 invariant: every node in the operational KG must be reachable from a root (:Project) node (undirected reachability; enforced by scripts/check_kg_connectivity.py). Written after the 2026-10-13 orphan audit that found 28,889 floating modules and repaired the graph to 0 unreachable of 1,088,711 nodes.

TL;DR

# Authoritative census (native in-graph BFS since 2026-10-13; ~47s on 1M nodes)
ORCHESTRATA_ENV_PROFILE=test python scripts/check_kg_connectivity.py

# Canonical-schema conformance (constraints + keyless-node scan)
ORCHESTRATA_ENV_PROFILE=test python scripts/kg_schema.py check

Both must pass. If the census reports unreachable nodes, work through Diagnosis and Repair recipes below.

What an "orphan" actually is here

An orphan is not a node without edges. The 2026-10-13 incident proved the dangerous shape: subtrees that are fully edged internally (Package→Module, DEFINES, IMPORTS, HAS_PARAMETER all present) but have no path to any (:Project) root, because the single top edge — Project-[:CONTAINS]->Package — was never written. Reachability is the criterion, not degree.

Root causes found (2026-10-13) — four distinct loader gaps

  1. GraphSyncer.incremental_sync never created the Project root. incremental_sync calls _sync_ast(..., sync_project_node=False) (deliberate — totals must describe the whole project, not a subset), but _sync_contains_edges hangs depth-0 packages off MATCH (a:Project {id}). A MATCH on a non-existent node silently writes zero edges — no error, no log. Any project whose first load was incremental (every wt-* worktree sync via scripts/ast_live_sync.py) floated. Fixed (commit 7a29723dd): _sync_ast now always runs MERGE (p:Project {slug}) ON CREATE SET p.id, p.name before the edge phase; existing Project totals are never touched by incremental syncs. Regression: tests/knowledge_graph/test_sync_incremental_project_root.py.

  2. sync_architecture_to_graph.py upserted Patterns no Component referenced. Component-[:APPLIES]->Pattern binds via each component's protocols list; "Stale PID cleanup", "Three-tier gRPC logging" and "Exponential backoff" were in PATTERNS but in no list → zero-degree islands. Fixed (commit 872213961): Gateway applies the first two, Kernel applies Exponential backoff.

  3. Tier-J ingest without the root/link seams (dr-hannah). Architecture, ArchitectureDecision, Artifact nodes for project dr-hannah were written without a Project{dr-hannah} node and without HAS_ARCHITECTURE/DECIDES/DOCUMENTS edges. ArchitectureWriter. upsert_architecture does MERGE the owning Project — the ingest path that wrote these nodes never called it. Repaired data-side; writer-side guard is follow-up work.

  4. Test pollution without test_id. tests/scripts/test_ast_live_sync.py left _live_<hex>-prefixed Package/Parameter nodes carrying no test_id property, so the session sweep (tests/knowledge_graph/conftest.py::_graph_session_sweep, which deletes n.test_id IN $ids) could never reclaim them. Deleted manually (7 nodes). Lesson: any test that writes graph nodes must stamp test_id — RUL-025's exemption only covers nodes that carry it.

Diagnosis

Fast targeted scans (seconds)

// Orphan code subgraphs: Modules unreachable from any Project
MATCH (m:Module) WHERE NOT (:Project)-[:CONTAINS*0..]->(m)
RETURN split(m.id, ':')[0] AS prefix, count(*) ORDER BY count(*) DESC;

// True islands: zero-degree nodes by label
MATCH (n) WHERE NOT (n)--() RETURN labels(n)[0] AS label, count(*);

// Code nodes detached from their parent tier
MATCH (c:Class)     WHERE NOT (:Module)-[:DEFINES]->(c)     RETURN count(c);
MATCH (m:Method)    WHERE NOT (:Class)-[:DEFINES]->(m)      RETURN count(m);
MATCH (p:Parameter) WHERE NOT ()-[:HAS_PARAMETER]->(p)      RETURN count(p);

// Runtime hubs (direction matters: Intent-[:TRIGGERED]->PipelineRun,
// GatewayAuditEvent-[:DURING]->Session — outbound, not inbound)
MATCH (s:Session) WHERE NOT (:Project)-[:HAS_SESSION]->(s) RETURN count(s);

Authoritative census

scripts/check_kg_connectivity.py — BFS from all (:Project) seeds over undirected edges, exit 1 if anything is unreachable. Since 2026-10-13 (commit 196ce76a7) it is dialect-aware: on Memgraph a single native [*BFS..32] expansion collects the reachable id set engine-side and one anti-join aggregates unreachable counts per label (~47s total; the 1.09M-row reachable set never crosses the wire). On Neo4j — no *BFS modifier, and a bounded [*..N] would path-enumerate exponentially — the original Python-side batched BFS remains as fallback. Do not reintroduce Python-side frontier BFS on Memgraph: its MATCH (n)--(m) WHERE id(n) IN $ids batches use no index; two runs exceeded 30 minutes without finishing.

Repair recipes

All repairs are MERGE-based and idempotent. Run against orchkg with GraphSyncer(no_auth=True) (see .cache/kg_audit*.py in the repo for the importlib harness pattern).

Re-root a floating code subgraph

MERGE (p:Project {slug: $prefix}) ON CREATE SET p.id = $prefix, p.name = $prefix;
MATCH (p:Project {slug: $prefix})
MATCH (pkg:Package) WHERE pkg.id STARTS WITH $prefix + ':'
  AND NOT ()-[:CONTAINS]->(pkg)
MERGE (p)-[:CONTAINS]->(pkg);

Wire an unreferenced Pattern

Add the pattern name to the right component's protocols list in scripts/sync_architecture_to_graph.py and re-run it (idempotent). Never hand-write APPLIES edges for data the sync script owns — fix the source so the next run keeps it.

Attach Tier-J strays

Mirror the canonical shape (measured on orchestrata-ultimate): Project-[:HAS_ARCHITECTURE]->Architecture, ArchitectureDecision-[:DECIDES]->Architecture, Artifact-[:DOCUMENTS]->Architecture.

Delete untagged test pollution

MATCH (n) WHERE n.id STARTS WITH '_live_' DETACH DELETE n;

Only safe for prefixes that are provably dead test namespaces — check n.test_id absence and the producing test first.

Prevention checklist (for any new loader/writer)

Follow-ups still open

References