Skip to content

feat(core): measure retrieval, then fix it (BM25F + eval harness) - #75

Merged
siracusa5 merged 4 commits into
mainfrom
c/context-cake-next-steps-bbef54
Jul 30, 2026
Merged

feat(core): measure retrieval, then fix it (BM25F + eval harness)#75
siracusa5 merged 4 commits into
mainfrom
c/context-cake-next-steps-bbef54

Conversation

@siracusa5

Copy link
Copy Markdown
Collaborator

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.

recall@1   0.263 -> 0.895
recall@5   0.500 -> 1.000
mrr        0.348 -> 0.947
conflict   1.000 -> 1.000

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.

conflict was 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

Commit What
refactor(core) Extract retrieval into search.mjs, behavior-identical. Prerequisite: mcp-server.mjs parses argv and can process.exit() at load, so it could not be imported, so the ranker could not be scored.
test(core) The eval — golden question set over a committed 3-layer corpus. Baseline lands at 0.263.
feat(core) BM25F over Porter-stemmed tokens. Baseline moves to 0.895.
ci docs/release-gates.md plus 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:

  1. Substring, not token, matching — the largest single effect. The old scorer looked for the query word I with indexOf and found the i inside identifier, in, with. Any natural-language question handed the longest document a large free score, which is why decisions/service-stack won nearly everything regardless of what was asked.
  2. No IDF — a word in every document counted as much as a word in one.
  3. No length normalization — repetition beat precision.

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

  • The stemmer is the published Porter algorithm, not a suffix list written next to these questions. search.test.mjs pins it against Porter's own reference vocabulary.
  • Porter's real gaps are recorded, not patched. rebalancing and rebalance genuinely do not meet; a test asserts that, so nobody "fixes" it into a corpus-fitted stemmer later.
  • Questions with true synonym gaps (keep vs retained) stay in the set as the honest ceiling of lexical ranking.
  • CLAUDE.md warns 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 AccountPanel renders unconditionally. No flag.

docs/release-gates.md splits the checks into the ones apps/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.1 until that gate is closed. That is intended.

Test plan

  • npm test — 15 engine suites plus the eval, exit 0
  • node --test packages/core/tests/search.test.mjs — 13 tests
  • Extraction commit verified green before any ranking change
  • Regression gate verified in both directions: a deliberately degraded scorer exits 1 with RETRIEVAL REGRESSED; restored, exits 0
  • fixtures/mcp-tools-baseline.json unchanged — tool schemas are byte-identical
  • Release-gate grep verified both ways (open blocks, closed passes)
  • check-dco.sh origin/main HEAD — 4 commits signed off
  • The three manual Pre-release validation: hosted OAuth and settings sync #44 checks — @siracusa5, steps in docs/release-gates.md

Notes

  • Engine stays dependency-free; the stemmer and BM25 are plain Node.
  • Retrieval remains O(corpus) per query, as before — unchanged, not addressed here.
  • 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

siracusa5 and others added 4 commits July 29, 2026 23:32
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>
@siracusa5
siracusa5 merged commit 71fd654 into main Jul 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant