Skip to content

Add rank-stability probe to measure retrieval pool sensitivity - #143

Merged
samkeen merged 2 commits into
mainfrom
claude/issue-141-kjx4w6
Aug 3, 2026
Merged

Add rank-stability probe to measure retrieval pool sensitivity#143
samkeen merged 2 commits into
mainfrom
claude/issue-141-kjx4w6

Conversation

@samkeen

@samkeen samkeen commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the rank-stability probe (--example stability), the second half of the eval harness (GH #141). While the scored eval measures retrieval quality on a hand-labelled 26-chunk corpus, that corpus is too small to see fusion-width changes — both signals return the entire index, making the ranking invariant under pool widening. This probe measures the property the eval cannot: how much the top-K ranking shifts when retrieval pool width changes on a vault large enough for the pool to bind.

fixes #141

Key Changes

  • New example: crates/b2-embed/examples/stability.rs (668 lines)

    • Runs unlabelled probes from evals/stability.json at three retrieval depths (4, 10, 30), each widening the candidate pool
    • Reports pool sensitivity: how many probes' top-K results survive each pool widening (prefix stability)
    • Reports baseline drift: compares current ranking against committed evals/stability-baseline.json
    • Uses deterministic fake embedder by default (content-addressed, reproducible across machines)
    • Supports --model for real bge vectors (skips baseline comparison), --vault for custom vaults, --bless to accept current ranking as new baseline, --verbose to inspect diverging lists
  • Committed baseline: crates/b2-embed/evals/stability-baseline.json

    • Snapshot of top-10 notes and chunks for each probe on fixtures/test-vault under the fake embedder
    • Enables future runs to show movement rather than inferring it
    • Includes provenance metadata (vault, embedder, K, git SHA)
  • Probe set: crates/b2-embed/evals/stability.json

    • 7 unlabelled queries designed for high recall across fixtures/test-vault topics
    • No correct answers — measures movement, not relevance
  • Public API: vault::candidate_pool(limit) function

  • Integration:

    • Updated eval.rs to warn when corpus is smaller than pool (the blindness condition)
    • Updated search.rs to expose pool_size as pub(crate) for vault::candidate_pool
    • Updated justfile with stability recipe and documentation
    • Updated design docs (index-engine.md, CLAUDE.md, fixtures/README.md) to explain the blindness and the probe

Notable Details

  • Deterministic by default: Fake embedder (blake3 content-addressed) ensures committed baseline is reproducible on every machine; real-model runs skip baseline comparison
  • Unlabelled corpus: No relevance scoring — drift is a signal, not a failure; the probe measures mechanism, not quality
  • Control experiment: Running on evals/corpus (the eval corpus) shows every prefix holds at every depth — that is the blindness (evals: the corpus is smaller than the retrieval pool, so the harness is blind to fusion-width changes #141)
  • Exit status 0 always: Never a gate; the probe is a measurement tool, not a pass/fail check
  • Vault isolation: Works on a throwaway copy to avoid mutating committed fixtures

https://claude.ai/code/session_014jpJ649GKkMnAh8Zrcw6MC

Summary by CodeRabbit

  • New Features

    • Added a rank-stability probe to compare search rankings at different retrieval depths.
    • Added baseline comparison and blessing workflows, with verbose output and support for fake or real embedding models.
    • Evaluation results now report candidate-pool size and identify runs where the corpus is too small to measure pool-width effects.
  • Documentation

    • Documented stability evaluation commands, baseline management, and small-corpus measurement limitations.
  • Tests

    • Added coverage for candidate-pool sizing and ranking consistency.

The eval corpus is 26 chunks; retrieval reaches `vault::candidate_pool(10) = 150`
candidates per signal, so both halves of the hybrid return the whole index and the two
ranked lists RRF fuses are identical for any pool at least that wide. Every score the
eval prints is therefore invariant under pool/fusion width — #140's 3x widening of
`search_chunks`' pool moved 5 of 7 probe top-10s on fixtures/test-vault while printing
bit-identical eval numbers.

Options (2) and (3) from the issue:

- `--example stability` (`just stability`) — a rank probe on fixtures/test-vault (~780
  chunks, where the pool binds). It reports pool sensitivity (the same query asked at
  widening pools, since candidate depth is a function of the ask) and drift of the
  shipped top-10 against a committed snapshot; `--bless` accepts a new ranking. Runs
  the fake embedder, so it is deterministic, needs no model, and its baseline is
  reproducible on any machine; `--model` scores the same probes on real bge vectors
  when the magnitude rather than the mechanism is the question.
- The eval now states its blindness out loud: a corpus smaller than the pool prints a
  warning, and every results.jsonl row carries `pool` / `pool_blind`, so the caveat
  travels with the numbers instead of living in an issue.

`vault::candidate_pool` is the one new public seam — the composition of `hit_pool` and
`search::pool_size` that a measurement has to know. The suite pins that composition and
the small-corpus invariance property it explains.

Measured with the probe (fake embedder, fixtures/test-vault): the shipped 3x pool (150
candidates/signal) against the conservative `limit + 2` reading (60) drifts 10/10 probes
with ~50% of each top-10 turning over — the evidence #142 was waiting on, with the
caveat that uncorrelated fake vectors exaggerate what correlated bge rankings would
show. The same probe on the eval corpus moves nothing at any pool, which is exactly the
gap this issue names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jpJ649GKkMnAh8Zrcw6MC
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change exposes effective retrieval-pool sizing, detects pool-blind evaluation runs, and adds a deterministic rank-stability probe with configurable queries, depth comparisons, baseline drift reporting, and blessing commands.

Changes

Retrieval Pool Measurement

Layer / File(s) Summary
Candidate pool contract and tests
crates/b2-core/src/search.rs, crates/b2-core/src/vault.rs, crates/b2-core/tests/search.rs
candidate_pool exposes widened per-signal retrieval depth. Tests cover minimum depth, monotonicity, and stable prefixes when the corpus fits within the pool.
Pool-blind evaluation reporting
crates/b2-embed/examples/eval.rs, docs/design/index-engine.md, CLAUDE.md
The evaluation reports candidate-pool size, warns when the corpus cannot exercise the pool, and persists pool-blind status in result rows.
Rank-stability probe and baseline
crates/b2-embed/examples/stability.rs, crates/b2-embed/evals/stability.json, crates/b2-embed/evals/stability-baseline.json, justfile, fixtures/README.md, CLAUDE.md
The probe compares rankings across depths, reports divergences and baseline drift, supports fake or real embedders, and adds stability and baseline-blessing commands.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Justfile
  participant StabilityExample
  participant VaultCopy
  participant Embedder
  participant Retrieval
  participant Baseline

  Justfile->>StabilityExample: Run stability probe with flags
  StabilityExample->>VaultCopy: Copy selected vault
  StabilityExample->>Embedder: Index copied vault
  StabilityExample->>Retrieval: Query configured probes at multiple depths
  Retrieval-->>StabilityExample: Return ranked notes and chunks
  StabilityExample->>Baseline: Compare or write ranking snapshot
  Baseline-->>StabilityExample: Report drift or save baseline
Loading

Possibly related issues

Possibly related PRs

  • AlteredCraft/B2#33 — Both changes expose or report effective retrieval-pool sizing in search.rs and evaluation code.
  • AlteredCraft/B2#61 — Both changes modify the evaluation harness and related documentation.
  • AlteredCraft/B2#71 — Both changes overlap in retrieval, evaluation, tests, and documentation areas.

Suggested reviewers: claude

Poem

I thumped through pools both wide and small,
And watched ranked notes rise and fall.
A baseline waits beneath the moon,
While probes compare each changing tune.
Hop, bless the ranks when fixtures grow!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding a rank-stability probe to measure retrieval pool sensitivity.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/issue-141-kjx4w6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
crates/b2-core/tests/search.rs (1)

54-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert chunk-level rank stability for the depth invariant.

search_chunks returns ChunkSearchResult, while chunk_id lives on Hit; comparing path can pass across different chunks in the same note and can miss swapped chunks. Compare hit.chunk_id with an internal Hit-level API, or use a stable result identity from search_chunks, and limit the test to its observed depths unless it actually checks the entire depth range.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/b2-core/tests/search.rs` around lines 54 - 75, Update
a_corpus_smaller_than_the_pool_ranks_the_same_at_any_depth to compare
chunk-level identities rather than paths, using hit.chunk_id or another stable
search_chunks result identity. Ensure the assertion detects swapped chunks
within the same note, and either validate every intended depth or limit the test
wording and inputs to the depths it actually observes.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/b2-core/src/search.rs`:
- Around line 91-98: Update the candidate-depth calculation in pool_size to use
saturating multiplication for limit * 5, preventing overflow while preserving
the existing minimum depth of 30.

In `@crates/b2-embed/examples/eval.rs`:
- Around line 452-460: Correct pool-blind handling across all listed sites: in
crates/b2-embed/examples/eval.rs lines 452-460 and 564-568, use <= for the pool
comparison and limit the warning and metadata to candidate-pool width; in
crates/b2-core/src/vault.rs lines 86-92, remove claims that signal lists are
identical or RRF_K is invisible; in crates/b2-core/tests/search.rs lines 47-76,
cover the exact-pool boundary and candidate-width stability; in
docs/design/index-engine.md lines 321-329 and crates/b2-embed/examples/eval.rs
lines 29-35, update guidance to state that complete candidate lists are stable
while RRF_K may change fused ordering and BM25 need not return the whole corpus.

In `@crates/b2-embed/examples/stability.rs`:
- Around line 353-364: Update prefix_cell so a zero-length comparison is
reported as unmeasured with an “n/a” label and does not count as stable;
preserve the existing =N and N/M output for non-empty comparisons, ensuring
empty answers are excluded from moved and pool-invariant counts.

In `@justfile`:
- Around line 243-253: Update the stability usage examples in the recipe
comment, its [doc] description, and CLAUDE.md lines 89-90 to include the `--`
separator before forwarded flags, using forms such as `just stability --
--verbose` and `just stability -- --vault <path>`. Keep the existing stability
recipe and argument forwarding unchanged.

---

Nitpick comments:
In `@crates/b2-core/tests/search.rs`:
- Around line 54-75: Update
a_corpus_smaller_than_the_pool_ranks_the_same_at_any_depth to compare
chunk-level identities rather than paths, using hit.chunk_id or another stable
search_chunks result identity. Ensure the assertion detects swapped chunks
within the same note, and either validate every intended depth or limit the test
wording and inputs to the depths it actually observes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e7134638-ede1-4d51-b606-94acff4bdff7

📥 Commits

Reviewing files that changed from the base of the PR and between 196dc3b and 62bbac9.

📒 Files selected for processing (11)
  • CLAUDE.md
  • crates/b2-core/src/search.rs
  • crates/b2-core/src/vault.rs
  • crates/b2-core/tests/search.rs
  • crates/b2-embed/evals/stability-baseline.json
  • crates/b2-embed/evals/stability.json
  • crates/b2-embed/examples/eval.rs
  • crates/b2-embed/examples/stability.rs
  • docs/design/index-engine.md
  • fixtures/README.md
  • justfile

Comment thread crates/b2-core/src/search.rs
Comment thread crates/b2-embed/examples/eval.rs
Comment thread crates/b2-embed/examples/stability.rs
Comment thread justfile
… pool (#141)

Review of the #141 instrument, three corrections. Two are factual, and both were
measured rather than argued.

`search::pool_size` now saturates. `limit` is user input and the two widenings
compose (`pool_size(hit_pool(limit))`), so a large `--limit` reached a product that
overflows: `b2 search foo --limit 18446744073709551615` panicked a debug build at
`(limit * 5)`, and a release build would have *wrapped* it into a tiny pool — an
absurd-but-harmless ask silently returning wrong results. Saturating matches
`hit_pool`'s own widening; a test pins that an absurd limit still answers.

The blindness claim was too broad. It said a corpus smaller than the pool hides a
change to `hit_pool`, `pool_size` **or `RRF_K`**. The first two are right; `RRF_K` is
not — it re-weights the *same* two lists, so it reorders any corpus. Measured on the
26-chunk eval corpus: k = 60 → 10 moves note ranks across the query set, so the eval
sees a k change fine. Everything now says *candidate* width, and the docs state the
mechanism precisely: it is not that both signals "return the whole index" (BM25
returns every *matching* chunk), it is that neither list is truncated, so widening
cannot add a candidate. The boundary is `<=` for the same reason — a pool exactly the
size of the corpus truncates nothing either — so the warning and the `pool_blind`
field now use it.

The probe no longer counts an empty comparison as stability. A probe that returns
nothing gave `span = 0`, printed `=0`, and was folded in as pool-invariant, which let
a run that measured nothing announce that everything was invariant. Cells are now
stable / moved / unmeasured, the denominators count only what was measured, and an
all-empty run says so.

Also: the probe drops a bare `--`. `just stability --verbose` forwards the flag
directly (measured on just 1.58), but `just stability -- --verbose` forwards the
separator into the recipe verbatim, and the example would have rejected it as an
unknown flag.

Rankings are unchanged — the blessed baseline still matches exactly; only its
explanatory note moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jpJ649GKkMnAh8Zrcw6MC
@samkeen
samkeen merged commit 4c30bbd into main Aug 3, 2026
2 checks passed
@samkeen
samkeen deleted the claude/issue-141-kjx4w6 branch August 3, 2026 23:35
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.

evals: the corpus is smaller than the retrieval pool, so the harness is blind to fusion-width changes

2 participants