Knowledge Graphs for AI Agents
CAPSTONE 2 ยท BONUS โ˜…โ˜…โ˜…โ˜†โ˜† 80% lab ยท 20% concept

Large Java Codebase โ€” Measure the Token Reduction

โฑ ~90โ€“120 min ๐Ÿ“‹ Prerequisites: Capstone 1 (plus M03, M09, M11)
Bonus ยท after Capstone 1 โ€” outside the 14-part numbering

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

architecture
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.

๐ŸŽฏ Why It Matters

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.

โš ๏ธ Common Misconceptions (about benchmarks like the one you're about to produce)

"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:

TagMeaningExample (commons-lang run)
EXTRACTEDCallee defined in the same file โ€” unambiguous local resolution2,055 edges
INFERREDExactly one definition of that bare name in the whole corpus5,921 edges
AMBIGUOUS2โ€“8 candidate definitions; every candidate emitted, honestly labeled32,333 edges
unresolvedMore than 8 definitions (AMBIG_CAP = 8): no edges emitted at all10,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.

๐Ÿ”Ž What Just Happened?

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:

  1. 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.
  2. 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.
  3. 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.

TargetFilesHow
synthetic-mini~91python gen_java_repo.py <dir> --files 100 (offline, known ground truth)
commons-lang624git clone --depth 1 https://github.com/apache/commons-lang.git
synthetic~1,491python gen_java_repo.py <dir> (offline, known ground truth)
spring-framework9,247git -c core.longpaths=true clone --depth 1 https://github.com/spring-projects/spring-framework.git
1

Extract

STEP 1

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.

terminal
pip install tree-sitter tree-sitter-java
โœ… Checkpoint

python -c "import tree_sitter_java" exits silently. If it doesn't, you're outside the labs venv.

STEP 2

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.

terminal
python ../shared_tools/kg_extract_java.py <repo> --out <repo>-graph.json --quiet

Expected shape (real runs):

output โ€” commons-lang (~12s)
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']
output โ€” spring-framework (~2m27s)
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']
๐Ÿ”ง Troubleshooting

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.

STEP 3

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.

โœ… Checkpoint

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.)

2

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.

STEP 4

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.

python
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 1192
โœ… Checkpoint

1192 / 1192. The instrument measures what it claims to measure.

STEP 5

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.

โœ… Checkpoint

You verified real edges by hand. Every number in Phase 4 now rests on an instrument you tested, twice, two different ways.

3

Serve

STEP 6

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.

terminal
# 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.json
STEP 7

Ask 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.

โš ๏ธ Remember M11

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.

โœ… Checkpoint

One-call answer on a real symbol; graceful miss on a fake one.

4

Measure

STEP 8

Run the harness on every target

terminal
python solution/measure_tokens.py <repo> <graph.json> --questions 8 --json bench.json

Real Spring output (deterministic per repo snapshot โ€” yours will match closely):

output โ€” spring-framework, ~15s
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.7x
โœ… Checkpoint

Your aggregate line exists, with the honesty notes printed under it. Save each --json file โ€” Phase 5 compares them.

STEP 9

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.

5

The Honest Writeup

STEPS 10โ€“12

Explain your own numbers in NOTES.md

Answer three questions, in writing, with your data:

  1. 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.
  2. 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.
  3. 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.
โœ… Checkpoint

Someone who has never seen this course could read your NOTES.md and NOT be misled by your own benchmark. That is the bar.

STEP 13

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.)

6

Live Measurement (Optional)

STEP 14โ€“15 ยท OPTIONAL, COSTS API TOKENS

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):

RepoFilesNodes / EdgesFiles opened per questionAggregate per-question
synthetic-mini91โ€”128.3x (87.9%)
commons-lang62410,648 / 40,30946116.5x (99.1%)
synthetic1,4917,008 / 5,96015413.4x (92.6%)
spring-framework9,195110,885 / 432,74156733.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.

QuestionBaselineInit (CLAUDE.mds)Graph-availableGraph-forced
who calls registerBeanDefinition116K ยท 3t81K ยท 2t154K ยท 4t42K ยท 1t
who calls ResolvableType.forClass125K ยท 3t197K ยท 4t415K ยท 10t136K ยท 3t
why BeanDefinitionOverrideException (logic)231K ยท 6t203K ยท 5t276K ยท 8t524K ยท 12t
refresh() + late bean definitions (logic)114K ยท 3t118K ยท 4t113K ยท 3t234K ยท 8t
Structural totals240K278K (+16%)570K (+137%)178K (−26%)
Logic totals345K321K (−7%)389K (+13%)758K (+120%)
All four questions585K600K (+2%)959K936K

Read the three arms as one story:

  1. 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 forClass that made graph-available WORSE than having no graph at all (10 turns, 415K).
  2. Fan-out is the graph's poison. Bare-name resolution merges overloads into 1,000+-caller answers; even forced, forClass only tied the baseline, with wild spread (45K–698K). A type-resolved graph with compact answers is what earns both trust and savings.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
⚠️ The bug that almost shipped a wrong conclusion

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 Three Quantities, final form

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.

ToolsRead, Glob, Grep
Prompt extrasnone (question + effort cap only)
Repo stateclean — zero CLAUDE.md files (the runner records the count per run and warns if nonzero)
Runpython run_live_bench.py <repo> --arms baseline --reps 3 — FIRST, before Scenario 2 ever touches the repo
Measuredstructural 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.

Toolsidentical to baseline — the ONLY variable is the repo state
Repo statea generated CLAUDE.md in every spring-* module (22 files, ~$3.40 one-time)
Runpython init_claude_mds.py <repo> --prefix spring-run_live_bench.py <repo> --arms init --reps 3init_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
Measuredstructural 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.

Toolsfile 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”
Runpython run_live_bench.py <repo> --arms available --reps 3 --mcp-config mcp.json
Measuredstructural 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.

Toolsgraph_callers / graph_callees / explore — nothing else
Prompt extras“They are your ONLY tools… note honestly if AMBIGUOUS provenance makes parts uncertain”
Runpython run_live_bench.py <repo> --arms forced --reps 3 --mcp-config mcp.json (add --forced-logic for the “why” questions)
Measuredstructural 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:

QuestionBaselineInit (root CLAUDE.md)Graph-availableGraph-forced
who calls setStatus160K · 4t118K · 3t228K · 7t188K · 6t
who calls findByMemberNumber74K · 2t77K · 2t149K · 4t149K · 4t
when does MANUAL_REVIEW happen (logic)196K · 7t120K · 3t195K · 7t468K · 17t
eligibility exception semantics (logic)199K · 7t250K · 9t283K · 10t352K · 18t
All four629K565K (−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.)

🧭 No scenario wins everywhere — that IS the finding

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

ScriptWhat it does
run_live_bench.pyRuns 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.pyPrepares the init arm: writes a CLAUDE.md into every module subfolder headlessly (~$0.05–0.15/module); --remove cleans them all back out.
aggregate.pyBuilds the median (min–max) table from the run files, with per-kind and per-arm totals, and flags errors and contamination.
questions.jsonThe question set โ€” edit it for your repo; keep a structural/logic split so the router lesson stays measurable.
mcp-config.template.jsonPoint 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 runs

Methodology rules (each one bought with a mistake)

  1. Fresh sessions, medians of ≥3. Identical sessions varied up to 2x in our runs; a single run is a coin flip. Every claude -p call is a fresh session โ€” repetition is the whole methodology.
  2. 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).
  3. 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.
  4. Same model everywhere, one variable per arm. The runner pins the model; the only thing that changes between arms is tools/context.
  5. Windows gotcha: prompts must be single-line โ€” the .cmd shim truncates argv at embedded newlines (the runner handles this; keep it in mind if you edit it).
  6. 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

#TypeInputExpected
1happygraph_callers("registerBeanDefinition") on the Spring graphNon-empty caller list, exactly one tool call
2happySynthetic graph_callers("lookup")Exactly the seeded count from known_truth.json (1,192 on the full synthetic)
3happyHarness on any targetTable + aggregate + honesty notes render; --json file written
4edgegraph_callers("notASymbol")Graceful error: ... try grep โ€” never a crash
5edge--symbols toStringRow marked unresolvable โ€” the graph declines to guess

