The load & analysis harness

The dependency graphs

The Open Brain builds a single merged, cross-repo, cross-stack dependency graph of every MOTMaster codebase and layers enrichment, layout, serialisation, an interactive WebGL viewer, and refactoring-safety tools on top. This is the structural half of the index — the part that answers "what calls this?" and "what breaks if I change it?" instead of "what looks similar?"

Nodes, edges, and where they come from

A node is a code symbol (class, method, SQL proc); an edge is a directed dependency source → target. Per-repo graphs are produced by the external Graphify CLI, merged into one global graph, and enriched in place. The wrapper GraphifyRunner.cs shells to graphify <repo> update (LLM-free) or extract when an LLM backend is configured (GraphifyRunner.cs:104-127), and never throws on a missing binary or non-zero exit — it falls back to the pre-shipped graph.json (:20-24,259-266). The defensive parser GraphFeatureIndex.ParseRepoGraph (GraphFeatureIndex.cs:1126) tolerates Graphify's unpinned schema by BFS-walking JSON for nodes/entities/vertices + edges/relationships/links and reading node fields by trying multiple key names (:1171-1187). MergedNode (:1501) carries identity, computed InDegree/OutDegree, and enrichment slots (Layer, Pattern, IsPublicEntry, IsOrphan, IsDeadEnd, X, Y); MergedEdge (:1542) carries SourceId, TargetId, Kind, Relation, Confidence, FromRepo, ToRepo, ResolvedBy, IsLayerSkip.

Why it matters

"What depends on X?" is answered in one query, not ten greps, and because the parser tolerates schema changes, a Graphify upgrade degrades gracefully instead of breaking the build.

Edge kinds — which fuse well versus poorly

Each edge has a coarse Kind (intra/cross_repo) plus a semantic Relation, with the vocabulary in EdgeKinds (Models.cs:349-388).

RelationSourceHowConfidence today
call / referenceGraphifynative edges parsed as intra (:1193-1219)forward-only
name_match (cross_repo)LoadAndMergedistinctive label in ≥2 repos (:945-999)heuristic, low precision
endpoint / sproc_call (cross_repo)CrossStackResolverroute/sproc join → AddCrossStackEdges (:152-202)HIGH (ResolvedBy="cross_stack")
covered_by (intra)derivedreverse test→code to code→test (AddCoverageEdges :325-366)good
implementsDiExtractorAddScoped<IFoo,Foo>() → interface→impl (AddDiEdges :377-446)extracted
reference (usage backfill)UsageEdgeExtractorconsumer→used-symbol caller edges (AddUsageEdges :457-527)extracted; fix for ~0% inbound
emitted_fromruntime fusionruntime-log→emitter (AddEmittedFromEdges :233-314)fuses 0 today
emitted_from is fully implemented but currently dormant: it is gated on the motlogs_log_facts corpus, which is empty wherever the journalctl tail never ran, so it materialises "0 runtime-fused files" (:242-247). motivates (git/ADR) is declared but not yet materialised. name_match is deliberately low-trust behind a distinctiveness filter (:1260-1292); the ResolvedBy field lets callers filter cross_stack (trust) from name_match (noise).
Why it matters

The deterministic extractors (endpoint, sproc_call, implements, covered_by) fuse well and can be trusted for refactors, while the honest labelling of low-precision and dormant edges means a caller always knows how much weight an edge deserves.

Cross-stack / cross-language edges

The headline differentiator over single-repo tools: a walk from a frontend button, through the API and controller, to the stored procedure. CrossStackResolver.RunAsync (CrossStack/CrossStackResolver.cs:82) scrolls every chunk once and does three things.

  1. Endpoint extraction — EndpointExtractor parses C# route definers via Roslyn: [HttpGet("...")] + class [Route] composition (EndpointExtractor.cs:104-185) and minimal-API MapGet/MapPost (:187-217). Callers are found by regex — C# HttpClient.GetAsync on .cs/.razor/.cshtml (:257-298, important because the MOTMaster UI is Blazor so callers are C#, not fetch) and TS/JS fetch()/axios (:322-398). Both are normalised to a canonical path so api/foo matches /api/foo (:38-56).
  2. Sproc extraction — SprocExtractor finds CREATE PROC pr_xxx in .sql (:115-143) and pr_\w+ references in C# string literals (:65-109).
  3. Join — matched by normalised route/sproc name into directed caller→definer edges (:275-329), written both as Qdrant payload neighbour lists (endpoint_neighbours, sproc_neighbours) and first-class graph edges via AddCrossStackEdges (:402-403). Each edge anchors on the file's highest-degree ("representative") node (GraphFeatureIndex.cs:156-169).

