labgo · stages 1–3b · file map
Local extraction, cloud storage: an AST walker and a git-history miner turn a repo into a call graph and a scored exam with no database and no LLM (Stage 1) — then a deterministic Cypher baseline over Neo4j (Stage 2) and a Voyage-embedded vector index (Stage 3a) each get measured on their own before anything is blended, plus a small React viewer that turns the graph into something you can click through. Below: every file, what it's for, and how data actually moves between them.
k
one (D015's full sweep reaches 60.7% recall at k=20, but only by spending
precision down to 7% — predicting a much larger slice of the corpus). At matched
budget, vectors alone (19.3% recall) don't beat call-graph alone (22.4%) — D001's claim
that vector search can't answer transitive impact holds under measurement, not just
assertion. The hybrid union of both reaches 40.1% recall and 14.8% precision —
ahead of either signal alone on both axes, meaning the two retrieval modes
catch different true positives more often than they overlap. hybrid+cc
(highlighted) adds co-change, mined leave-one-out at a matched min_count=25
(D012, D010) — real additional lift (43.3% / 15.4%), not leakage, though a small one
that shrinks as k grows (D015 follow-up). The union stays a plain set
union throughout — no weighting or ranking; deliberately deferred until a measurement
shows it's the bottleneck, not assumed.
hops/k/min_count). The Stage 4 agent
has no such knob: its system prompt describes the tools' tradeoffs but never asks
for a bounded final answer, so it predicts 13.05 files/case — 57% larger than
the deterministic budget — and its precision (5.8%) falls below even the
call-graph floor's own (12.2%), despite reasonable recall (30.3%, between the
floor and the tuned hybrid). D016's diagnosis: this is the same category of mistake
D012 and D015 each caught once — a plausible number produced by predicting too much
— not evidence that the agent's reasoning about individual files is worse than the
deterministic signals it has direct access to. Sampled 40/236 cases (Haiku 4.5,
disclosed, reproducible via --sample-seed 42); the matched-budget
version of this comparison (ask the agent for its best N files, ranked) is
named as the next fix in D016, not yet built.
min_count tightens, plateauing at min_count≈24 — httpx has
no co-change pairs left above that count, so pushing further changes nothing. The
default (min_count=2, far left) predicts most of the corpus and is not a
fair number to cite (D012). At min_count=25 (dashed line), mean predicted
size (5.1 files) is matched to the call-graph baseline's own budget (4.8 files) — and
at that matched budget the combined signal beats the call-graph-only floor (dashed
references) on both recall and precision. Sweeping evidence_max_files
alongside min_count (table in D010) showed it barely matters once
min_count is tuned — the original hypothesis named the wrong parameter.
voyage-code-3, LangGraph, Claude), and the current httpx
numbers for both the call-graph baseline and the embedding index.typer, rich, neo4j); everything else is
gated behind optional extras so Stage 1/2 stay fast to set up: vectors
(voyageai — alone drags in langchain-core, tokenizers, huggingface-hub,
numpy, and pillow transitively, D014, far more than "one embeddings call" implies),
agents (langgraph / anthropic, Stage 4),
mcp (Stage 5), observability (opentelemetry-sdk,
Stage 6 — genuinely optional, D017: agent.py runs the same without it,
just untraced). Also pins [tool.uv.workspace] exclude so a cloned
analysis corpus never gets mistaken for a workspace member (D007). Ruff runs in
strict mode; the ignore list is annotated per rule rather than blanket-disabled —
e.g. FBT001/FBT003 are excused because
--open/--no-open boolean flags are the Typer API, and
PLC0415 is excused only in cli.py, only for the commands
that lazily import an optional extra so every other command still runs without it.NEO4J_URI (AuraDB Free's neo4j+s://
connection string, D013 — a commented-out local-Docker fallback is included too),
NEO4J_PASSWORD, VOYAGE_API_KEY, and
ANTHROPIC_API_KEY (Stage 4+ — the LangGraph agent and, via --api-key
passthrough, nothing else needs it). .env itself is gitignored;
cli.py loads it via python-dotenv on startup.ruff check + pytest on every
push/PR to main, all extras installed so lint/tests cover every stage,
not just Stage 1/2. Deliberately does not run labgo baseline or
agent-eval against live services — confirmed first that all 46 tests
need zero live credentials (grepped the suite for driver/client construction before
wiring this up), so a free, fast lint+test gate is the honest scope for CI, not a
simulated full eval run that would need secrets and, for the agent, real per-token
cost on every push (D017)..env and labgo load --clear
works identically against it..venv, __pycache__, .pytest_cache, .env,
data/). Nothing project-specific here.src/labgo/ingest (repo → code graph), history (repo → raw co-change +
eval set), benchmark (repo → pinned, filtered exam), verify
(does a repo still match a pinned benchmark?). Stage 2, Neo4j: load
(graph.json → Neo4j, --clear to wipe first) and baseline
(score an impact prediction against a pinned benchmark — --method calls,
the default, is the unchanged Stage 2 path; vectors/hybrid
route to hybrid.py, Stage 3b, D015). Stage 3a, +Voyage: embed
and search — both import labgo.embed lazily inside the
command body (PLC0415 ignored, deliberately) so the other commands
keep working without the vectors extra installed;
labgo.hybrid has no such dependency, so it's imported normally at the top.
Stage 4, +Anthropic/LangGraph: agent (ask one impact question, interactive)
and agent-eval (score the agent against a sample of a pinned
benchmark — the full 236 cases through a multi-turn LLM loop is real money and time,
so the sample size is a disclosed, deterministic choice, not a shortcut hidden from
the number). Stage 5, +mcp: mcp serves the same five tools over the
Model Context Protocol (stdio) instead of driving them itself — opposite direction
of control from agent. All three lazily import their extras
(labgo.agent, labgo.mcp_server), same reasoning as
embed/search. view is the odd one out — no repo
argument, only --data: a ThreadingHTTPServer serves the
prebuilt viewer/dist/ bundle, with _view_handler()
intercepting /graph.json and /cochange.json to route them to
whatever's on disk right now, so the committed bundle never serves a graph baked in at
someone else's build time.ast module walks every file and builds a
call/import graph in two passes: collect every definition, then resolve every call site
against it. Each CALLS edge is tagged with a confidence tier
(exact / self / local / heuristic)
rather than pretending Python's dynamic dispatch is fully resolvable — 27.2% of in-scope
calls resolve on httpx, and the module is explicit about why the rest can't be, short of
running the program.Node /
Edge dataclasses, the NodeKind / EdgeKind /
Confidence enums, and ExtractionStats — which deliberately
separates "external" calls (builtins/stdlib/third-party) from the resolution-rate
denominator, since counting them as unresolved understated the real number by 7 points
(D005). Plain dataclasses on purpose, so the extractor needs no database to run or test.git log --no-merges --name-only and turns
the output into two things: co-change pairs (files that changed together ≥2 times) and
an EvalCase per commit — one seed file, the rest as the "expected" answer.
Excludes merges and oversized commits, because pairs grow as N² and five commits out of
620 otherwise supply 30% of all coupling evidence (D010). The parser also survived two
subprocess/encoding bugs recorded in D006 — one of which silently returned zero commits.
leave_one_out_neighbors() (D012) subtracts one commit's own contribution
from the raw pairs before applying min_count — the fix for scoring a case
against evidence that includes the very commit being predicted.benchmarks/<name>/. filter_answerable()
drops any case referencing a file no longer in the tree — on httpx that was 58% of raw
cases (D008) — and verify_corpus() refuses to score a benchmark against a
repo that has since moved past that commit. write_benchmark() also pins the
raw, unfiltered co-change pairs to evidence.json (D012) —
load_evidence() reads them back so baseline.py can subtract a
case's own commit at scoring time, which needs every count, not just the ones that
already cleared min_count.connect() reads
NEO4J_* from explicit args or env, and fails loudly — a
RuntimeError, not a silent connection to nothing — if no password is set.
load_graph() MERGEs nodes and edges into Neo4j in batches,
idempotent so a re-ingest can always be re-loaded; every node keeps a generic
:Node label alongside its specific kind so one uniqueness constraint (and
later, one vector index) covers all three kinds at once. CO_CHANGED is
loaded directed but meant to be queried undirected — git history carries no inherent
direction between two files that changed together.predict_impact_calls() is pure Cypher — functions the
seed file contains, callers reachable within hops traversals of
CALLS — structural, so it carries no leakage risk. Measured on httpx:
22.4% recall, 28.8% hit rate, the honest floor. predict_impact()
adds Neo4j's global CO_CHANGED edges on top — fine for a live,
one-off query, but scoring a benchmark against it leaks: every eval case's own commit
is inside its own co-change evidence window (D012). predict_impact_loo() is
the fix used for scoring — same call-graph query, but co-change comes from
gitlog.leave_one_out_neighbors() with the scored case's own commit
subtracted first. Leak-free result: 92.1% recall, 5.9% precision — barely below
the leaky 94.9%, because checking predicted-set size found the real problem: it predicts
40.5 of ~60 files per case, the same "returns most of the corpus" failure D015
caught for an early vector measurement. score_baseline() now requires
pairs= (from benchmark.load_evidence()) whenever
use_cochange=True, and raises rather than silently scoring leaky.
D010's follow-up sweep found the actual fix: at --min-count 25 (a
matched ~5-file budget — the curve plateaus there, since httpx has no pairs left
above count ~24), the combined signal reaches 26.9% recall, 15.7% precision —
beats the floor on both axes, and evidence_max_files turned out to
barely matter once min_count is tuned properly.voyage-code-3 (100 texts/call, comfortably under Voyage's 1,000-text/
120K-token caps), writes vectors onto Neo4j's generic :Node label, and
creates a native vector index (node_embedding, cosine similarity) that
simply excludes any node without an embedding rather than erroring. Measured on httpx:
1,229 candidates, 1,229 embedded, 162,884 tokens billed.
semantic_search() is the librarian half of the WHY.md
distinction, on its own — no graph traversal.predict_impact_vector() ranks candidate files by their single best-matching
node and caps the result at k files — the first version instead
unioned every seed function's own top-k neighbors unfiltered, and scored a
shiny 75.5% recall by sweeping in ~30% of httpx's 60-file corpus per case, the vector
equivalent of D012's leakage bug (D015). At a budget matched to the call-graph baseline
(~4 files/case), vectors alone score 19.3% — call-graph's 22.4% holds up. But
predict_impact_hybrid(), a plain set union of both signals, reaches
40.1% recall / 14.8% precision — better than either alone, meaning the two
signals miss different things. Imports predict_impact_calls /
predict_impact_loo and CaseScore from baseline.py
(D012: use_cochange=True here needs pairs= too, same
leave-one-out requirement as baseline.score_baseline) and nothing from
embed.py, so it has no voyageai dependency: scoring reuses
vectors labgo embed already wrote, no live Voyage call needed to rerun it.
D015 follow-up: turning cochange back on with D010's matched min_count=25
lifts the hybrid to 43.3% recall / 15.4% precision — real lift, not the
leakage D012 caught, though it shrinks as k grows since a wide vector
net starts catching the same hits on its own. Ranking the union stays unbuilt on
purpose — no measurement yet shows the plain union is the bottleneck.call_graph_traverse and co_change_neighbors
wrap what baseline.py/graph.py already measure,
semantic_search wraps embed.py, and two are new —
test_coverage (walks TESTS edges) and
likely_reviewer (most frequent historical committer, plain
git log, no graph). State carries Anthropic's own message/tool-use JSON
unchanged — no langchain_core message type, no
create_react_agent: the graph is agent ⇄ tools with a
conditional edge, so the routing decision that is this stage's actual point
(D001) stays visible instead of hidden inside a prebuilt loop. A finalize
node (tools disabled) is the escape hatch at max_turns, guaranteeing
termination in a fixed number of turns rather than hoping the model stops calling
tools on its own. The system prompt lists every file id in the corpus so predictions
are scored on reasoning, not on inventing correctly-spelled paths;
parse_impacted_files() drops anything unrecognized rather than guessing,
and keeps the drop visible on AgentResult.unrecognized_mentions instead
of silently discarding it.
Measured on httpx (n=40/236 sample, Haiku 4.5, D016): 30.3% recall / 5.8%
precision — does not beat the 43.3% / 15.4% deterministic hybrid+cochange floor.
Diagnosed, not just reported: mean predicted size is 13.05 files/case, 57% larger
than the deterministic baseline's own matched budget — every deterministic method
has an explicit size cap (hops/k/min_count),
the agent has none, so it lists everything it gathered rather than committing to a
budget. Same category of mistake D012 and D015 each paid for once: a plausible
number produced by predicting too much.agent.py puts an LLM inside this project deciding which tool to
call, mcp_server.py puts this project's tools inside any MCP
client's own model — Claude Code, Claude Desktop, anything that speaks the
protocol. Owns no logic of its own: build_server() wires
MCPServer.tool() decorators around agent.py's
tool_call_graph / tool_co_change /
tool_semantic_search / tool_test_coverage /
tool_likely_reviewer — promoted off their old _tool_*
private names specifically for this reuse, two callers sharing one implementation
rather than two copies drifting apart. A labgo://files
resource (not a tool — static context a client reads once, not an action to
invoke repeatedly) exposes every file id, the same grounding
agent.py's system prompt gives the LangGraph loop. Connects to Neo4j
once at process startup, not per-call like every other CLI command — an MCP server
is a long-lived stdio process, not a one-shot invocation. Verified against a real
mcp.ClientSession over actual stdio JSON-RPC (handshake, tool listing,
tool calls, resource read) — not just calling the server object's methods directly.SimpleSpanProcessor(ConsoleSpanExporter()), not Batch:
labgo's commands are short CLI processes, and a batched processor can still be
holding spans when one exits. Chose this over Langfuse specifically to avoid one
more account/API key that can be unavailable overnight (D017) — swapping in an OTLP
exporter later is one line, not a rewrite. span() is a no-op context
manager when opentelemetry-sdk (the observability extra)
isn't installed — same optional-dependency shape as voyageai (D014) and
mcp (Stage 5); every caller runs identically either way, just untraced.
agent.py's run_agent() wraps the whole invocation in one
root agent.run span so every child agent.llm_call /
agent.tool_call span nests under it and shares a trace_id —
the first version didn't do this, and checking directly showed every span getting
its own trace, a pile of spans rather than a trace of one run. LLM spans
carry input_tokens / output_tokens / latency_ms;
mcp_server.py's tool spans carry latency_ms only — that
model call is the client's, not one this project pays for.labgo/ and labgo/ingest/.
No content.viewer/ (React + TypeScript, dist/ committed)react-force-graph-2d): Explore shows the raw graph with
CALLS edges visible by default (CONTAINS is 1,200+ edges and
would drown out everything else). Impact is click-driven, not hover-driven — pick
a node and a BFS walks the reverse CALLS adjacency (callee → caller,
the "what breaks if I change this" direction) out to an adjustable hop depth, colored by
hop distance, plus separately-highlighted co-change neighbors from
cochange.json — also threshold-adjustable now (a "Co-change ≥" slider next
to Hops, min pinned at 2 since that's cochange.json's own baked-in floor,
max computed from whatever count actually appears in the loaded data). A hint nudges
toward ≥25 below that, citing D010's matched-budget finding directly in the UI — low
thresholds visibly pull in most of the corpus, the same thing that made the default
unfit for scoring. Camera auto-fits to whatever's currently in frame via
zoomToFit, clamped to a min/max zoom so a single isolated node doesn't fill
the screen.ingest/models.py
writes (RawGraph / RawGraphNode / RawGraphEdge) plus
RawCochangeEdge from gitlog.py's output, and the
GraphNode/GraphLink shapes react-force-graph-2d
wants once its simulation mutates edges to point at live node objects.<App /> in strict mode) and
styling. No project-specific logic.data/graph.json and
data/cochange.json into public/ so npm run dev's
Vite server has something to fetch. Never runs as part of npm run build —
the production bundle must not bake in whichever corpus the maintainer last happened to
ingest, which is exactly what cli.py's _view_handler exists to
avoid at the other end.labgo view actually serves — checked
into the repo so end users need no Node at runtime. Regenerate with
cd viewer && npm install && npm run build; only necessary when
src/ changes.react-force-graph-2d for the canvas. Linted with
oxlint rather than ESLint.pyast.py and gitlog.py: import/self
call resolution, the builtin-exclusion fix from D005, syntax errors counted rather than
fatal, and — pointedly — that the git-log parser never silently returns zero commits
(the exact failure mode from D006).benchmark.py: cases with a dead seed or a dead
expected file get dropped, the drop-percentage arithmetic is correct including the 0/0
case, a corpus at the wrong commit raises CorpusMismatch with an
actionable fix command rather than silently scoring the wrong world, and (D012)
write_benchmark()/load_evidence() round-trip raw co-change
pairs unchanged — including counts below min_count, which leave-one-out
needs and the old, already-filtered cochange.json couldn't provide.leave_one_out_neighbors()
keeps a neighbor whose count survives other commits' support, drops one whose only
support was the excluded commit (the bug this exists to catch), leaves untouched pairs
alone, and never subtracts more than the one commit's single contribution even when
min_count is raised past it. Pure Python, no live Neo4j.graph.py: graph.json
round-trips through read_graph_json() unchanged, cochange.json
is picked up when present, and — pointedly — connect() raises
RuntimeError rather than silently connecting to nothing when
NEO4J_PASSWORD is unset. No live database for any of these.None
(not 0) when nothing was predicted at all — averaging that as zero would
punish an empty prediction twice, since it already tanks recall.embed.py:
read_source() extracts the right line range, and returns None
rather than raising when line numbers are missing or the file's gone — no live Voyage
or Neo4j needed.hybrid.py:
aggregate_scores() averages recall/precision correctly, hit rate counts
cases with any recall, empty predictions don't drag the precision mean to zero (same
guarantee test_baseline.py checks for CaseScore itself), and
zero cases aggregates to zeros rather than a division-by-zero crash. No live Neo4j —
the two Cypher-querying functions this module adds
(predict_impact_vector, predict_impact_hybrid) aren't unit
tested here, matching how baseline.py's own
predict_impact isn't either; both were instead checked directly against
Aura while diagnosing D015's inflated-prediction bug.agent.py, no live Anthropic or
Neo4j: parse_impacted_files() keeps recognized file ids and drops
unrecognized ones without crashing (including a model that ignores the format
entirely), and _route_after_agent() — the loop's actual routing logic —
continues to tools under the turn cap, forces finalize at
it, and ends once the model stops calling tools on its own. A hallucinated tool name
dispatches to an error payload, not an exception. The tool-calling loop itself was
exercised directly against Aura, same testing philosophy as
baseline.predict_impact / hybrid.predict_impact_vector.tracing.span()'s contract without
special-casing whether opentelemetry-sdk is installed — that would
defeat the point of it being optional (D017): usable as a context manager either
way, yields something attribute-settable or None, accepts no
attributes, and — the one that would actually matter in production —
never swallows an exception raised inside the traced block.labgo ingest on httpx: 1,301 nodes, 2,100 edges.labgo history: 1,745 co-change edges and 610
unfiltered eval cases. Explicitly labelled "not yet usable for scoring" until run through
benchmark.b5addb64, 236
answerable cases surviving from 565 raw (D008), plus the extraction parameters and filter
rule that produced them — everything needed to reproduce or invalidate the score later.min_count — leave-one-out has to subtract a commit's
contribution before the threshold is applied, or a pair that only clears it
because of the excluded commit would look like it never existed.labgo load writes
graph.json's nodes/edges plus CO_CHANGED into a single free
Aura instance (D013, switched from local Docker the same day); labgo embed
adds an embedding vector property + native vector index on top of the same
nodes. One instance, no local/prod split — --clear is destructive against
the only copy, though recoverable in one command since data/graph.json
stays the source of truth. Free tier auto-pauses after 72h idle and is deleted 30 days
after that; expected and accepted for a project touched in bursts.data/. benchmark reruns history's extraction alongside a fresh
git rev-parse / ls-tree read, filters out any case touching a
file that no longer exists, and writes the pinned exam to benchmarks/httpx/.
verify takes that manifest's recorded SHA and checks it against the repo's SHA
right now — mismatch refuses to score rather than returning a plausible wrong number.
view (bottom) is deliberately off the bus — it takes no repo argument at all. It
serves the committed viewer/dist/ bundle and re-reads graph.json
/ cochange.json from disk on every request rather than at build time, so the
browser always sees whatever you last ingested. Dashed lines mark the two test files
asserting the modules above them. Not shown: README.md, WHY.md,
DECISIONS.md, pyproject.toml — prose and config, no runtime
data passes through them.
evidence.json too, not just cases.json,
so it can subtract each case's own commit from the co-change pairs before scoring it
(leave-one-out, D012) instead of scoring against Neo4j's global, leaky edges.
baseline --method vectors|hybrid reuses that same cases.json but
routes through hybrid.py instead of baseline.py — no
voyageai dependency, since it only reads vectors embed
already wrote. embed reads the corpus repo directly (for source text) plus the
Voyage API (for vectors); search takes a CLI argument and calls Voyage for
the query embedding before querying Neo4j's vector index. Dashed lines mark values
returned back from Neo4j rather than written to it. Not shown:
tests/test_graph.py, test_baseline.py,
test_embed.py, and test_hybrid.py assert the pure-Python
parts of the four modules above without touching a live database or API — see the
Tests section.
mcp.ClientSession over stdio, not just direct function calls. Both call
the identical tool_*() functions in agent.py — promoted off
their original _tool_* private names specifically so a second caller
could reuse them without a copy drifting out of sync. Four of the five tools query
Neo4j; only likely_reviewer reads the corpus repo directly, via plain
git log, no graph involved.