feat(core): measure retrieval, then fix it (BM25F + eval harness) - #75
Merged
Conversation
The ranking behind `search` and `find_captures` lived inside mcp-server.mjs, which parses argv and can call process.exit() at module load. That made it unimportable, and an unimportable scorer is an unmeasurable one — every suite in the repo tests the cascade's mechanism and none of them can say whether an agent would find the right concept in the first place. Move scoring, tokenization and snippeting into search.mjs as pure functions over an injected layer array. Behavior is unchanged: same occurrence counting, same field weights, same tie-breaks, same capture recency half-life. mcp-server.mjs keeps telemetry emission, since that is a server concern rather than a ranking one. This is the prerequisite for scoring the ranker against a golden question set; the scorer itself is not touched here. Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds a golden question set over a committed three-layer corpus, scored
against search.mjs: recall@1, recall@5, MRR, and whether resolving the
answer still surfaces the layers that disagree.
The first run is not flattering. On 38 questions written the way someone
would actually type them:
recall@1 0.263
recall@5 0.500
mrr 0.348
conflict 1.000
Half the questions never surface the right document at all, and
decisions/service-stack wins almost everything regardless of what was
asked. Three causes, which the eval separates:
- Substring rather than token matching. The query word "I" matches the
"i" inside identifier, in, and with, so any natural question hands
the longest document a large score for free.
- No inverse document frequency: a word in every document counts as
much as a word in one.
- No length normalization: repetition beats precision.
conflict is already 1.000, which is the honest read on the engine. The
cascade and the per-section conflict surfacing work. Retrieval is the
broken half, and it was broken badly enough to hide the working half.
The runner is wired into `npm test`, so a ranking change that loses
recall fails the build rather than being discovered by a user. Accepting
a new number requires --record --label "<why>", and superseded numbers
stay in baseline.json's history so a deliberate trade stays legible.
No scorer changes here — this commit only establishes the measurement.
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Replaces substring occurrence counting with BM25F, and the ad-hoc query
tokenizer with a Porter stemmer.
recall@1 0.263 -> 0.895
recall@5 0.500 -> 1.000
mrr 0.348 -> 0.947
conflict 1.000 -> 1.000
Measured, not asserted: `npm run eval`.
Three changes, each addressing one cause the eval isolated:
- Match tokens, not substrings. This is the largest single effect. The
old scorer looked for the query word "I" with indexOf and found the
"i" in identifier, in, and with, so the longest document won almost
every natural-language question.
- Weight by inverse document frequency, using the log(1 + ...) form so
a term common across the corpus decays toward zero instead of going
negative and actively demoting the documents that contain it.
- Normalize by field length, b=0.75 on the body and lower on the short
identifying fields, whose length carries no signal.
Aggregation across layers changes from sum to best-layer. Summing let a
concept that three layers happen to mention outrank the single document
that answers the question; the cascade's premise is that those three are
one concept, so they should not vote three times. Measured separately:
BM25 alone reaches 0.842 recall@1, best-layer aggregation takes it to
0.895.
The stemmer is the published Porter algorithm rather than a suffix list
written alongside this corpus, and the unit test pins it against Porter's
own reference vocabulary. That costs some accuracy — "rebalancing" and
"rebalance" genuinely do not meet under Porter — and the test records
those gaps instead of patching them. A stemmer hand-fitted to the
questions grading it would make the eval measure itself.
Tool schemas are untouched: fixtures/mcp-tools-baseline.json still
matches byte for byte.
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PR #38 merged deferring three hosted acceptance checks to #44, noting they "remain required before publishing the desktop account-sync release." Three releases shipped anyway: 0.1.0, 0.2.0 and 0.3.0, all with account sync reachable by anyone who downloads them. Reachable, not merely present. generate-supabase-config.mjs throws unless SUPABASE_URL and SUPABASE_ANON_KEY are set, app-release.yml supplies both from repository secrets, and AccountPanel renders unconditionally in Settings with sign in, sign out and delete account. There is no flag; every published build can sign a user into the hosted project. Nothing here says the feature is broken. It says nobody has confirmed it works on the artifact users actually download. Records the gate in docs/release-gates.md, splitting it by what can prove what: - Callback state validation, cancel/retry races, offline sign-out, and the whole scrub/quarantine model that keeps paths and MCP commands from syncing as runnable config are already covered by tests in apps/desktop/test/ and need no manual repeat. - A packaged app, a second physical Mac, and the hosted database cannot be simulated. Those three checks stay manual, with commands. Then makes the file the gate rather than a note about one: the release workflow refuses to publish while an "## Open:" section remains. The issue-only version of this gate was enforceable solely by remembering it, which is precisely how it came to be missed three times. Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Retrieval was the weakest part of ContextCake and nobody could tell, because the ranker was unmeasurable. This adds the measurement, then fixes what it exposed.
Reproduce with
npm run eval. Also records the #44 release gate and stops the release workflow from publishing past an open one.Why this matters
Everything the cascade is good at — section merge, provenance, dated conflicts — is downstream of the agent finding the right concept. It was finding the right concept 26% of the time.
conflictwas already 1.000 before the change. That is the honest read on the engine: the cascade and the conflict surfacing work, and always did. Retrieval was the broken half, and it was broken badly enough to hide the working half.The four commits, in order
refactor(core)search.mjs, behavior-identical. Prerequisite:mcp-server.mjsparses argv and canprocess.exit()at load, so it could not be imported, so the ranker could not be scored.test(core)feat(core)cidocs/release-gates.mdplus a workflow step that blocks a release while a gate is open.What was actually wrong
Three causes, isolated by the eval rather than guessed at:
IwithindexOfand found theiinside identifier, in, with. Any natural-language question handed the longest document a large free score, which is whydecisions/service-stackwon nearly everything regardless of what was asked.Aggregation across layers also changed from sum to best-layer: summing let a concept three layers happen to mention outvote the one document that answers the question. Measured separately — BM25 alone reaches 0.842 recall@1; best-layer aggregation takes it to 0.895.
On not cheating the benchmark
search.test.mjspins it against Porter's own reference vocabulary.rebalancingandrebalancegenuinely do not meet; a test asserts that, so nobody "fixes" it into a corpus-fitted stemmer later.keepvsretained) stay in the set as the honest ceiling of lexical ranking.CLAUDE.mdwarns against tuning the stemmer or field boosts against the golden set — add questions first, then tune.Release gate (#44)
Separate concern, same branch because it is one sitting's work.
PR #38 deferred three hosted acceptance checks to #44 as pre-release gates. 0.1.0, 0.2.0 and 0.3.0 all shipped since, all with account sync reachable: packaging throws without a hosted Supabase config, the release workflow supplies one, and
AccountPanelrenders unconditionally. No flag.docs/release-gates.mdsplits the checks into the onesapps/desktop/test/already discharges in CI (callback state, cancel/retry races, offline sign-out, the entire scrub/quarantine model) and the three that need a packaged app, a second Mac, and the hosted database. Those three remain open and need @siracusa5 — the OAuth flow needs real credentials.This PR will block
app-v0.3.1until that gate is closed. That is intended.Test plan
npm test— 15 engine suites plus the eval, exit 0node --test packages/core/tests/search.test.mjs— 13 testsRETRIEVAL REGRESSED; restored, exits 0fixtures/mcp-tools-baseline.jsonunchanged — tool schemas are byte-identicalcheck-dco.sh origin/main HEAD— 4 commits signed offdocs/release-gates.mdNotes
q07("how long do we keep logs?" against a corpus that says retained) still misses at rank 1. No stemmer bridges that; it is the number to beat if embeddings ever earn their dependency.🤖 Generated with Claude Code