It is triggered by POST /api/admin/cross-stack/run (Program.cs:2021).

Why it matters

"Where does this UI button hit the database?" becomes one walk across four repos and three languages — the question single-repo indexers cannot answer at all.

Cross-repo resolution by label matching

Because Graphify graphs are per-repo, LoadAndMerge synthesises cross-repo edges. It builds a global symbol index over all repos (:923-943) and emits a cross_repo/name_match edge for a distinctive label with a foreign match (:955-997). Confidence scales inversely with fan-out: 1 repo = EXTRACTED, ≤3 = INFERRED, else AMBIGUOUS. IsDistinctiveLabel (:1274-1292) rejects common names (Configure, Run, ToString, length <6) and keeps schema-qualified / snake_case / multi-capital names — so pr_save_application_log links but Init does not.

Why it matters

The distinctiveness filter is what keeps cross-repo linking useful: it connects the genuinely shared symbols while refusing to draw noise edges between every ToString in the estate.

Graph coverage — 9/9 repos, and why some used to go blind

Graph generation (Graphify Pass 1, inside IngestionEngine.BuildGraphIndexAsync) only ever ran for a repo as a side-effect of that repo being ingested, and every real trigger was a single-repo post-deploy webhook. A repo nobody had redeployed in a while — motlogs/scout/ n8n went July/August-stale — never got re-graphified even though its source was still scanned fine, dropping coverage to 5 of 9 repos (/api/stats.lastSync.graphReposSkipped is where the symptom surfaced). GraphBackfillHostedService fixes this with a background sweep, default every Graph:BackfillIntervalMinutes (360, i.e. 6h) that finds repos whose durable graph.json — resolved via Graphify:PerRepoCacheDir (default graphify-cache/<repo>/graph.json, GraphifyRunner.cs:107-118) — is missing or older than Graph:MaxAgeHours (default 168, i.e. a week), and runs graphify for just that repo, one at a time, through the same run lock a full ingest takes so the two never race. It is Graph:BackfillEnabled (default true) and never calls Groq or embeddings — graph only. Landed and live in the 2026-09-12 execution run: graph coverage went 5/9 → 9/9 repos (docs/LEDGER_SCALE_PLAN.md §12).

Why it matters

A repo's graph no longer silently rots between full rebuilds just because nobody happened to redeploy it — every repo's dependency graph now self-heals on a bounded schedule instead of depending on deploy cadence.

Reachability — orphan and dead-end flagging

ReachabilityService.FlagOrphansAndDeadEnds (ReachabilityService.cs:70) runs in Phase 2, after the Layer/Pattern join and before layout. It normalises edges to scoped repo:local ids and builds forward adjacency (:86-114). Roots come from three unioned sources: structural (path contains /Controllers/, /Endpoints/, /Hubs/ or a Name ending Controller/Hub/Job/BackgroundService, :119-150); SQL procs with zero incoming edges (:152-165); and analytical roots by joined Layer/Pattern (:167-177). A forward BFS (:179-192) marks unvisited non-external nodes IsOrphan, and visited out-degree-0 nodes (excluding fields/properties and side-effecting patterns) IsDeadEnd. External references are never flagged.

Why it matters

It is an architectural-smell detector: it powers the Orphan and Dead-End lenses in /graph2 and feeds the dead-code agent, surfacing code that nothing reaches without a human reading every file.

Layout — ForceAtlas2 with semantic seeding

ForceAtlas2LayoutService.ComputeLayout (ForceAtlas2LayoutService.cs:52) uses a two-level scheme: an outer pass collapses each Leiden Cluster into a super-node (mass ∝ log member count) and runs FA2 on the small graph (:85-172); an inner pass lays out members per cluster in parallel, normalises to a unit disc, and offsets by the super-node (:174-254). The FA2 core (:265-339) rebuilds a quadtree each iteration (QuadNode :363-448) for O(n log n) Barnes-Hut repulsion, linear spring attraction, gravity and adaptive cooling, deterministic via a Layout:Seed golden-angle spiral (SeedCircle :457-474). The optional semantic seed (SemanticLayoutService.SeedAsync, SemanticLayoutService.cs:46) reads each file's mean code vector from Qdrant and projects it onto two random orthonormal directions (Johnson-Lindenstrauss via Gram-Schmidt, :131-148) for the seed X/Y, which FA2 then refines with useExistingAsSeed=true. It is off by default (Layout:UseSemanticSeed).

