Accuracy benchmark
The graph is only worth trusting if its edges are right. This page reports what the edges actually do on real projects, including the parts that do not work yet.
Headline
Across 83 labelled cases on four pinned projects: 0 edges invented. Every edge the graph claimed was correct. All 69 expected edges were produced, and all 14 negative controls stayed absent.
1.000
0 false positives. No confidently wrong edge was observed in 83 labelled cases.
1.000
All 69 expected edges were produced, across all four edge kinds and all three languages.
1.000 / 1.000
Call resolution and virtual dispatch are perfect on the sampled labels.
Method
Four real projects, pinned by commit, crawled in memory and scored against expectations written from their source.
| Project | Stack | Commit | Files | Nodes | Edges | Crawl |
|---|---|---|---|---|---|---|
| spring-petclinic | Java · Spring Boot | 500158f73241 |
50 | 1,244 | 2,660 | 652 ms |
| spring-boot-realworld | Java · Spring Boot + MyBatis | ee17e31aafe7 |
116 | 1,978 | 5,057 | 978 ms |
| fastapi-template | Python · FastAPI | e4022a9502a6 |
138 | 1,798 | 3,514 | 1,019 ms |
| nestjs-realworld | TypeScript · NestJS + TypeORM | c1c2cc4e448b |
36 | 466 | 865 | 162 ms |
@RequestMapping joined to a
bare @GetMapping), but a runtime call log is stronger evidence.
Virtual dispatch is the one edge type with execution-backed proof, on a small compiled
fixture rather than on these projects — see
runtime proof. The precision and recall numbers on this page
are hand-verified, not runtime-verified.
main. Upstream
main and tag 0.12.0 both contain invalid Python at
backend/app/api/deps.py:36 (except InvalidTokenError,
ValidationError:, Python 2 syntax), and a fixture that cannot run is not a fair
benchmark. At 0.9.0 the same line is correct.
By edge type
Cases include negative cases, so precision is not free.
| Edge kind | Cases | Precision | Recall | TP | FP | FN | TN |
|---|---|---|---|---|---|---|---|
CALLS |
16 | 1.000 | 1.000 | 11 | 0 | 0 | 5 |
DYNAMIC_DISPATCH |
7 | 1.000 | 1.000 | 3 | 0 | 0 | 4 |
INJECTS |
20 | 1.000 | 1.000 | 15 | 0 | 0 | 5 |
ROUTES |
40 | 1.000 | 1.000 | 40 | 0 | 0 | 0 |
TP = expected edge present and correct. FP = graph asserted something that is not true (an invented edge, or a route with the wrong verb or path). FN = expected edge missing. TN = expected absence correctly not asserted, which is how over-approximation gets caught — a self-loop on virtual dispatch, or a dispatch edge to the wrong interface, fails here.
By language and project
All three languages now score 1.000 on the sampled labels.
| Language | Cases | Precision | Recall | TP | FP | FN | TN |
|---|---|---|---|---|---|---|---|
| java | 44 | 1.000 | 1.000 | 36 | 0 | 0 | 8 |
| python | 20 | 1.000 | 1.000 | 17 | 0 | 0 | 3 |
| typescript | 19 | 1.000 | 1.000 | 16 | 0 | 0 | 3 |
| Project | Cases | Precision | Recall | TP | FP | FN | TN |
|---|---|---|---|---|---|---|---|
| spring-petclinic | 27 | 1.000 | 1.000 | 22 | 0 | 0 | 5 |
| spring-boot-realworld | 17 | 1.000 | 1.000 | 14 | 0 | 0 | 3 |
| fastapi-template | 20 | 1.000 | 1.000 | 17 | 0 | 0 | 3 |
| nestjs-realworld | 19 | 1.000 | 1.000 | 16 | 0 | 0 | 3 |
Confidence distribution
Only three of the thirteen edge kinds carry a confidence value at all.
| Project | Edge kind | Confidence buckets |
|---|---|---|
| spring-petclinic | CALLS |
0.00: 1,118 · 0.86: 5 · 0.90: 137 · 0.95: 87 · 1.00: 79 |
| spring-petclinic | INJECTS |
no confidence recorded: 23 |
| spring-boot-realworld | CALLS |
0.00: 1,779 · 0.86: 1 · 0.90: 107 · 0.95: 338 · 1.00: 169 |
| spring-boot-realworld | DYNAMIC_DISPATCH |
0.80: 19 |
| fastapi-template | CALLS |
0.00: 644 · 0.80: 259 · 0.85: 230 · 0.90: 112 · 1.00: 4 |
| fastapi-template | ROUTES |
no confidence recorded: 23 |
| nestjs-realworld | CALLS |
0.00: 97 · 0.80: 42 · 0.90: 35 |
| nestjs-realworld | INJECTS / ROUTES |
no confidence recorded: 21 / 21 |
A 0.00 bucket is not a low-confidence guess: it is the unresolved marker
the crawler writes when it cannot prove a receiver, such as
__unknown__:this.repository.save. Filtering it out is the honest way to
read a call graph.
Runtime proof: dispatch edges against a real call log
One edge type is checked by running code instead of reading it.
DYNAMIC_DISPATCH is the edge type where static reading is most likely to
be wrong: the call site names an interface method, and the target is whichever
implementation happens to be live at that moment. So it is verified against execution.
A fixture defines an interface Shape with three implementations
(Circle, Square, Triangle); Main
builds a Circle and a Square, never a Triangle,
and drives all of them through the interface. Every implementation prints a line when
its method actually runs, so the log is a real call log rather than a second reading of
the source.
npm run bench:runtime compiles the fixture with javac, runs
it, crawls the fixture directory, and reconciles the two.
| Source | Target | Confidence | Proven by execution |
|---|---|---|---|
fixture.Shape#area |
fixture.Circle#area |
0.80 | yes |
fixture.Shape#area |
fixture.Square#area |
0.80 | yes |
fixture.Shape#area |
fixture.Triangle#area |
0.80 | no — never constructed |
Triangle really does implement Shape, so the edge
becomes true the moment any code constructs a Triangle; this particular run
simply never did. Counting it as an error would understate the graph, and counting it as
proven would overstate it, so it is reported separately as unexercised. That
over-approximation is exactly why DYNAMIC_DISPATCH carries a flat 0.80
rather than a near-1.00 confidence: the edge set is deliberately a superset, and a
single-implementation interface gives that superset nothing to add while an interface
with many implementations gives it plenty.
Where Code-Crawler is weak
Reproduced from the benchmark failures, in order of how much they cost you.
1. Framework wiring is modelled one edge kind at a time, and most of it is left
The benchmark covers four edge kinds. Of the wiring that breaks static-only graphs
most badly, only event and messaging publication is modelled so far
(EMITS: Spring @EventListener and
@TransactionalEventListener, Kafka, JMS, NestJS @OnEvent,
with a confidence rule per channel kind). Still producing no edge at all: Pub/Sub and
RabbitMQ, proxy boundaries from @Transactional, @Async and
@Cacheable, Spring Data repository derivation, @Bean factory
methods, @Qualifier/@Primary disambiguation, and
@Scheduled/@Async re-entry. EMITS is also not
among the 69 labelled cases, and the four benchmark projects turn out to contain no
event or messaging wiring at all (0 EMITS edges across all four), so it is
verified by fixture tests only rather than measured. Adding a fifth project with
published events, and the remaining wiring, is the next step: one edge kind at a time,
with a fixture, positive and negative tests, a confidence rule and a doc entry each.
2. INJECTS and ROUTES have no confidence model
Both edge kinds are written without a confidence value, so a consumer cannot tell a
proven mapping from a derived guess. The project rule is that no edge type exists
without a confidence model; these two are the standing exceptions. Recall has improved
enough that this now matters more, not less: a Spring
@GetMapping on a bean and a Lombok-derived constructor parameter are
different strengths of evidence, and the graph reports both with no confidence at all.
Proxy boundaries took the other route. @Transactional,
@Async and @Cacheable facts are categorical rather than
scored, because proxyBlocked can name the exact reason a container could
never honour an annotation (private, static,
final, final-type) and inert marks a
self-invocation that bypassed the proxy. That precision is not measured here either:
none of the four projects below contains a proxied self-invocation, so proxy facts are
fixture-verified only.
3. Dispatch is matched by name and arity, not by proof
A virtual dispatch edge carries a flat 0.80 and is emitted to every in-project
subtype with a same-named, same-arity method. It is correct on the sampled labels,
but that is partly because spring-boot-realworld's ArticleRepository has
exactly one implementation. With several implementations, or overloads differing
only by generics, the constant would overstate certainty. There is also no dispatch
for lambdas or method references.
4. An ambiguous overload is flagged, not resolved
When a call site matches several overloads equally, the edge keeps the first
declaration as its target and lists the rivals in alternatives at
confidence 0.6. That is a rank, not a resolution: the graph still does not know
which overload ran, and a reader who ignores the field gets the same confident
wrong answer as before. Rivals are only detected inside one declaration list, so
an inherited overload tie goes unreported, and none of the four benchmark projects
contains an ambiguous overload - the behaviour is covered by fixtures only.
5. Foreign calls are unresolved by design
Calls into framework or library types resolve to an ext: node or to an
__unknown__ marker, for example
Repository<ArticleEntity>.save in the NestJS project. This is the
correct trade — the tool does not have TypeORM's bytecode — but it means the graph
stops at the framework boundary. Event and messaging wiring that crosses that
boundary is only modelled when both the publisher's call and the listener's
annotation are in index; proxy wiring still is not.
6. A perfect table on 69 edges is not a proof of the graph
Precision and recall of 1.000 here mean the graph answered all 69 sampled questions
correctly. The four graphs contain roughly 12,000 edges, and the labelled cases were
chosen to probe specific idioms rather than to sample uniformly: routes, injections,
calls and dispatch on four projects. Whole categories — reflection chains,
REFERENCES, EXTENDS/IMPLEMENTS hierarchies —
carry no labelled cases, so a regression in them would not fail the benchmark. The
floors in the manifest are a floor on the sample, not a certificate for the tool.
7. change_impact's ranking is untested against reviewers
change_impact ranks changed declarations with a hand-picked score:
risk band, then routes, entry points, injection points, advice, event flows, then
raw caller count. No benchmark measures whether that order matches what a reviewer
would look at first, so treat the order as a convenience and read every kept entry.
What is tested is the mapping, not the ranking: diff hunks onto declarations by
line span, a member collapsing into its enclosing type, repo-relative paths
resolving against crawl-root-relative ones, and unmatched surviving
instead of being dropped. The tool adds no edge kind and no schema field, so its
accuracy is exactly the accuracy of the edges underneath it.
What the benchmark already found
Writing the first fixtures exposed a parser crash that silently deleted files. A
lookup into a method table was resolving identifiers through Object.prototype,
so any file containing a call to toString(), valueOf() or
hasOwnProperty() threw during parsing. The crawler records parse
failures per file, so those files vanished from the graph with only a log line.
On spring-petclinic this had hidden 7 of 50 files, including the
Owner JPA entity: files parsed went from 43 to 50,
CALLS edges from 574 to 1,426, and total edges from 1,408 to 2,637.
The bug is fixed and covered by regression tests.
The Spring route cases failed on a missing feature rather than a bug. The framework
descriptor declared the mapping annotations as entry points, but the Java parser never
produced route registrations, so no controller could yield a ROUTES edge
at all.
Spring mappings are now extracted: a class-level @RequestMapping prefix
is joined with each verb annotation, covering the array form
@GetMapping({"/vets"}), a named path = attribute, a bare
annotation that inherits the class path, paths without a leading slash, and
method = RequestMethod.PUT on the prefix-less form. Spring route recall
went from 0.250 to 0.778 on its own; with the FastAPI router prefixes below,
ROUTES recall reached 1.000, with no invented edge.
Injection failed the same way: the parser only recorded a parameter when an annotation
asked for it. Spring has not needed one since 4.3 — the sole constructor of a bean is
autowired silently — and a Lombok @AllArgsConstructor leaves the
constructor out of the source entirely, so there was nothing to annotate in the first
place.
Bean stereotypes now mark a class as container-managed, and its dependencies follow
from that: the sole constructor's parameters when there is exactly one, the promoted
fields when a Lombok annotation generates the constructor. The rules stay conservative
in the ways that matter. Two unannotated constructors inject nothing, because Spring
cannot choose between them either. A field initialised at its declaration is skipped.
A class with no stereotype is skipped, since no container constructs it. A class that
writes its own constructor alongside a Lombok generator injects nothing, because that
is two constructors and the same ambiguity applies. Trusting a generator's field list
blindly would have produced 0.95-confidence edges to dependencies the code never
receives. Spring constructor injection alone took INJECTS recall from
0.133 to 0.667 and Java recall from 0.778 to 1.000, with precision held at 1.000 and
no invented edge. Every remaining miss was FastAPI.
FastAPI failed for two independent reasons, and the benchmark had to be read carefully
to separate them. First, APIRouter(prefix=...) was ignored, so every route
from a prefixed router was emitted with the wrong path. Second, dependencies declared
once as a module-level alias
(SessionDep = Annotated[Session, Depends(get_db)]) produced no edge when a
handler named the alias in its signature — and neither did a dependency reached only
through another dependency function.
Both are now extracted, and writing the cases forced two corrections that were not
obvious from the code. An injection belongs to the callable the framework
populates, not to the module that declares it, so the edge now starts at
read_items rather than at module:...items. And the dependency
is the provider the framework calls, not the type it returns:
Depends(get_service) is an edge to get_service, not to
OrderService. Retargeting to the provider is strictly more accurate — the
returned object is produced by that callable at run time — and it changed no property
name or edge kind. INJECTS recall went from 0.667 to 1.000 and Python
recall from 0.588 to 1.000, with precision held at 1.000.
The work also exposed an incremental-crawl bug in the same area: dependency aliases
live in a Map, the parse cache round-trips through JSON, and
JSON.stringify turns a Map into {}. Replayed
files therefore carried no aliases, and — because an unchanged file's edges were never
rebuilt when a different file changed — an edited alias left a stale, confidently wrong
injection edge pointing at the previous provider. Alias maps are now packed explicitly,
injection edges are rebuilt project-wide whenever anything changes (as
DYNAMIC_DISPATCH and ADVISES already were), and both
behaviours are covered by tests that crawl twice and then edit a Depends(...)
target.
Agent cost: graph versus grep-and-read
Accuracy only matters if the answer arrives inside a context window. Ten structural questions, measured on both paths.
| Task | Project | Graph calls | Graph tokens | Compact tokens | Grep calls | Grep tokens |
|---|---|---|---|---|---|---|
Who calls OwnerRepository.findAll? |
spring-petclinic | 1 | 698 | 188 | 8 | 7,698 |
What runs on GET /owners/{ownerId}? |
spring-petclinic | 2 | 4,435 | 1,486 | 7 | 12,616 |
What breaks if I change the Pet entity? |
spring-petclinic | 1 | 6,002 | 2,448 | 13 | 18,230 |
| Which package-level declarations are dead? | spring-petclinic | 1 | 2,245 | 826 | 30 | 18,328 |
Who calls User.getId? |
spring-boot-realworld | 1 | 592 | 113 | 21 | 21,705 |
What does ArticleQueryService.findById reach? |
spring-boot-realworld | 1 | 1,832 | 840 | 21 | 14,506 |
Who depends on get_current_user? |
fastapi-template | 1 | 2,362 | 814 | 2 | 491 |
What runs on POST /items? |
fastapi-template | 2 | 4,058 | 1,475 | 7 | 5,903 |
Who calls ArticleService.create? |
nestjs-realworld | 1 | 214 | 109 | 4 | 3,161 |
What breaks if I change ArticleEntity? |
nestjs-realworld | 1 | 469 | 254 | 7 | 3,120 |
| Total | 4 projects | 12 | 22,907 | 8,553 | 120 | 105,758 |
The graph answered all ten with a payload that is the answer, in 12 tool calls instead
of 120, reading 78% fewer tokens than grep-and-read and 92% fewer with
compact: true. One row goes the other way: on FastAPI,
Depends(get_current_user) appears in a single file, so two searches beat a
query — and the graph still wins on calls.
Method. Every graph number is the real MCP server running in-process over the pinned projects, counting the exact JSON text each tool returns. The baseline models an agent with only text search: one search per query, then opening every file the search hit, because the matching lines do not say which enclosing method matched or who dispatches to it. For the dead-code task it searches each declaration named in the answer set one at a time — the answer set is handed over for free, so the baseline is under-charged. Reads are capped at 20 files, which under-charges again: two tasks had 38 and 32 matching files. Both sides use the same four-characters-per-token heuristic the server uses for its own budget, and the checkout prefix is stripped from payloads so a CI runner measures the same graph the same way.
welcome?" has no graph answer at all — the graph indexes symbols, not
template text, so search_symbols returns same-named symbols and grep
answers in one call for 340 tokens. "Where is the owner search SQL defined?" is a
one-call lookup either way, and grep is competitive. If the question is about text —
a literal, a config key, a template — search is the right tool.
Reproduce it
One command. No services, no credentials, no database server.
git clone https://github.com/nithin554/code-crawler
cd code-crawler
npm ci
npm run bench:accuracy
npm run bench:runtime
npm run bench:ergonomics
The runner clones each project into a gitignored benchmarks/.cache/ at the
commit in benchmarks/manifest.json, crawls it into an in-memory SQLite
database, scores the labelled cases and writes
benchmarks/reports/accuracy.md and accuracy.json. It exits
non-zero when precision or recall drops below the floors in the manifest, and CI runs
it on every pull request, so an accuracy regression fails the build.
bench:runtime is the separate dispatch proof described above: it needs a
JDK 17 or newer (javac on PATH) and writes
benchmarks/reports/runtime.md. CI installs Temurin 21 for it.
bench:ergonomics re-measures the token and tool-call comparison above
against the same four cached projects and writes
benchmarks/reports/ergonomics.md; it clones nothing, so run
bench:accuracy once first.