|
| 1 | +""" |
| 2 | +PR-3 regression — monolith form (drop-in for upstream `bm25-memory.py` port). |
| 3 | +
|
| 4 | +Same 5 cases as `test_pr3_deterministic_sort.py`, but the import path is |
| 5 | +restructured so the test loads the upstream monolith via `importlib.util` |
| 6 | +(the file name `bm25-memory.py` contains a hyphen and cannot be imported |
| 7 | +as a normal module). |
| 8 | +
|
| 9 | +After the monolith port lands upstream, these tests run as-is from the |
| 10 | +upstream root with no `_bm25/` package on `sys.path`. From the fork side, |
| 11 | +they also pass because our orchestrator file is at the same path. |
| 12 | +
|
| 13 | +Sites covered: |
| 14 | + - dense_rank_decisions |
| 15 | + - rrf_merge |
| 16 | + - bm25_rank_decisions |
| 17 | +""" |
| 18 | +import importlib.util |
| 19 | +import sys |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | + |
| 23 | +def _load_bm25_memory(): |
| 24 | + """Dynamically load `src/hooks/bm25-memory.py` as a module. |
| 25 | +
|
| 26 | + Hyphen in the filename rules out a normal import. The fork orchestrator |
| 27 | + and the upstream monolith both expose the three target functions at |
| 28 | + module level, so this loader is interchangeable between them. |
| 29 | + """ |
| 30 | + proj = Path(__file__).resolve().parents[2] |
| 31 | + monolith = proj / "src" / "hooks" / "bm25-memory.py" |
| 32 | + # Ensure the package directory is importable so the orchestrator's own |
| 33 | + # internal imports resolve (in fork: `_bm25/`; in upstream monolith |
| 34 | + # there are no such imports — the loader still works). |
| 35 | + sys.path.insert(0, str(proj / "src" / "hooks")) |
| 36 | + spec = importlib.util.spec_from_file_location("bm25_memory", monolith) |
| 37 | + mod = importlib.util.module_from_spec(spec) |
| 38 | + spec.loader.exec_module(mod) |
| 39 | + return mod |
| 40 | + |
| 41 | + |
| 42 | +bm25_memory = _load_bm25_memory() |
| 43 | + |
| 44 | + |
| 45 | +def _resolve(name): |
| 46 | + """Resolve a target function from the monolith. |
| 47 | +
|
| 48 | + Upstream monolith: function defined at module level → direct attribute. |
| 49 | + Fork orchestrator (post-decomposition): orchestrator does not re-export |
| 50 | + rrf_merge / dense_rank_decisions / bm25_rank_decisions, so we fall back |
| 51 | + to the `_bm25.ranker` module that the orchestrator imports from. |
| 52 | + """ |
| 53 | + if hasattr(bm25_memory, name): |
| 54 | + return getattr(bm25_memory, name) |
| 55 | + # Fallback for fork — package present alongside the orchestrator |
| 56 | + from _bm25 import ranker # type: ignore |
| 57 | + return getattr(ranker, name) |
| 58 | + |
| 59 | + |
| 60 | +rrf_merge = _resolve("rrf_merge") |
| 61 | +dense_rank_decisions = _resolve("dense_rank_decisions") |
| 62 | +bm25_rank_decisions = _resolve("bm25_rank_decisions") |
| 63 | +HAS_BM25 = getattr(bm25_memory, "HAS_BM25", None) |
| 64 | +if HAS_BM25 is None: |
| 65 | + from _bm25 import ranker as _ranker # type: ignore |
| 66 | + HAS_BM25 = getattr(_ranker, "HAS_BM25", False) |
| 67 | + |
| 68 | + |
| 69 | +def _items(n, prefix="c"): |
| 70 | + return [{"hash": f"{prefix}{i:03d}", "text": f"item {i}", "emb": []} for i in range(n)] |
| 71 | + |
| 72 | + |
| 73 | +def test_rrf_merge_idempotent_same_input(): |
| 74 | + """Same input → same output across repeat calls (no hidden randomness).""" |
| 75 | + a = _items(20, "a") |
| 76 | + b = _items(20, "b") |
| 77 | + keys1 = [it["hash"] for it in rrf_merge(a, b)] |
| 78 | + keys2 = [it["hash"] for it in rrf_merge(a, b)] |
| 79 | + assert keys1 == keys2, f"rrf_merge non-idempotent: {keys1[:5]} vs {keys2[:5]}" |
| 80 | + |
| 81 | + |
| 82 | +def test_rrf_merge_equal_rank_tiebreak_independent_of_list_input_order(): |
| 83 | + """Items with identical RRF rank in both lists must order by hash — |
| 84 | + independent of whether item X or item Y was inserted first into list_a. |
| 85 | +
|
| 86 | + This is the bug that hash tiebreak fixes: previously dict-insertion |
| 87 | + order leaked into the output, so swapping list_a/list_b position of |
| 88 | + equal-rank items would shuffle the result.""" |
| 89 | + a1 = [{"hash": "zzz_late", "text": "z"}] |
| 90 | + b1 = [{"hash": "aaa_early", "text": "a"}] |
| 91 | + a2 = [{"hash": "aaa_early", "text": "a"}] |
| 92 | + b2 = [{"hash": "zzz_late", "text": "z"}] |
| 93 | + keys1 = [it["hash"] for it in rrf_merge(a1, b1)] |
| 94 | + keys2 = [it["hash"] for it in rrf_merge(a2, b2)] |
| 95 | + assert keys1 == keys2 == ["aaa_early", "zzz_late"], ( |
| 96 | + f"hash tiebreak failed:\n case1={keys1}\n case2={keys2}" |
| 97 | + ) |
| 98 | + |
| 99 | + |
| 100 | +def test_rrf_merge_equal_score_tiebreak_is_hash(): |
| 101 | + """Items with identical RRF scores (same rank in both lists) must |
| 102 | + order by hash key ascending, not insertion order.""" |
| 103 | + a = [{"hash": "z_high", "text": "z"}, {"hash": "a_low", "text": "a"}] |
| 104 | + b = [{"hash": "a_low", "text": "a"}, {"hash": "z_high", "text": "z"}] |
| 105 | + out = rrf_merge(a, b) |
| 106 | + keys = [it["hash"] for it in out] |
| 107 | + assert keys == ["a_low", "z_high"], f"hash tiebreak failed: got {keys}" |
| 108 | + |
| 109 | + |
| 110 | +def test_dense_rank_decisions_no_emb_returns_empty(): |
| 111 | + """Sanity: vec-daemon down → empty list.""" |
| 112 | + corpus = _items(5) |
| 113 | + result = dense_rank_decisions(corpus, "any query") |
| 114 | + assert result == [] or all("hash" in it for it in result) |
| 115 | + |
| 116 | + |
| 117 | +def test_bm25_rank_decisions_index_tiebreak(): |
| 118 | + """bm25_rank_decisions: equal scores → index ascending. |
| 119 | +
|
| 120 | + Corpus of byte-identical entries gets identical BM25 scores. With the |
| 121 | + explicit `(-scores[i], i)` tiebreak, surviving entries (after MMR / |
| 122 | + cluster dedup) come back in ascending index order.""" |
| 123 | + if not HAS_BM25: |
| 124 | + return |
| 125 | + corpus = [ |
| 126 | + {"hash": f"h{i}", "subject": "identical text", "text": "identical text body for bm25"} |
| 127 | + for i in range(5) |
| 128 | + ] |
| 129 | + result = bm25_rank_decisions( |
| 130 | + corpus, |
| 131 | + "identical bm25", |
| 132 | + top_k=5, |
| 133 | + min_score=0.0, |
| 134 | + adaptive_floor_ratio=0.0, |
| 135 | + mmr_jaccard_threshold=1.01, # disable MMR |
| 136 | + skip_rerank=True, |
| 137 | + ) |
| 138 | + hashes = [it["hash"] for it in result] |
| 139 | + if len(hashes) > 1: |
| 140 | + assert hashes == sorted(hashes), f"index tiebreak broken: {hashes}" |
| 141 | + |
| 142 | + |
| 143 | +if __name__ == "__main__": |
| 144 | + test_rrf_merge_idempotent_same_input() |
| 145 | + print("PASS: rrf_merge idempotent") |
| 146 | + test_rrf_merge_equal_rank_tiebreak_independent_of_list_input_order() |
| 147 | + print("PASS: rrf_merge equal-rank tiebreak independent of input order") |
| 148 | + test_rrf_merge_equal_score_tiebreak_is_hash() |
| 149 | + print("PASS: rrf_merge equal-score tiebreak by hash") |
| 150 | + test_dense_rank_decisions_no_emb_returns_empty() |
| 151 | + print("PASS: dense_rank_decisions no-emb sanity") |
| 152 | + test_bm25_rank_decisions_index_tiebreak() |
| 153 | + print("PASS: bm25_rank_decisions index tiebreak") |
| 154 | + print("\nAll PR-3 monolith-form regression tests passed.") |
0 commit comments