Why it matters

It makes a 60k-node graph legible, and in semantic mode places code that talks about similar things in the same basin — so proximity on screen means relatedness in meaning.

GraphBinaryWriter — the columnar wire format

GraphBinaryWriter.Write (GraphBinaryWriter.cs:95) emits under <OutputDir>/v2/: nodes.bin (a fixed header plus columnar arrays — FNV1a-32 id-hash, interleaved xy floats, u8 categorical indices for repo/kind/layer/pattern interned to dicts, u16 cluster, in/out degrees, a flags byte, :317-366); edges.bin (src/tgt indices, relation index, confidence, flags, :374-386); labels.bin (four length-prefixed UTF-8 arrays, :388-400); meta.json (dicts + bbox + tile manifest); and a tile pyramid per zoom level (grid 2^z × 2^z, tile/{z}_{x}_{y}.bin, :213-284). Raw little-endian is used instead of MessagePack so the browser can map Float32Array/Int32Array/Uint8Array views directly — saving about a 50KB decoder (:21-27) — and every file is Brotli-pre-compressed at write time (WriteBrotliFile :549-558).

Why it matters

The WebGL viewer opens tens of thousands of nodes instantly and streams only the tiles in view, so a graph with 60k nodes stays interactive on an ordinary laptop.

Caller recall — the forward-only problem and its fix

Graphify is forward-only (callee edges, no inbound usage), so node_neighbours(up) and blast_radius saw about 0% caller recall (UsageEdgeExtractor.cs:13-17). The fix, UsageEdgeExtractor.Extract (:35), scans real source on disk for identifier usages naming a distinctive graph symbol and emits reference caller edges via AddUsageEdges. C# is handled via Roslyn (IdentifierNameSyntax, excluding usings, :98-110); Razor/cshtml/JS via a comment/string-aware lexer (:118-147). Edges deliberately exclude comments and strings so the metric is not circular (:20-23). This lifts inbound recall from about 0% to about 40%.

Gap: .ts/.tsx files have no Graphify nodes, so TS callers are not yet sourceable (:25-26). The reconciliation tools below cover the residual deterministically.
Why it matters

A missed caller is a broken build. The backfill recovers about 40% of callers straight from the graph, and the reconciliation tools cover the rest — so a rename can be checked instead of guessed.

blast_radius and blast_radius_diff

BlastRadius (GraphFeatureIndex.cs:760) seeds from changed files or anchors, walks upstream (incoming = dependents) via BfsNeighbours, keeps the shallowest hop, and sorts by hop then descending InDegree (:803-838). The MCP wrapper CallBlastRadius (McpService.cs:3986) surfaces an empty-graph hint pointing at sync_ledger. The shared BfsNeighbours (:659) walks up/down/both with an edge-kind allow-list against Kind + Relation (:682-687), capped, with cached adjacency invalidated on any edge change. blast_radius_diff (CallBlastRadiusDiffAsync :3753) delegates to ReferenceReconciliationService.DiffAsync (ReferenceReconciliationService.cs:156), which diffs the graph caller set against a deterministic word-boundary textual sweep into in_both/graph_only/lexical_only and computes recall = |graph ∩ textual| / |textual|. The lexical_only set is the graph's recall gap — the callers to verify before a rename.

Why it matters

It turns "what will this PR break?" into a query and tells you how much to trust the answer — the graph gives the fast list, the textual diff gives the honest completeness number.

reference_set and caller_recall_audit

Both live in ReferenceReconciliationService.cs. ReferenceSetAsync (:147) returns every whole-identifier occurrence with line numbers, reading bytes off disk (not the truncated payload) so counts are trustworthy (:251-274), behind a 120s corpus cache. AuditAsync (:172) picks distinctive singly-defined symbols, diffs graph versus textual callers, and reports mean recall, the perfect-recall count, the zero-graph-caller count, per-repo means, and the 15 worst offenders (:194-218). It is refreshed off-thread by CallerRecallHostedService for the dashboard tile.

WP3.2 added two refinements. Anchor resolution is now repo-scoped (ResolveAnchorScoped, todo 3a2ce5c3): the underlying GraphFeatureIndex.ResolveAnchor searches every repo's nodes with no way to scope the search, so a symbol name existing in two repos used to silently pick whichever node had the higher degree, even when the caller asked about one specific repo. Now, when repo is supplied, candidates are filtered to it first; when it is omitted and the name is ambiguous across repos, no winner is guessed — the caller gets an ambiguity result (one candidate per repo) instead. When PreciseReferenceService is available (Precise:Enabled — live in production since 2026-09-13, see the three-lane routing section on Vectors & search for the full detail) and the caller supplied a concrete repo, reference_set and blast_radius_diff can answer with compiler truth (truth: "compiler") for C# symbols instead of only the graph/text sweep.

