Large Java Codebase โ Measure the Token Reduction
Learning Objectives
By the end of this capstone you will be able to:
- Extract a structural graph from a real Java codebase (624 to 9,000+ files) with tree-sitter-java โ and read its provenance report critically.
- Verify a graph against ground truth before trusting any number it produces โ the step no benchmark reader ever does.
- Serve a large graph to any MCP-aware agent with the parametrized M09 server (
--graph). - Run a two-arm token benchmark and produce your own measured table โ per-question multiples, reduction percentages, exploration burden.
- Explain your numbers the way this course taught you: per-question vs session-level, ceiling case vs median, and why a smaller repo can show a bigger multiple.
Skill level: Intermediate โ Advanced
The Brief: Flip the Floor
In Capstone 1 you built the full three-layer stack โ structural graph, OKF bundle, enrichment hook, MCP server, freshness gate โ over orderflow, a 15-file repo. Your measured improvement was a single-digit multiple, and the course told you that was the correct, expected result: orderflow sits far below the ~500-file threshold where graph tooling pays for itself.
That lesson only lands completely when you see the other side. This capstone flips the setup: you will graph Apache commons-lang (624 Java files) and Spring Framework (9,247 Java files โ over 13 million tokens of source), then run a deterministic benchmark harness that measures, for real structural questions, what an exploring agent would pay versus what one graph query costs.
By the end you will hold a benchmark table with your name on it โ the same shape of table that M04 and M05 taught you to read with suspicion. Producing one yourself, and then explaining its numbers honestly, is the graduation exercise of this course.
BEFORE: In a village of 15 houses, you don't need a map. You can knock on every door in ten minutes, so a cartographer's services feel like overhead โ you pay for the map and save almost nothing.
PAIN: Now you're a courier in a city of 9,000 buildings. "Deliver to everyone named Chen" means walking every street and reading every mailbox โ not once, but for every single delivery order. The cost of not having a map isn't linear; the bigger the city, the more brutal every lookup becomes.
MAPPING: orderflow was the village; Spring Framework is the city. The extraction cost (building the map) is paid once โ ~2.5 minutes. Every structural question afterwards ("who calls registerBeanDefinition?") is a map lookup instead of a door-to-door walk past hundreds of files. This capstone measures exactly how much the walk costs.
What you'll wire together
kg_extract_java.py โโโบ graph.json โโโบ MCP server (M09, --graph flag)
โ โ
[Java repo] measure_tokens.py
commons-lang (624 files) โ
spring-framework (9,247 files) your benchmark table
synthetic (offline fallback) (per-question + aggregate)Everything reuses course machinery: the graph schema from M01, the extraction approach from M03 (same tree-sitter family, new grammar), the M09 server with its new --graph flag, and โ in the writeup โ M11's staleness discipline.
Concept: The Two Arms of an Honest Measurement
Every benchmark comparing "agent with graph" to "agent without graph" is secretly a claim about what the without arm costs. Get that arm wrong and the multiple is fiction. So before you run anything, understand precisely what our harness charges each side.
BASELINE arm (simulated exploration). The harness greps the repo for the call-site pattern \bsymbol( โ a word boundary, the name, an opening parenthesis. That's what a competent agent actually greps: not the bare name, which over-matches comments and longer identifiers. Then it charges the full size of every matched file, at roughly 4 bytes per tokenThe unit LLMs read and bill by; ~4 characters of English/code on average. A 24KB file โ 6,000 tokens of context. โ because to confirm which hits are real call sites and who contains them, files enter the agent's context whole. Ops = files opened.
GRAPH arm (one tool call). The harness charges the byte size of the JSON answer returned by graph_callers(), plus a one-time ~700-token architectural orientation read (the GRAPH_SUMMARY.md pattern from M11) amortized across the question count. Ops = 1 tool call.
Notice what the baseline is not: it is not what a lazy agent spends. A lazy agent reads three of the 46 matching files, answers from those, and is wrong about the other 43 call sites. The baseline is the mechanical floor of a correct answer โ the honest denominator. That asymmetry (the graph is complete by construction; exploration is complete only if you pay for every file) is the entire economic argument of this course, made concrete.
registerBeanDefinition( โ ~764K tokens to confirm them all โ versus a ~46K-token graph answer. 16.4x for this one question.On the Spring run, answering just 8 structural questions by exploration means opening 4,535 files โ 15.2 million tokens. That is not a marketing projection โ you will reproduce it on your machine in Phase 4, in about 15 seconds of harness time. The graph answers the same 8 questions for โ451K tokens.
"116x means my monthly bill drops 116x." โ No. These are per-question multiples for pure structural lookups โ the same class of number as okf-rs's published ~400x per query. Sessions also spend tokens on reasoning, editing, and non-structural reads that no graph can remove; replicated session-level savings run 6.8xโ49x.
"The tool picked easy questions." โ The harness auto-selects symbols by caller count (structural relevance: the most-called methods are what developers actually ask about) โ never by grep expense. Selecting the most grep-expensive names would manufacture a ceiling case, which is exactly the benchmark sin M04 taught you to spot.
"Bigger repo always means bigger multiple." โ Bigger repo means bigger exploration burden (files opened per question). The multiple also depends on how big the graph's answers are โ you'll see commons-lang beat Spring on the multiple for exactly this reason, and explaining it is part of Phase 5.
Concept: When the Graph Should Refuse to Guess
Java breaks the bare-name resolution you used on Python in a specific way: overloading and interface convention mean the same method name is defined in many places. In orderflow, decode_jwt had one definition; in Spring, getName has hundreds.
The extractor keeps the three provenance tags you know from M01 โ with one addition:
| Tag | Meaning | Example (commons-lang run) |
|---|---|---|
EXTRACTED | Callee defined in the same file โ unambiguous local resolution | 2,055 edges |
INFERRED | Exactly one definition of that bare name in the whole corpus | 5,921 edges |
AMBIGUOUS | 2โ8 candidate definitions; every candidate emitted, honestly labeled | 32,333 edges |
| unresolved | More than 8 definitions (AMBIG_CAP = 8): no edges emitted at all | 10,258 calls to 36 names dropped โ append, toString, getโฆ |
Why drop rather than emit? A call to a name defined 150 times carries no structural signal โ emitting 150 candidate edges per call site is quadratic noise wearing an honesty costume. The first version of this extractor did exactly that and produced 183,568 edges for a synthetic repo whose true structure had under 6,000; the cap brought it back to 5,960 without losing a single seeded ground-truth edge. Refusing to guess is the honest answer here. Real tools do better not by guessing harder but by escalating to type resolution โ M03's LSP ladderM03's resolution ladder: heuristic bare-name matching โ LSP-backed disambiguation (ask the language server via textDocument/definition) โ accept AMBIGUOUS. Each rung costs more and resolves more., where a language server like Eclipse JDT resolves the receiver's static type.
You now know how to read the extractor's closing line โ unresolved: 10,258 calls to 36 over-ambiguous names dropped โ as a feature report, not an error. It tells you which questions this graph can answer deterministically and which ones need a smarter tool. Ask it about toString and it will decline; ask it about registerBeanDefinition and it answers in one call.
Concept: Reading Benchmarks Honestly โ Now With Your Own
M04 gave you the skeptic's checklist for other people's benchmarks: vendor-reported vs replicated, ceiling vs median, corpus topology. Now apply it to the table you are about to produce. Three questions matter:
- What level is the number? Per-question structural lookups (this harness, okf-rs's ~400x/query) vs whole sessions (CodeGraph's task benchmarks: OkHttp 645 files โ 13%, Django ~3,000 โ 36%, VS Code ~10,000 โ 78%; independent Graphify replications 6.8xโ49x). Both are true; they measure different things.
- What does the baseline assume? Ours: complete confirmation of every call-site grep hit. Weaker baselines (agent samples a few files) produce smaller multiples and wrong answers; stronger claims usually hide a lazier baseline.
- What was selected, and by whom? Symbols here are chosen by caller count, deterministically, before any cost is known. If you let the selector see grep expense first, you can make the aggregate say almost anything โ try it in Going Further and watch the number inflate.
Bridge to the build: you now know what each arm charges, why some names get dropped, and how to classify the number that comes out. Time to produce it.
The Build
๐ Get the files: labs/CAPSTONE-2-java-token-benchmark on GitHub โ or clone the whole course once: git clone https://github.com/varasrinivas/knowledge-graph-course.git
All paths below are relative to labs/CAPSTONE-2-java-token-benchmark/. The full packet, with the same steps, lives in that folder's README.md. Pick at least two repo sizes โ the comparison across sizes is the lesson.
| Target | Files | How |
|---|---|---|
| synthetic-mini | ~91 | python gen_java_repo.py <dir> --files 100 (offline, known ground truth) |
| commons-lang | 624 | git clone --depth 1 https://github.com/apache/commons-lang.git |
| synthetic | ~1,491 | python gen_java_repo.py <dir> (offline, known ground truth) |
| spring-framework | 9,247 | git -c core.longpaths=true clone --depth 1 https://github.com/spring-projects/spring-framework.git |
Extract
Install the Java grammar
What & why: tree-sitter needs a per-language grammar; you used tree-sitter-python in M03, this adds Java. Already listed in labs/requirements.txt.
pip install tree-sitter tree-sitter-javapython -c "import tree_sitter_java" exits silently. If it doesn't, you're outside the labs venv.
Extract each target
What & why: one command per repo; the extractor is deterministic (sorted output, no timestamps), so re-runs on the same snapshot are byte-identical โ you can diff graphs across commits.
python ../shared_tools/kg_extract_java.py <repo> --out <repo>-graph.json --quietExpected shape (real runs):
Rebuilt: 10648 nodes, 40309 edges -> commons-lang-graph.json
nodes: {'class': 939, 'interface': 104, 'method': 8981, 'module': 624}
edges: {'AMBIGUOUS': 32333, 'EXTRACTED': 2055, 'INFERRED': 5921}
unresolved: 10258 calls to 36 over-ambiguous names (> 8 candidates) dropped,
e.g. ['append', 'toString', 'get', 'of', 'contains']Rebuilt: 110885 nodes, 432741 edges -> spring-graph.json
nodes: {'class': 15806, 'interface': 1819, 'method': 84065, 'module': 9195}
edges: {'AMBIGUOUS': 379024, 'EXTRACTED': 16022, 'INFERRED': 37695}
unresolved: 228955 calls to 911 over-ambiguous names dropped, e.g. ['assertThat', 'get', 'getName']Windows: Filename too long during the Spring clone โ Spring's docs tree overflows the 260-char path limit. Clone with git -c core.longpaths=true AND into a short path like D:\tmp โ a long parent directory re-breaks it even with longpaths on.
Extraction seems slow โ check you passed --quiet; progress prints every 500 files otherwise. Spring's ~2.5 min is parsing 9,195 files โ that is the one-time map-building cost.
Read the provenance report
What & why: the closing lines are the graph's honesty label. Note commons-lang's ratio โ 32K AMBIGUOUS to 8K resolved โ and recall the concept section: Java overloading makes AMBIGUOUS the dominant class, and the unresolved line lists exactly which questions this graph will decline.
You can answer: which provenance tag would graph_callers("toString") rely on? (None โ it's dropped as unresolved; the server will return a graceful miss.)
Verify Against Ground Truth
This is the step no benchmark reader ever does โ and the reason your numbers will deserve more trust than the ones on Medium.
Exact check on the synthetic repo
What & why: gen_java_repo.py is seeded: it writes known_truth.json alongside the code, recording that the deliberate god node Registry.lookup has exactly 1,192 callers on the full synthetic. Count the graph's edges into lookup and compare.
import json
g = json.load(open("synth-graph.json")); truth = json.load(open("synth-java/known_truth.json"))
callers = [e for e in g["edges"] if e["target"].endswith(".lookup")]
print(len(callers), "found, expected", truth["lookup_callers"]) # 1192 found, expected 11921192 / 1192. The instrument measures what it claims to measure.
Spot check on the real repo
What & why: ground truth for commons-lang is the source itself. Pick one caller from graph_callers("capitalize"), open the named file at the named line, and confirm the call site with your own eyes. Two spot checks are enough โ the point is the habit, not the coverage.
You verified real edges by hand. Every number in Phase 4 now rests on an instrument you tested, twice, two different ways.
Serve
Point the M09 server at the Java graph
What & why: the M09 server gained a --graph flag: skip extraction, load any pre-built graph.json. Same three tools (graph_callers, graph_callees, explore), new corpus โ this is the payoff of keeping one schema across both extractors.
# standalone
python ../M09-mcp-graph-server/solution/server.py --graph <path>
# register with your assistant (absolute paths!)
claude mcp add java-graph -- python /abs/path/server.py --graph /abs/path/graph.jsonAsk through the agent โ and test the miss
What & why: ask "who calls registerBeanDefinition?" and watch it answer with one tool call, zero file reads. Then ask about a symbol that isn't there: the server must return error: ... try grep, never a stack trace โ the graceful miss is what lets an agent fall back instead of falling over.
The server loads graph.json at startup. Re-extract Spring and the running server still serves the old graph until you restart it. Fresh on disk โ fresh in memory.
One-call answer on a real symbol; graceful miss on a fake one.
Measure
Run the harness on every target
python solution/measure_tokens.py <repo> <graph.json> --questions 8 --json bench.jsonReal Spring output (deterministic per repo snapshot โ yours will match closely):
who calls ...? files baseline tok graph tok reduction multiple
--------------------------------------------------------------------------
getClass 1340 4,075,945 84,885 97.9% 48.0x
registerBeanDefinition 174 764,534 46,540 93.9% 16.4x
isTrue 1070 3,377,634 122,220 96.4% 27.6x
...
--------------------------------------------------------------------------
AGGREGATE 4535 15,194,788 451,436 97.0% 33.7xYour aggregate line exists, with the honesty notes printed under it. Save each --json file โ Phase 5 compares them.
Probe the refusal path
What & why: force a question the graph declines: --symbols toString. The row must come back marked unresolvable โ the graph declines to guess. A benchmark that can't show its instrument failing isn't a benchmark; it's an ad.
The Honest Writeup
Explain your own numbers in NOTES.md
Answer three questions, in writing, with your data:
- What level are these multiples? Per-question structural lookups. Cite the session-level range (6.8xโ49x) and say why they differ: sessions spend most tokens on reasoning and edits the graph can't touch.
- Why does commons-lang (116.5x) beat Spring (33.7x) on the multiple despite being 15x smaller? Spring's answers are bigger โ more callers and more AMBIGUOUS candidates per answer raise the graph arm's cost. Answer quality and ambiguity trade against raw reduction. Meanwhile the exploration burden (files/question) does scale with the haystack: 12 โ 46 โ 154 โ 567.
- Why did CodeGraph's published OkHttp number land at 13%? 645 files, session-level measurement, small haystack โ your own commons-lang session experience will feel closer to that than to your 116x lookup table, and now you can explain why both are true.
Someone who has never seen this course could read your NOTES.md and NOT be misled by your own benchmark. That is the bar.
Ask the M11 question, again
Your Spring graph took ~2.5 minutes to build โ nobody will rebuild it on every commit by hand. So: how would you detect this graph going stale within 24 hours? Answer mechanically. (Your Capstone-1 freshness gate works here unchanged โ same artifact, same git log -1 comparison.)
Live Measurement (Optional)
Compare against a real agent
With an API key: ask claude -p the same two questions with and without the MCP server registered, and compare reported token usage. Expect the live gap to be smaller than the harness's mechanical floor โ a live agent reads files partially, samples instead of confirming, and reasons either way. Record both numbers. Understanding why they differ โ completeness vs sampling, mechanical floor vs behavioral reality โ is the graduation exercise of this course.
Measured Reference Results
All from real runs of the course tooling (2026-08-24 snapshots; extraction: commons-lang ~12s, Spring ~2m27s):
| Repo | Files | Nodes / Edges | Files opened per question | Aggregate per-question |
|---|---|---|---|---|
| synthetic-mini | 91 | โ | 12 | 8.3x (87.9%) |
| commons-lang | 624 | 10,648 / 40,309 | 46 | 116.5x (99.1%) |
| synthetic | 1,491 | 7,008 / 5,960 | 154 | 13.4x (92.6%) |
| spring-framework | 9,195 | 110,885 / 432,741 | 567 | 33.7x (97.0%) |
What a Real Live Run Looks Like
Everything above is the mechanical benchmark โ no model in the loop. We also ran the Phase 6 live experiment: the same questions asked via claude -p against this spring-framework snapshot, Sonnet on every arm, medians of 3 independent fresh sessions per cell. Four arms: baseline (Read/Glob/Grep only, clean repo), init (identical tools, but every spring-* module first got a headless-generated CLAUDE.md โ M12's layered-context pattern, i.e. the add lever, as a measured arm; one-time generation cost ~$3.40 for 22 modules), graph-available (graph MCP tools plus file tools, prompt says "prefer the graph"), and graph-forced (MCP tools ONLY โ the Graphify-style forced integration).
The three arms, in plain language. Answering questions about a 9,000-book library: baseline = an assistant with a photocopier that returns just the sentences mentioning your query. Catalog-available = same assistant, plus a card catalog it may consult โ but our homemade catalog's cards are fat (every same-named entry merged in), so the assistant reads the card and then double-checks the shelves, paying for both. Catalog-forced = the catalog is the only thing on the desk: one card lookup, one answer, done โ provided you can live with the card's known sloppiness, which the assistant is told to disclose.
| Question | Baseline | Init (CLAUDE.mds) | Graph-available | Graph-forced |
|---|---|---|---|---|
who calls registerBeanDefinition | 116K ยท 3t | 81K ยท 2t | 154K ยท 4t | 42K ยท 1t |
who calls ResolvableType.forClass | 125K ยท 3t | 197K ยท 4t | 415K ยท 10t | 136K ยท 3t |
why BeanDefinitionOverrideException (logic) | 231K ยท 6t | 203K ยท 5t | 276K ยท 8t | 524K ยท 12t |
refresh() + late bean definitions (logic) | 114K ยท 3t | 118K ยท 4t | 113K ยท 3t | 234K ยท 8t |
| Structural totals | 240K | 278K (+16%) | 570K (+137%) | 178K (−26%) |
| Logic totals | 345K | 321K (−7%) | 389K (+13%) | 758K (+120%) |
| All four questions | 585K | 600K (+2%) | 959K | 936K |
Read the three arms as one story:
- Forcing is what unlocks the win. With the graph as the only tool, the callers question was answered in ONE turn at 42K tokens — 64% under baseline. When the graph was merely available, the model queried it, distrusted the fan-out, and re-verified against files — paying for both paths. On
forClassthat made graph-available WORSE than having no graph at all (10 turns, 415K). - Fan-out is the graph's poison. Bare-name resolution merges overloads into 1,000+-caller answers; even forced,
forClassonly tied the baseline, with wild spread (45K–698K). A type-resolved graph with compact answers is what earns both trust and savings. - Logic questions invert the verdict. The graph-only agent DID answer both "why" questions correctly — it walked callee edges to reconstruct the override-guard chain and the post-processor re-scan — but at roughly DOUBLE the baseline's cost (L1: 524K vs 231K, 12 turns of edge-triangulation vs 6 turns with one file read). The graph can substitute for reading code, expensively; it cannot beat reading code at comprehension. Caveat: Spring is in the model's training data, so edge-walking "confirmed" logic it partly already knew — on a private codebase, graph-shaped confidence over unread code is a hallucination risk, not a capability.
- The line-granular baseline is strong. A real agent's Grep returns matching lines, never whole files, so the mechanical whole-file floor (the 33.7x above) vastly overstates what live exploration costs.
- The add lever is a per-session wash that hides texture. The init arm landed at +2% overall โ but that average conceals a 30% WIN on one navigation question (the CLAUDE.md map sent the agent straight to the right module: 2 turns) and a 58% LOSS on another (module CLAUDE.mds load into context when the agent works in those directories, and on that question the added context never paid for itself). CLAUDE.mds are conventions-and-orientation context, not a lookup index โ M12's "facts every session needs" rule, measured. The one-time $3.40 generation cost amortizes only if many future sessions hit the winning pattern.
- The synthesis is M10's router. Neither arm wins everywhere: forced-graph is best for structural lookups (−26%), files are best for comprehension (forced-graph +120% there). A router sending each question class to its cheap arm would have spent ~523K on these four questions vs 585K all-baseline — the hybrid-stack lesson, measured.
- Measurement anomaly, reported not hidden: the forced arm's dollar cost read high relative to its token counts (the CLI's cost field didn't reconcile with its usage fields on those runs); tokens and turns are the trustworthy columns there.
Our first two live campaigns reported "the graph arm lost by ~70%" — and those numbers were invalid: the MCP server had been silently crashing at startup in every session (an SDK-2.x import rename), so the "graph arm" was really baseline plus a false promise of tools. Nothing errored. The agent simply reported the tools "unavailable" — and with file tools as fallback, the failure was invisible in the results. It was caught only because a forced arm had no fallback to hide behind. This is M11's thesis eating its own benchmark: a component that fails silently under a client that degrades gracefully produces confident, wrong measurements. The forced arm is not just a performance configuration — it is a canary.
The mechanical per-question floor (33.7x) measures worst-case exploration vs answer size, no model involved. The replicated session ranges (6.8x–49x) measure other stacks on other tasks. The live measurement on this stack depends on integration mode: graph-available lost (+137% structural), graph-forced won (−26%) — same graph, same model, same questions. All of these are correct answers to different questions. Only a measurement on your stack, in your integration mode, with a verified-working server, describes your setup — which is why this capstone makes you run it, and why the first thing to verify is that the graph is actually being served.
The Four Scenarios, In Depth
Each arm of the live benchmark is a deliberate integration scenario you might actually ship. This is the full reference for each: what it simulates, its exact configuration, how to run it, what it measured, and the insight it bought. (Same content ships as live_bench/SCENARIOS.md next to the tooling.)
Scenario 1 — baseline: a normal agent, nothing added
Simulates: the out-of-the-box experience — file tools, no curated context. The control every other scenario is measured against.
| Tools | Read, Glob, Grep |
| Prompt extras | none (question + effort cap only) |
| Repo state | clean — zero CLAUDE.md files (the runner records the count per run and warns if nonzero) |
| Run | python run_live_bench.py <repo> --arms baseline --reps 3 — FIRST, before Scenario 2 ever touches the repo |
| Measured | structural 240K · logic 345K · all-four 585K |
Insight: far stronger than the mechanical benchmark predicts, because a real agent’s Grep returns matching lines, not whole files — 3 turns on the callers question without wholesale file reads. Anything that wants to win has to beat line-granular grep, not the whole-file strawman.
Scenario 2 — init: curated CLAUDE.md context (the ADD lever)
Simulates: a team that invested in per-module context files — M12’s layered-CLAUDE.md pattern. Claude Code auto-loads a module’s CLAUDE.md when the agent works in that directory; the orientation rides along, and so does its token cost.
| Tools | identical to baseline — the ONLY variable is the repo state |
| Repo state | a generated CLAUDE.md in every spring-* module (22 files, ~$3.40 one-time) |
| Run | python init_claude_mds.py <repo> --prefix spring- → run_live_bench.py <repo> --arms init --reps 3 → init_claude_mds.py <repo> --remove |
| Gotcha | /init does NOT fire in claude -p mode — the script uses an explicit “analyze and Write CLAUDE.md” instruction |
| Measured | structural 278K (+16%) · logic 321K (−7%) · all-four 600K (+2%) |
Insight: the +2% wash hides the story — a 30% WIN where the CLAUDE.md map sent the agent straight to the right module (81K, 2 turns), a 58% LOSS where the auto-loaded context never paid off. CLAUDE.mds are orientation-and-conventions context, not a lookup index; the generation cost amortizes only across sessions that hit the winning pattern.
Scenario 3 — available: graph offered, not imposed
Simulates: the most common real integration — a graph MCP server registered alongside normal tools, with guidance to prefer it. The agent chooses.
| Tools | file tools plus graph_callers / graph_callees / explore |
| Prompt extras | “Prefer the graph tools for caller/callee questions; fall back to Read/Grep only when the graph cannot answer” |
| Run | python run_live_bench.py <repo> --arms available --reps 3 --mcp-config mcp.json |
| Measured | structural 570K (+137% — the worst arm) · logic 389K (+13%) · all-four 959K |
Insight: optionality actively hurts with a fan-out-heavy graph. The model queries the graph, receives a 1,000+-candidate bare-name answer, rationally distrusts it, and re-verifies against files — paying for BOTH retrieval paths (10 turns, 415K on forClass). “We registered a graph and told the model to prefer it” is not an integration strategy.
Scenario 4 — forced: graph as the only tool
Simulates: a Graphify-style forced integration — the graph is the single search surface, no file access.
| Tools | graph_callers / graph_callees / explore — nothing else |
| Prompt extras | “They are your ONLY tools… note honestly if AMBIGUOUS provenance makes parts uncertain” |
| Run | python run_live_bench.py <repo> --arms forced --reps 3 --mcp-config mcp.json (add --forced-logic for the “why” questions) |
| Measured | structural 178K (−26% — the only arm to beat baseline), S1 in 1 turn at 42K · logic 758K (+120%) |
Insight (two): forcing is what unlocks the structural win — remove the option to double-check and one-call answers finally beat grep. And the forced arm is a canary: our first two campaigns measured a “graph arm” whose server was silently crashing; with file tools as fallback the failure was invisible. The forced arm, with no fallback, reported “tools unavailable” and exposed it. Smoke-test a forced session before trusting any graph-arm number. On logic questions it answered correctly by edge-walking — at 2x the cost, with the training-data confound (on private code, graph-shaped confidence over unread code is a hallucination risk).
The small-repo counterpoint: the same four scenarios on 36 files
We reran the identical four-scenario campaign (same runner, same methodology, questions adapted to the domain) on priorauth-api-boot3 — a 36-file Spring Boot 3 prior-authorization API. The mechanical harness said 15.2x per-question. The live grid said something else entirely:
| Question | Baseline | Init (root CLAUDE.md) | Graph-available | Graph-forced |
|---|---|---|---|---|
who calls setStatus | 160K · 4t | 118K · 3t | 228K · 7t | 188K · 6t |
who calls findByMemberNumber | 74K · 2t | 77K · 2t | 149K · 4t | 149K · 4t |
| when does MANUAL_REVIEW happen (logic) | 196K · 7t | 120K · 3t | 195K · 7t | 468K · 17t |
| eligibility exception semantics (logic) | 199K · 7t | 250K · 9t | 283K · 10t | 352K · 18t |
| All four | 629K | 565K (−10%) | 855K (+36%) | 1,156K (+84%) |
The verdict flipped. On spring-framework, forced-graph won structural and init was a wash. Here, one 55-line root CLAUDE.md describing the submission→determination flow is the best arm overall (−10%, and −38% on the MANUAL_REVIEW question — the map pointed straight at CriteriaScoringService), while the graph loses in every configuration — even forced (+84%): at 36 files grep costs 2 turns, the graph is tiny and half-AMBIGUOUS, and forced edge-walking on logic questions burned 17–18 turns.
This is the ~500-file payoff floor (M04, M11) measured live instead of asserted, and the sharpest form of the course’s closing lesson: which context lever wins is a property of the repo and the question mix, not of the tool. Big haystack + structural questions → forced graph. Small repo one person can hold in their head → one good CLAUDE.md beats everything. The only way to know which world you are in is the measurement you just learned to run. (Raw 48-session dataset: expected_output/live-run-priorauth.json.)
Forced-graph owns structural lookups; plain files own comprehension; CLAUDE.mds help orientation and tax everything else; an optional graph is the worst of both worlds. The synthesis is M10’s router, derived here from data: route structural questions to the forced graph and everything else to files ≈ 523K for this question set vs 585K all-baseline — each context layer serving only the question class it is good at.
Walk it, step by step
The four arms walked one at a time on the same question set. Watch for the flip at step 6: the arm that wins structural questions by 26% loses logic questions by 120% โ and then step 7, where on a 36-file repository every graph arm loses to a single hand-written CLAUDE.md.
Run These Benchmarks Yourself
Everything in the previous section is reproducible with the tooling that ships in labs/CAPSTONE-2-java-token-benchmark/live_bench/. The complete scenario reference โ what each of the four arms simulates, its exact configuration, step-by-step commands, measured findings, and pitfalls โ lives alongside the tooling in live_bench/SCENARIOS.md. The workflow below is the distilled version of what actually happened when we ran it โ including the guardrails added after each thing that went wrong.
The tooling
| Script | What it does |
|---|---|
run_live_bench.py | Runs the question set through claude -p, one fresh session per run, for any combination of the four arms. Resumable; records tokens, turns, cost, and the repo's CLAUDE.md count per run. |
init_claude_mds.py | Prepares the init arm: writes a CLAUDE.md into every module subfolder headlessly (~$0.05–0.15/module); --remove cleans them all back out. |
aggregate.py | Builds the median (min–max) table from the run files, with per-kind and per-arm totals, and flags errors and contamination. |
questions.json | The question set โ edit it for your repo; keep a structural/logic split so the router lesson stays measurable. |
mcp-config.template.json | Point it at the M09 server and your graph.json (absolute paths). |
The four arms
Documented in depth in The Four Scenarios, In Depth above — simulation intent, exact configuration, per-arm steps, measured results, and pitfalls. The short version: run baseline first on a clean repo, init only after generating CLAUDE.mds (and clean up after), available/forced need --mcp-config, and forced doubles as your server canary.
The run, step by step
cd labs/CAPSTONE-2-java-token-benchmark/live_bench
# 0. Prereqs: graph built (Phase 1) and the M09 server smoke-tested (Phase 3).
# DO NOT SKIP THE SMOKE TEST โ see the canary box below.
# 1. Control first, clean repo:
python run_live_bench.py D:/tmp/spring-framework --arms baseline --reps 3
# 2. The graph arms (fill in mcp-config from the template first):
python run_live_bench.py D:/tmp/spring-framework --arms available,forced --reps 3 --mcp-config mcp.json
# 3. The init arm โ write the CLAUDE.mds, run, then clean up:
python init_claude_mds.py D:/tmp/spring-framework --prefix spring-
python run_live_bench.py D:/tmp/spring-framework --arms init --reps 3
python init_claude_mds.py D:/tmp/spring-framework --remove
# 4. The table:
python aggregate.py runsMethodology rules (each one bought with a mistake)
- Fresh sessions, medians of ≥3. Identical sessions varied up to 2x in our runs; a single run is a coin flip. Every
claude -pcall is a fresh session โ repetition is the whole methodology. - Arm order matters. Baseline before init (the runner records the repo's CLAUDE.md count in every run and warns when a "baseline" run is contaminated).
- The forced arm is your canary. Our first two campaigns measured a graph arm whose MCP server was silently crashing โ invisible because file tools gave it something to fall back on. The forced arm has no fallback: if the server is broken, forced sessions say so instead of producing plausible garbage.
- Same model everywhere, one variable per arm. The runner pins the model; the only thing that changes between arms is tools/context.
- Windows gotcha: prompts must be single-line โ the
.cmdshim truncates argv at embedded newlines (the runner handles this; keep it in mind if you edit it). - Budget before you start: at Sonnet, expect roughly $0.10–0.40 per session, ~$2.50 per 3-rep arm campaign on a repo this size, plus ~$3 for a 22-module init pass.
Test Cases
| # | Type | Input | Expected |
|---|---|---|---|
| 1 | happy | graph_callers("registerBeanDefinition") on the Spring graph | Non-empty caller list, exactly one tool call |
| 2 | happy | Synthetic graph_callers("lookup") | Exactly the seeded count from known_truth.json (1,192 on the full synthetic) |
| 3 | happy | Harness on any target | Table + aggregate + honesty notes render; --json file written |
| 4 | edge | graph_callers("notASymbol") | Graceful error: ... try grep โ never a crash |
| 5 | edge | --symbols toString | Row marked unresolvable โ the graph declines to guess |
Going Further OPTIONAL
- Wire the freshness gate: point your Capstone-1
freshness_gate.pyat the Spring graph in CI; commit without re-extracting and watch it block. - Benchmark the benchmark: modify the selector to rank by grep expense instead of caller count, re-run, and watch your aggregate inflate โ you have now manufactured a ceiling case on purpose, which inoculates you against ever being fooled by one.
- Compare instruments: run Graphify or CodeGraph over commons-lang and diff their edge sets against
kg_extract_java's โ expect them to win on resolution (they do type analysis; you cap at 8 candidates). - Extend the server: add
extends/implementsqueries (graph_api-style) โ the edges are already in graph.json. - Stress the baseline: add a
--noiseflag togen_java_repo.py(dead code, comments naming symbols) and measure how the exploration arm degrades while the graph arm doesn't.
Knowledge Check
Summary
What you built: a verified Java structural graph pipeline, an MCP serving path for any pre-built graph, and โ the real artifact โ a benchmark table you can defend line by line. That closes the course: you can now build the map, keep it honest, and measure exactly what it saves.
References
- Lab packet:
labs/CAPSTONE-2-java-token-benchmark/README.md(targets, phases, expected outputs) - Tools:
labs/shared_tools/kg_extract_java.pyยทgen_java_repo.pyยทsolution/measure_tokens.pyยท M09 server--graphflag - tree-sitter Java grammar โ github.com/tree-sitter/tree-sitter-java
- Apache commons-lang โ github.com/apache/commons-lang ยท Spring Framework โ github.com/spring-projects/spring-framework
- Course modules this builds on: M03 (parsing & the LSP ladder), M04/M05 (benchmark literacy), M09 (MCP serving), M11 (staleness)