Search Brigade

Search Brigade pages and docs.

[ POST ]

Applying CocoIndex to GraphTrail: re-parse one file, not 16,700

Published July 17, 2026 · Escoffier Labs · Part of the Brigade fleet

Star Brigade 60 GraphTrail 5 cocoindex 10.9k

GraphTrail’s sync carried a number I knew was wrong: change one file in a 16,700-file TypeScript repo and the re-sync took 2 minutes 52 seconds. Parsing the changed file took milliseconds. The rest went to re-parsing 16,699 files that had not changed, because cross-file call resolution was computed in memory and thrown away, so a single content change had to rebuild every present file to keep edges from going stale. The whole extracted graph sat in RAM until one final transaction, 397 MB peak.

Around the same time I was reading CocoIndex, an Apache-2.0 Rust-core framework built on one stance: the index is a materialized view of the source, TargetState = Transform(SourceState), and the engine’s whole job is to keep that view converged by reprocessing only what changed. Their incremental processing writeup reads like a checklist of everything GraphTrail was doing by hand or not doing at all.

Same deal as the ActiveGraph post: the ideas are theirs, the changes are mine, and no code moved between projects. GraphTrail is a single-purpose Rust sidecar with one SQLite file per repo. Adopting a framework, its state store, and its chunker to avoid re-parsing files would be a strange trade. Three ideas transferred as design.

The skip check learns about the extractor

GraphTrail’s incremental sync skipped a file when its content hash matched the stored one. That is correct right up until the extractor changes, and then the skip check serves rows produced by code that no longer exists. I had already paid for this the hard way: the schema v3 migration (per-symbol body hashes) needed a hand-rolled forced-full-reindex because there was no way to say “the content is the same but the transform is not.”

CocoIndex solves it structurally. Their memo key combines a fingerprint of the input with a fingerprint of the transformation code, and an explicit version= bump covers changes the code hash cannot see, like a prompt file or an external model. Unchanged input plus unchanged logic equals cache hit. Either one changing invalidates exactly the affected rows.

GraphTrail’s version of that is schema v4: every language extractor declares an EXTRACTOR_FINGERPRINT constant, the fingerprint is recorded per file row, and the skip check compares both hashes. Bump rust-v1 to rust-v2 and only .rs files re-extract, lazily, on the next sync. A compiled Rust binary has no cheap runtime view of its own function bodies, so where CocoIndex hashes the code, GraphTrail makes their manual version= escape hatch mandatory: a constant bump that shows up in a diff and gets reviewed like everything else. The bump rule is one line in AGENTS.md, next to the constants.

Lineage over replay

The idea that fixed the 2m52s number came from a different place in their design. CocoIndex tracks which derived rows came from which source rows, and when a source changes it removes and rebuilds derived data through those tracked relationships instead of re-running old transforms to figure out what they produced. They chose lineage because it stays correct even when the transform is non-deterministic. An LLM call replayed is not the same call.

16,700 source filespending_calls(stored lineage)edges(derived)parse only whatchanged: 1 file, 2.5sresolve: a pure functionof stored rowsschema v6/v7: re-derive everything, re-parse nothingthe graph is a materialized view. lineage is the middle table.
Their pattern, on GraphTrail’s tables. Everything right of the source files derives from stored rows.

GraphTrail’s transform is deterministic, but the same table fixes a different disease. Schema v5 persists each file’s unresolved calls in a pending_calls table, streams extraction one file at a time, and derives the entire edges table from the stored pending calls after any change. A one-file sync now re-parses one file. Resolution in unchanged files picks up definitions added or removed elsewhere, because resolution reads the stored calls, never the source. And peak memory is one file’s graph plus the resolution indexes.

one file changed, before v516,700 files re-parsed · 2m52sone file changed, after1 file re-parsed · 2.5s1 file (< one cell)
What a one-file change costs, before and after schema v5. Each cell stands for roughly 20 files.

Measured on that same 16.7k-file TypeScript repo with identical row counts before and after: full index 19.8s at 397 MB RSS before, 21.4s at 136 MB after. The one-file change went from 2m52s to 2.5s.