Why it matters

Together they close the refactoring loop: reference_set is the exhaustive checklist, blast_radius_diff is the per-symbol scope, and caller_recall_audit is the trend over time — so you always know whether the graph can be trusted for a given change — and repo-scoped anchoring plus an optional compiler-truth answer both raise how much that trust is worth.

reference_set, blast_radius, blast_radius_diff, refactor_plan, cross_stack_path, prior_decisions, find_similar and caller_recall_audit were previously hidden from the default MCP tool list. They are now part of CoreTools (McpService.cs:264-278) — the trimmed, usage-based default surface every caller sees unless Mcp:AdvertiseAllTools=true — so an agent no longer needs to already know these tools exist to be offered them.

The interactive /graph2 viewer

GraphViewer2Assets.cs serves inline HTML/JS (Sigma.js v3 + Graphology from CDN, with the binary decoder running in a Web Worker so the main thread never stalls, :1-12) at /graph2 + /graph2/app.js (Program.cs:976-986), and binary tiles + JSON through /graph/data/{*name} (:932) which returns the pre-Brotli'd .bin with Content-Encoding: br when advertised, else decompresses on the fly (a workaround for Chrome refusing Brotli over the insecure tailnet, :945-967). Lenses include Orphan, Dead-End, Hot-Path, Stack-Integrity/Layer-Skip, Cross-Repo, Cross-Stack, Public-Entry and Twin-Finder (similarity), grouped into Discovery / Architectural-smells / Connectivity / Similarity, with a tile-mode fallback for >20k nodes. The Ask box posts to /api/ask (:992). Every graph route is gated by IsGraphAuthorized (:3111): loopback is always allowed; a remote request requires a ?token= or Bearer matching Mcp:BearerToken, compared in constant time, fail-closed.

Why it matters

The whole graph is explorable by a human — with lenses for each kind of smell — and secured so a leaked URL cannot be brute-forced.

The drill-through explorer

drill-through.html is the cross-stack tracer built on the same graph: start from a screen, controller, stored proc or table (or paste a file path) and walk Screen → API → code → SQL → Table either as lane-grouped columns or as a force-directed network that grows one hop per tap. It renders the same accumulated graph as the viewer, but is built for following a single request across repos.

  • Depth is a radius around the focused (last-tapped) node — a BFS hop-count over the current edges — and defaults to 5. Tapping recentres the neighbourhood; anything further than depth hops auto-hides. The force layout is tuned (stronger damping, faster cooling, a single settle-fit) to settle quickly and hold still rather than drift.
  • Source on tap. Double-tapping a node opens its detail panel, which fetches the real body via /api/graph/node-detail — for UI, API and code nodes and, as of #73, for SQL procedure and table nodes too (the schema .sql body), with the node's trace relations listed beneath.
  • Ask, scoped. The Ask box (/api/ask) answers about the focused node's real structural neighbourhood, or the whole system when nothing is focused. Tapping a route reveals its child routes as a navigable tree, and "App routes" opens each UI on its home page.
Finding a node to start from is semantic; walking its neighbourhood is structural — the two halves of Two stores, two questions.

Pass orchestration order

The passes run in a fixed order in IngestionEngine (:1614-1670): LoadAndMerge → ChunkAnalysisJoin (Layer/Pattern) → Reachability → LayerSkipTagger → UsageEdgeExtractor (caller backfill) → optional SemanticLayout.SeedAsync → ForceAtlas2.ComputeLayout → GraphBinaryWriter.Write. Every pass is try/catch and non-fatal. The cross-stack, covered_by, implements and emitted_from edges are materialised separately by CrossStackResolver in the whole-corpus post-passes.

Key flags: emitted_from is fully implemented but dormant (0 edges where log_facts is empty); motivates is declared but not materialised; inbound recall is about 0% from Graphify alone, lifted to about 40% by UsageEdgeExtractor; TS/TSX callers remain unreachable, and the reconciliation tools cover the residual deterministically.
Why it matters

Order is load-bearing — each pass consumes what the previous produced (reachability needs the layer join, layout needs everything else) — and non-fatal passes mean one failing enrichment never takes down the whole graph build.

Open Brain — the MOTMaster Architectural Ledger · generated from source 2026-07-07