Files
turnstone/tests/test_bm25.py
T
Patrick Buckley 215f7506ba feat(rerank): wire endpoint-backed reranking into BM25 retrieval surfaces
Reuse the shipped Cohere/Jina rerank client as an optional post-process on
the BM25 surfaces (tool search, skill search, memory composition) via one
seam: BM25Index gains an injected reranker + a two-stage search (BM25 recall
top-50 -> rerank -> top-k). No new storage.

Gated on a configured endpoint plus tools.rerank_bm25 (default on, matching
rerank_web_search). tools.rerank_bm25_threshold (default 0.0 = off) is a
relevance FLOOR for proactive memory surfacing: BM25 always returns something,
so without a floor every-turn memory injection spends tokens on the top-k of
whatever lexically matched; the reranker score is what makes a meaningful
"inject nothing" gate possible.

Two reranker modes (BM25Index rerank_filters):
- REORDER (reactive tool/skill search): the reranker must never drop results
  -> fall back to BM25 order on empty, backfill omitted pool items, so a
  misbehaving endpoint can't silently lose tools.
- FILTER (memory, rerank_filters = threshold > 0): a clean empty/short result
  is honoured (inject nothing) -- a deliberate divergence from
  web_search._rerank_results.
Parse/endpoint failure is a discrete branch from the floor: an empty result
for non-empty input means an unparseable response (a conforming reranker
scores every doc), so the closure raises RerankError and BM25Index falls back
to BM25 order in BOTH modes -- the floor only acts on valid scores.