graphtrail callees sync_repo: drawn from the live index, edge opacity = confidence
sync_repoguard_unsafe_rootstore/repo_policy.rshas_git_markerensure_graphtrail_ignoredwrite_branch_metaacquire_sync_lockstore/sync.rscollect_sync_walkstore/walk.rsupgrade_for_syncstore/schema.rsindex_fileextractors/mod.rsstale_planstore/persist.rswrite_file_graphpurge_file_graphload_db_filesrebuild_edgesstore/resolve.rs
The sync pipeline, drawn from GraphTrail’s own index: graphtrail callees sync_repo. rebuild_edges derives the edge table from persisted pending calls.

The follow-up paid out one schema version later, and it is the field report I would send upstream. Schema v6 added a confidence score to every call edge, graded by how the call resolved: an import-backed match scores 0.9, a bare same-file call 0.8, an ambiguous name 0.5. Rebuilding every edge with the new scoring re-parsed zero files, because the derivation inputs were already sitting in pending_calls. Their pitch is that lineage makes deletes safe. The surprise was that lineage makes re-derivation a re-resolve, not a re-parse.

876 call edges in graphtrail's own graph, by resolution pathshade = confidence · click to pin
352
493
Every call edge in GraphTrail’s graph of itself, graded by resolution path. Schema v6 rebuilt all 876 from stored pending calls without re-parsing a file. Hover a segment for exact counts; click one to pin it, and the URL remembers the pick.
2.5s
one-file sync, down from 2m52s
136 MB
full-index RSS, down from 397 MB
876
edges re-scored by schema v6
0
files re-parsed to do it

Freshness became a contract

The third borrow is a stance more than a mechanism: staleness is the index’s problem, and the index should answer for it. Their cocoindex-code MCP server takes a refresh_index parameter on search, default true. GraphTrail’s query tools gained the same opt-in refresh: true, default false, because the default build promises no writes an agent did not ask for. The query itself stays on a read-only connection either way, and a failed refresh appends a note instead of blocking the answer.

graphtrail doctor is the other half: schema status, last-sync age, pending change counts, branch drift, and a FRESH, STALE, or NEEDS-MIGRATION verdict wired to exit codes 0, 1, and 2 so a hook can gate on it. And instead of their live watch mode, a Claude Code SessionStart hook runs graphtrail sync wherever an index exists. A no-op sync is sub-second, so the graph is warm before the first query without any daemon at all. The 0.3.0 half of this story, fingerprints and doctor included, is in the release post.

The same memoization pattern landed next door while I was at it: my Code Search service got a content-addressed artifact cache keyed on (content_hash, model, kind), and backfilling it copied 493,641 existing embeddings and summaries into cache in one pass. An index that once cost real LLM money to rebuild is now a cache-hit replay.

Rust to Rust

CocoIndex is a Rust engine with a Python definition layer: you author the pipeline in Python, the incremental machinery underneath is Rust. GraphTrail is Rust end to end, so the binding layer was never the borrow. What carried over was how they let Rust do the jobs Rust is good at, and two of those showed up in their 1.0.x changelog wearing designs I recognized.

One parse, many products. Their new CodeSource shares a single syntax-tree parse across chunking and pattern matching. GraphTrail’s extractors have the same rule: one tree-sitter traversal per file yields symbols, imports, and calls together, behind a per-language LangSpec trait. The parse is the expensive part, the walk is nearly free, and in Rust that one immutable tree feeds all three collectors as a shared borrow: no clone, no copy, no lifetime fight.

State lives inside the engine. Their use_state() gives a pipeline component a typed state slot, and v1 keeps the engine’s internal state in LMDB, an embedded store in one local file, no Redis or Postgres sidecar. pending_calls is the same instinct: the lineage rows live in the one SQLite file next to the data they explain, so there is no second store to keep consistent, and a snapshot of the file is a snapshot of everything.

And one lesson the round taught me about my own Rust. Edge confidence started as a bare f64 on each row. Writing explain forced the admission that the float was hiding an enum: six ways a call can resolve, each with a confidence and a reason. ResolutionPath is now a real type, the scores derive from its variants, and the compiler enforces that every resolution path has a confidence, which a comment used to assert. Every derived table GraphTrail grows from here (rename detection over the v7 ordinals is the obvious next one) follows the recipe this round settled: persist the lineage rows, type the derivation, and let everything downstream be a re-derivation instead of a migration.