Going Further OPTIONAL

  • Wire the freshness gate: point your Capstone-1 freshness_gate.py at 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/implements queries (graph_api-style) โ€” the edges are already in graph.json.
  • Stress the baseline: add a --noise flag to gen_java_repo.py (dead code, comments naming symbols) and measure how the exploration arm degrades while the graph arm doesn't.

Knowledge Check

1. Your commons-lang run shows a 116.5x aggregate. A colleague concludes the team's Claude bill will drop ~100x after installing a graph tool. What's the correct correction?
They're right โ€” the harness is deterministic, so the number transfers directly.
That's a per-question multiple for structural lookups; session-level savings (which bills follow) replicate at 6.8xโ€“49x because sessions mostly spend tokens on reasoning and edits a graph can't remove.
The number is fake because the baseline is simulated.
Bills depend only on repo size, so commons-lang is too small to say.
2. Why does the extractor emit NO edges for a call to a name with more than 8 definitions (AMBIG_CAP)?
To keep graph.json under a file-size limit.
Because such calls are always to JDK methods, which are out of scope.
A call to a name defined 150 times carries no structural signal โ€” emitting every candidate is quadratic noise, not honesty; real tools escalate to type resolution (the M03 LSP ladder) instead of guessing harder.
Java forbids more than 8 overloads per name, so it indicates a parse error.
3. Commons-lang (624 files) shows a HIGHER per-question multiple than Spring (9,195 files). Why isn't this a contradiction of "savings scale with the haystack"?
It is a contradiction โ€” the harness has a bug on large repos.
The exploration burden (files/question: 46 vs 567) DOES scale with the haystack; the multiple also divides by answer size, and Spring's answers are bigger โ€” more callers and AMBIGUOUS candidates raise the graph arm's cost.
Spring's files are smaller, so exploring them is cheap.
Commons-lang was measured with more questions.
4. The harness selects symbols by caller count, never by grep expense. What benchmark sin does this prevent?
Cherry-picking small files to make the baseline arm cheap.
Manufacturing a ceiling case โ€” choosing the questions where exploration happens to be most expensive, then reporting that best case as typical (the same pattern behind the 71.5x headline M04 dissected).
Measuring symbols that don't exist in the graph.
Running the same question twice and double-counting it.
5. What did Phase 2's check of Registry.lookup (1,192 / 1,192 seeded callers found) actually establish?
That the graph will be equally complete on Spring.
That the measuring instrument was verified against known ground truth before its numbers were trusted โ€” the step benchmark readers (and most benchmark authors) skip.
That the synthetic repo compiles.
That AMBIG_CAP is set correctly.
6. You re-extract the Spring graph after a big refactor, but agent answers don't change. Most likely cause?
tree-sitter cached the old AST.
The MCP server loads graph.json at startup and never re-reads it โ€” fresh on disk, stale in memory. Restart the server (M11's startup-cache trap).
The refactor didn't change any call edges.
claude mcp add must be re-run after every extraction.

Summary

One schema, two languageskg_extract_java mirrors the Python extractor โ€” so the M01 queries, M09 server, and M11 gate all work unchanged on Java.
Verify before you trust1,192/1,192 seeded callers on the synthetic; hand-checked edges on the real repo. Instrument first, numbers second.
The measured claimSpring: 8 questions = 4,535 files โ‰ˆ 15.2M tokens by exploration vs โ‰ˆ451K via graph โ€” 97.0%, 33.7x, reproduced on your machine in ~15s.
Per-question โ‰  per-sessionYour table is okf-rs-class (~400x/query territory); bills follow session-level numbers (6.8xโ€“49x). Say which one you're quoting.
Refusal is a featureAMBIG_CAP drops no-signal names; the server misses gracefully; the harness prints its own caveats. Honest tools name their limits.
The burden curve12 โ†’ 46 โ†’ 154 โ†’ 567 files per question as repos grow 91 โ†’ 9,195 files. The haystack is the story.

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 --graph flag
  • 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)