Also: cap the rerank client timeout at 15s (the per-turn memory path can't
afford tools.timeout's 120s default); move the Reranker alias to rerank.py
(shared, no import cycle); document the endpoint egress in the rerank_bm25
help, the admin Reranker-role description, and docs/tools.md; add
scripts/bench_bm25_rerank.py (manual, needs a live endpoint) to measure
precision@k/MRR lift and recommend a threshold default.

Negative-tested: reorder fallback-on-empty and omitted-item backfill,
filter-mode honor-empty, singleton-still-floored, the parse-fail RerankError
raise, the >= floor boundary, and pool-position-to-doc-index mapping -- each
guard reverted to confirm its test fails, then restored.
2026-06-01 12:56:45 -07:00

248 lines
11 KiB
Python

"""Tests for turnstone.core.bm25 — tokenizer and BM25 index."""
from turnstone.core.bm25 import _RERANK_POOL, BM25Index, _tokenize
class TestTokenize:
def test_simple_words(self):
assert _tokenize("hello world") == ["hello", "world"]
def test_underscores(self):
assert _tokenize("read_file") == ["read", "file"]
def test_hyphens(self):
assert _tokenize("web-search") == ["web", "search"]
def test_dots(self):
assert _tokenize("foo.bar.baz") == ["foo", "bar", "baz"]
def test_mixed_separators(self):
assert _tokenize("mcp__server__read_file") == ["mcp", "server", "read", "file"]
def test_empty_string(self):
assert _tokenize("") == []
def test_case_folding(self):
assert _tokenize("Hello World") == ["hello", "world"]
class TestBM25Index:
def test_search_returns_relevant(self):
docs = ["read a file from disk", "search for file in directory", "execute a bash command"]
index = BM25Index(docs)
results = index.search("file", k=2)
assert 0 in results
assert 1 in results
def test_search_empty_query(self):
docs = ["hello world"]
index = BM25Index(docs)
assert index.search("") == []
def test_search_no_match(self):
docs = ["hello world", "foo bar"]
index = BM25Index(docs)
assert index.search("zzzznotfound") == []
def test_search_respects_k(self):
docs = [f"document {i} with common word" for i in range(20)]
index = BM25Index(docs)
results = index.search("common", k=3)
assert len(results) <= 3
def test_empty_corpus(self):
index = BM25Index([])
assert index.search("anything") == []
def test_single_document(self):
index = BM25Index(["the only document about turnstone"])
results = index.search("turnstone")
assert results == [0]
def test_ordering_by_relevance(self):
docs = [
"unrelated content about cooking recipes",
"python programming with file operations",
"read file write file file operations disk io",
]
index = BM25Index(docs)
results = index.search("file operations", k=3)
# Doc 2 has more file/operations mentions, should rank higher
assert results[0] == 2
class TestBM25Reranking:
"""Two-stage search: BM25 recall pool reordered by an attached reranker.
The reranker is a plain callable returning POSITIONS into the recall pool
(best-first); ``search`` maps each position back to the original doc index
via ``pool[pos]``. Fakes are deterministic lambdas/closures — the rerank
HTTP boundary is exercised separately in test_rerank.py.
"""
# All matching docs share "alpha"; the non-matching ones (zzz) stay out of
# the pool, so a returned value that is 1 or 3 would prove a mapping bug.
_DOCS = ["alpha one", "zzz", "alpha two", "zzz", "alpha three"]
def test_reorders_by_reranker_output(self):
# Reranker dictates pool order 2, 0, 1 (positions) regardless of BM25.
docs = ["alpha", "alpha", "alpha"]
index = BM25Index(docs, reranker=lambda q, d: [2, 0, 1])
# pool == [0, 1, 2] (BM25 tie-break is ascending index); positions map
# back to original indices 2, 0, 1.
assert index.search("alpha", k=3) == [2, 0, 1]
def test_maps_reranker_position_back_to_original_index(self):
# Reranker reverses the pool it is handed (positions n-1..0). Guards
# the ``pool[pos]`` mapping line: returned values must be ORIGINAL doc
# indices (a subset of {0, 2, 4}), never raw positions like 1 or 3.
index = BM25Index(self._DOCS, reranker=lambda q, d: list(range(len(d)))[::-1])
result = index.search("alpha", k=5)
# Only the three "alpha" docs are in the pool.
assert set(result) == {0, 2, 4}
# Reversed pool ordering: whatever BM25 pool order was, it is reversed.
bm25_pool = BM25Index(self._DOCS).search("alpha", k=_RERANK_POOL)
assert result == bm25_pool[::-1]
def test_exception_falls_back_to_bm25_order(self):
def boom(q, d):
raise RuntimeError("rerank endpoint down")
index = BM25Index(self._DOCS, reranker=boom)
# Guards the ``except`` clause -> BM25 order, top-k.
assert index.search("alpha", k=2) == BM25Index(self._DOCS)._bm25_rank("alpha")[:2]
def test_clean_empty_return_is_honored_no_fallback(self):
# FILTER MODE (rerank_filters=True, the memory floor): a clean empty
# return means "inject nothing" (the caller's relevance floor emptied
# it) and must NOT fall back to BM25 order. Guards the filter-branch
# ``return out`` -- adding ``if not out: return pool[:k]`` there fails.
index = BM25Index(self._DOCS, reranker=lambda q, d: [], rerank_filters=True)
assert index.search("alpha", k=5) == []
def test_filter_mode_exception_still_falls_back(self):
# FILTER MODE honors a clean empty (above), but a reranker EXCEPTION is
# an endpoint failure, not a floor verdict -> BM25 fallback, NOT empty.
# This is the parse-failure-vs-floor distinction at the seam: the
# _bm25_reranker closure raises on an unparseable/empty response so
# memory composition can't be silently suppressed by a broken endpoint.
def boom(q, d):
raise RuntimeError("rerank endpoint down")
index = BM25Index(self._DOCS, reranker=boom, rerank_filters=True)
assert index.search("alpha", k=2) == BM25Index(self._DOCS)._bm25_rank("alpha")[:2]
def test_singleton_pool_still_reranked(self):
# A 1-doc pool is still sent to the reranker (no len>1 short-circuit).
# Recording reranker returns [] -> result is [] AND it WAS called once.
# Filter mode honors the empty result (no fallback).
calls = {"n": 0}
def rec(q, d):
calls["n"] += 1
return []
# "two" matches only docs[2] -> pool of size 1.
index = BM25Index(self._DOCS, reranker=rec, rerank_filters=True)
assert index.search("two", k=5) == []
assert calls["n"] == 1
def test_reorder_mode_empty_falls_back_to_bm25(self):
# REORDER MODE (default rerank_filters=False, reactive tool/skill
# search): an empty reranker result means the endpoint failed, so fall
# back to BM25 order -- results are NEVER silently dropped. [bug-1]
# Guards the reorder-branch ``if not out: return pool[:k]`` -- removing
# it makes this return [] and fail.
index = BM25Index(self._DOCS, reranker=lambda q, d: [])
assert index.search("alpha", k=5) == BM25Index(self._DOCS)._bm25_rank("alpha")[:5]
def test_reorder_mode_backfills_omitted_pool_items(self):
# REORDER MODE: the reranker returns a STRICT SUBSET of pool positions
# (only the first two). The two reranked items come FIRST, then the
# omitted pool items are backfilled in BM25 order, capped at k. [bug-2]
# Guards the backfill loop -- removing it drops the omitted docs.
bm25_pool = BM25Index(self._DOCS)._bm25_rank("alpha") # 3 matching docs
assert len(bm25_pool) == 3
# Reranker keeps only pool positions 0 and 1 (drops the third).
index = BM25Index(self._DOCS, reranker=lambda q, d: [0, 1])
result = index.search("alpha", k=5)
# Reranked two first (pool[0], pool[1]) ...
assert result[:2] == [bm25_pool[0], bm25_pool[1]]
# ... then the omitted pool item backfilled in BM25 order.
assert result == [bm25_pool[0], bm25_pool[1], bm25_pool[2]]
# No item silently lost: the full matching set is present.
assert set(result) == set(bm25_pool)
def test_reorder_mode_backfill_respects_k(self):
# Backfill must stop at k: subset rerank + a k smaller than the pool.
bm25_pool = BM25Index(self._DOCS)._bm25_rank("alpha") # 3 matching docs
index = BM25Index(self._DOCS, reranker=lambda q, d: [0])
result = index.search("alpha", k=2)
# One reranked item, then one backfilled, capped at k=2.
assert result == [bm25_pool[0], bm25_pool[1]]
def test_reorder_mode_full_reorder_unchanged(self):
# REORDER MODE (default) with a full permutation: behaves exactly like
# the historical reorder -- every pool item present, in reranker order,
# backfill loop adds nothing (all positions already seen).
index = BM25Index(self._DOCS, reranker=lambda q, d: list(range(len(d)))[::-1])
result = index.search("alpha", k=5)
bm25_pool = BM25Index(self._DOCS).search("alpha", k=_RERANK_POOL)
assert result == bm25_pool[::-1]
def test_no_reranker_matches_baseline(self):
docs = [
"read a file from disk",
"search for file in directory",
"execute a bash command about files",
"totally unrelated cooking content",
]
plain = BM25Index(docs)
attached = BM25Index(docs, reranker=lambda q, d: list(range(len(d))))
for q in ("file", "bash command", "disk", "cooking", "file directory"):
# reranker=identity returns the pool unchanged, but the no-reranker
# path must be byte-for-byte the historical result regardless.
assert plain.search(q, k=3) == BM25Index(docs).search(q, k=3)
# And identity-rerank reproduces the BM25 top-k for these queries.
assert attached.search(q, k=3) == plain.search(q, k=3)
def test_bool_positions_rejected(self):
docs = ["alpha", "alpha", "alpha"]
# True/False are int subclasses posing as 1/0 -> rejected; "1" is not an
# int -> rejected; only positions 2 and 0 survive. Filter mode so the
# rejected positions are not re-added by reorder-mode backfill -- this
# isolates the type guard.
index = BM25Index(docs, reranker=lambda q, d: [True, "1", 2, 0], rerank_filters=True)
assert index.search("alpha", k=5) == [2, 0]
def test_recall_pool_capped_at_rerank_pool(self):
# More matching docs than the recall cap: the reranker must receive
# exactly _RERANK_POOL docs, and outputs may only reference that set.
n = _RERANK_POOL + 10
docs = [f"alpha doc{i}" for i in range(n)]
seen_len = {"n": -1}
def rec(q, d):
seen_len["n"] = len(d)
return list(range(len(d))) # identity over the (capped) pool
index = BM25Index(docs, reranker=rec)
result = index.search("alpha", k=n)
assert seen_len["n"] == _RERANK_POOL # only the first 50 reached rerank
assert len(result) == _RERANK_POOL
# Every returned index is from the BM25 top-50 recall set.
recall = set(BM25Index(docs)._bm25_rank("alpha")[:_RERANK_POOL])
assert set(result) == recall
def test_empty_query_skips_reranker(self):
calls = {"n": 0}
def rec(q, d):
calls["n"] += 1
return list(range(len(d)))
index = BM25Index(self._DOCS, reranker=rec)
# Empty query -> empty BM25 pool -> reranker never invoked.
assert index.search("", k=5) == []
assert calls["n"] == 0