One for the Python side

The Python half of their design had a borrow queued too, aimed at Brigade rather than GraphTrail. CocoIndex memoizes a transform on two fingerprints, the input’s content and the function’s own code, and logic_tracking walks the fingerprint through nested @coco.fn calls so editing a helper invalidates its callers. Brigade’s outcome ledger scored a skill by name: brigade-work carries 200 helped and 76 hurt on this repo, and every one of those signals describes whatever the skill’s text said on the day it was captured. Edit the skill and the score quietly keeps vouching for text that no longer exists.

Their memo key, applied to the ratchet, landed while this post was in draft (brigade#218). Capture stamps each outcome with the sha256 of the skill text the run exercised, and rank and explain score the current-fingerprint cohort by default, so an edited skill earns its score back.

Two decisions the review round forced. Records from before fingerprints existed cannot be proven stale, so they grandfather into the current cohort instead of zeroing every score at rollout. And the fingerprint reads the harness-installed copy first, because that is the text a verified run actually exercised when the registry master has drifted ahead.

Their docs carry an honest caveat that undecorated helpers dodge the fingerprint, and the ledger version inherits the same one: a hash sees the skill’s text, not the harness around it. The first fingerprinted record in the ledger is the verify receipt for the commit that built the feature.

What stayed on their side of the fence

Three things I looked at and left. The engine itself: four schema versions of plain SQLite got GraphTrail the reprocess-only-what-changed behavior it needed for one pipeline, and adopting the framework would mean adopting its internal state store to reach for convergence guarantees a code graph never uses. Semantic chunk embeddings: Code Search owns that lane on my machine, and GraphTrail stays the exact-graph lane on purpose. And their MCP shape: cocoindex-code bets on a single search tool and cites a large token saving from it, while GraphTrail bets the other way: one narrow tool per question (callers, impact, diff, doctor, explain, and friends). Both bets cite token economy, so while this post sat in draft I benched them: GraphTrail’s typed tools against cocoindex-code 0.2.37 itself, installed from PyPI and running its default local arctic-embed-xs model.

Same corpus (GraphTrail’s own repo, 84 files, 1,061 call edges), same 12 agent questions, one surface at a time, success meaning every expected string shows up in the tool’s reply. The typed tools answered 11 of 12, including all 5 structural questions. The single-search shape answered 4 of 12 and 1 of 5 structural, because ranked chunks near the query text cannot say what calls sync_repo. But the search surface won a bet I did not expect it to win this cleanly: every one of its replies came back near 1,900 tokens, while impact on that same hot symbol spent about 9,150 in a single unbounded call and callers about 7,460. Across the whole set the totals nearly tied, 22,553 tokens against 23,545. The difference was the tail, and the tail was all on my side.

So the borrow was the lever, not the shape. callers, callees, and impact now take an optional limit that truncates the edge list and marks the cut (“results limited to 5 of 79 edges”), merged as graphtrail#34. Omitting limit returns the prior output byte for byte. The single-tool bet itself stayed on their side of the fence.

11/12
answers, 14 typed tools (structural 5/5)
4/12
answers, single search tool (structural 1/5)
9,150
tokens from one unbounded impact call
5 of 79
edges after the new optional limit, cut marked

The twelfth task is the reason the bench file stays in the repo. “Where are pending calls persisted” was the one question both surfaces missed, and the miss was a bug, not a hard question: fts_query quoted each task string as a single FTS token, so any query carrying a path fragment like store/persist.rs matched nothing at all. The fix splits query terms on path separators and drops sub-2-character noise tokens (commit ae7d578, same day).

A second bench measured what that had been costing. The co-change round replays 12 historical multi-file commits per repo across a Go, a TypeScript, and a Rust codebase: seed GraphTrail’s personalized context pack with the commit’s largest-diff file at the parent revision, then score how much of the rest of the commit it recovers, against an editor repo-map ranking at a matched token budget. Before the fix, path-seeded packs matched nothing and fell back to tiny unpersonalized output, 646 characters on the Go repo against a 4,000-character budget. After it, recall went 0.239 to 0.692 on Go and 0.200 to 0.504 on Rust, both clear of the repo-map baseline. TypeScript stayed at zero for both surfaces, and honestly so: that repo’s co-changes are route-page siblings tied by imports and shared data files, not call edges, so neither a call graph nor a repo map recovers them. The data names the next experiment itself: blend import edges into the personalization seeds.

Co-change recall: share of a historical commit's other files each surface recovers12 cases per repo, seeded from the largest-diff file
Go repo · baseline
0.269
Go repo · before
0.239
Go repo · after
0.692
Rust repo · baseline
0.236
Rust repo · before
0.2
Rust repo · after
0.504
TS repo · baseline
0.043
TS repo · before
0
TS repo · after
0
repo-map baseline (1,024-token map)GraphTrail context, before FTS fixGraphTrail context, after FTS fix
Recall of co-changed files, from benchmarks/context-cochange/results.json. The TypeScript rows staying at zero is the finding, not a rendering gap: those co-changes ride imports, not calls.

The next round rode the same lineage tables: graphtrail evaluate dry-runs an extraction without touching the database, and graphtrail explain traces any stored edge back to the resolution path that produced it. That round is itself a structural diff, and the tool it changes can draw it. Two synced snapshots, the feature branch against master, through graphtrail diff --json:

graphtrail: the follow-up branch vs master
+65 / -2 / ~20 symbols
79 edges in (484 counting line moves)
10 edges out (415 counting line moves)
src/evaluate.rsEvaluateReportEvaluateTotalsEvaluatedCallEvaluatedFileEvaluatedImportEvaluatedSymbolevaluate_pathevaluatedteststests.evaluate_extracts_a_single_file_without_a_databasetests.evaluate_rejects_unsupported_single_filestests.evaluate_walks_a_directory_with_sync_rules
src/lib.rsevaluatewatch
src/query/export.rsExportFormatExportScopeFileEdgeFileNodeSymbolEdgeSymbolNodedot_escapeexport_graphfile_dotfile_graphfile_graphmljsonlsymbol_dotsymbol_graphsymbol_graphmlxml_escape
src/extractors/common.rssymbol_id_at_byteSymbolStatesymbol_idvisit
src/store/resolve.rsscoredSymbolCandidateload_file_indexload_import_indexload_name_indexload_symbol_id_indexresolve_callresolve_imported_call
src/cli.rsCommand
src/mcp.rsToolIdbuild_tool_specscall_toolteststests.tool_specs_are_unique_complete_and_refresh_consistentvalidate_tool_args
src/store/schema.rsupgrade_for_sync
tests/incremental.rssync_disambiguates_same_named_javascript_functions_on_one_line
tests/mcp.rstools_list_exposes_the_query_tools_with_location_args
35 more changed symbols not shown. Node identity is line-independent since schema v7, so none of this is line-shift noise.
65 symbols in, 2 out, 20 changed. The struck-through pair is the old line-baked id machinery going away. Press replay.

The figures in this post came out of the tool itself: graphtrail callees sync_repo --json, one SQL query over the confidence column, and graphtrail diff --json between two synced snapshots, rendered straight to the page. 60 files, 565 symbols, 876 edges at the snapshot those figures froze. The bench above ran later against the same repo at 84 files and 1,061 edges, because the tool kept growing under its own post. GraphTrail’s own graph, drawn by GraphTrail.

Open source · MIT licensed

escoffier-labs/brigade
60

Your agents run loops. Brigade keeps the receipts. Local control plane: share MCP, tools, and memory across harnesses; prove with file receipts; improve only from real exit codes. No daemon, no lock-in.

agent-handoffsagent-memoryagentpantryagents-md
Python4MITUpdated 2m ago

The tool this post is about · MIT

escoffier-labs/graphtrail
5

Your agents grep. GraphTrail shows who calls whom. Local SQLite code graph: callers, callees, impact, and context over CLI + read-only MCP. No daemon, no network.

The engine these ideas came from · Apache-2.0

cocoindex-io/cocoindex
10.9k

Incremental engine for long horizon agents 🌟 Star if you like it!

Try Brigade

One reviewed source for the memory, tools, and MCP your AI coding agents share, merged into each tool's native config with a review gate and a receipt for every change. Local files, no daemon, no lock-in.

Star on GitHub 60

Quickstart · All posts