Cap per-source recall so one deep decision cannot flood the candidate pool
Browse filesCanfield exposed a recall-stage monopoly: its 94 paragraphs filled both
retrievers' CANDIDATES slices on device-search queries, so the result-stage
SOURCE_CAP deferred surplus chunks with nothing left to promote and
_ensure_primary found no statute in the pool at all. _capped_top now limits
each capped source (_source_key: a decision, a memorandum) to RECALL_CAP
chunks per retriever slice, with the semantic ranking over-fetching 4x so
other sources backfill the freed slots.
RECALL_CAP=20, env-overridable (CANLEX_RECALL_CAP), swept 2026-07-10 over
{12,16,20,24} on the 159-Q eval: 12 and 20 tie at 0.80/0.94/0.97/0.99/0.87
(4 misses) -- above the uncapped baseline (0.79/0.93/0.97/0.99/0.87, 5
misses); 20 chosen as the lightest touch. The device-search canary now
returns a spread of Canfield/Pike/Fearon instead of one decision six times,
and the PCMLTFA canary keeps its legitimate multi-decision cluster while
surfacing s. 18 and s. 20. Four unit tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- canlex/index.py +42 -3
- tests/test_index.py +43 -2
|
@@ -42,6 +42,22 @@ BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
|
|
| 42 |
# (memoranda, letters of understanding) vs its numbered articles
|
| 43 |
# (sweep-tuned 2026-05-23 from 0.008 -> 0.004)
|
| 44 |
SOURCE_CAP = 2 # max chunks one case or memorandum may contribute
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
|
| 46 |
|
| 47 |
# Primary instruments -- enacted law, collective agreements, the NJC directives
|
|
@@ -312,7 +328,10 @@ class LegislationIndex:
|
|
| 312 |
def _semantic_ranking(self, query):
|
| 313 |
qv = self.embedder.encode_query(query)
|
| 314 |
sims = self.vectors @ qv
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
| 316 |
# The top cosine similarity doubles as a corpus-coverage signal: a query
|
| 317 |
# the corpus cannot answer has no passage close to it.
|
| 318 |
return [int(i) for i in order], float(sims.max())
|
|
@@ -354,6 +373,25 @@ class LegislationIndex:
|
|
| 354 |
return ("memorandum", c["section"]) # act_code is a shared constant
|
| 355 |
return (doc_type, c["act_code"]) # one decision, keyed by citation
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
def _diversify(self, ordered):
|
| 358 |
"""Reorder so no single case, memorandum, agreement or directive can
|
| 359 |
monopolise the results: once a source has contributed SOURCE_CAP chunks,
|
|
@@ -490,11 +528,12 @@ class LegislationIndex:
|
|
| 490 |
confidence = None
|
| 491 |
fused = defaultdict(float)
|
| 492 |
bm25 = self._bm25_scores(expanded)
|
| 493 |
-
for rank, idx in enumerate(
|
|
|
|
| 494 |
fused[idx] += 1.0 / (RRF_K + rank)
|
| 495 |
if self.semantic:
|
| 496 |
sem_order, confidence = self._semantic_ranking(expanded)
|
| 497 |
-
for rank, idx in enumerate(sem_order):
|
| 498 |
fused[idx] += W_SEM / (RRF_K + rank)
|
| 499 |
|
| 500 |
# Ensure explicitly-referenced sections are retrieved even if recall
|
|
|
|
| 42 |
# (memoranda, letters of understanding) vs its numbered articles
|
| 43 |
# (sweep-tuned 2026-05-23 from 0.008 -> 0.004)
|
| 44 |
SOURCE_CAP = 2 # max chunks one case or memorandum may contribute
|
| 45 |
+
RECALL_CAP = int(os.environ.get("CANLEX_RECALL_CAP", "20"))
|
| 46 |
+
# max chunks one capped source (anything _source_key keys:
|
| 47 |
+
# a decision, a memorandum) may contribute to EACH
|
| 48 |
+
# retriever's CANDIDATES slice at the recall stage. Without
|
| 49 |
+
# it, a deep decision whose every paragraph matches a
|
| 50 |
+
# specific query (Canfield: 94 paragraphs on device border
|
| 51 |
+
# searches) fills the entire fusion pool, so the result-
|
| 52 |
+
# stage SOURCE_CAP defers its surplus with nothing left to
|
| 53 |
+
# promote and _ensure_primary finds no statute to pull in.
|
| 54 |
+
# Generous by design: SOURCE_CAP=2 governs display; this
|
| 55 |
+
# only has to leave room for other sources to survive
|
| 56 |
+
# recall. Uncapped sources (_source_key None) are unlimited.
|
| 57 |
+
# Swept 2026-07-10 over {12,16,20,24} on the 159-Q eval:
|
| 58 |
+
# 12 and 20 tie at 0.80/0.94/0.97/0.99/0.87 (4 misses),
|
| 59 |
+
# both above the uncapped baseline; 20 chosen as the
|
| 60 |
+
# lightest-touch winner (24 regresses Hit@3/@5).
|
| 61 |
APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
|
| 62 |
|
| 63 |
# Primary instruments -- enacted law, collective agreements, the NJC directives
|
|
|
|
| 328 |
def _semantic_ranking(self, query):
|
| 329 |
qv = self.embedder.encode_query(query)
|
| 330 |
sims = self.vectors @ qv
|
| 331 |
+
# Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
|
| 332 |
+
# surplus chunks of any one deep source, and the extra depth is what
|
| 333 |
+
# lets other sources backfill the freed CANDIDATES slots.
|
| 334 |
+
order = self._np.argsort(sims)[::-1][:CANDIDATES * 4]
|
| 335 |
# The top cosine similarity doubles as a corpus-coverage signal: a query
|
| 336 |
# the corpus cannot answer has no passage close to it.
|
| 337 |
return [int(i) for i in order], float(sims.max())
|
|
|
|
| 373 |
return ("memorandum", c["section"]) # act_code is a shared constant
|
| 374 |
return (doc_type, c["act_code"]) # one decision, keyed by citation
|
| 375 |
|
| 376 |
+
def _capped_top(self, ordered):
|
| 377 |
+
"""The first CANDIDATES indices of `ordered`, keeping at most
|
| 378 |
+
RECALL_CAP per capped source. The recall-stage counterpart of
|
| 379 |
+
_diversify: where _diversify reorders the fused candidates so a deep
|
| 380 |
+
source cannot monopolise the visible results, this stops that source
|
| 381 |
+
from monopolising the candidate pool itself -- skipped surplus frees
|
| 382 |
+
slots that later, weaker-ranked chunks of OTHER sources backfill."""
|
| 383 |
+
out, counts = [], defaultdict(int)
|
| 384 |
+
for idx in ordered:
|
| 385 |
+
key = self._source_key(idx)
|
| 386 |
+
if key is not None:
|
| 387 |
+
counts[key] += 1
|
| 388 |
+
if counts[key] > RECALL_CAP:
|
| 389 |
+
continue
|
| 390 |
+
out.append(idx)
|
| 391 |
+
if len(out) >= CANDIDATES:
|
| 392 |
+
break
|
| 393 |
+
return out
|
| 394 |
+
|
| 395 |
def _diversify(self, ordered):
|
| 396 |
"""Reorder so no single case, memorandum, agreement or directive can
|
| 397 |
monopolise the results: once a source has contributed SOURCE_CAP chunks,
|
|
|
|
| 528 |
confidence = None
|
| 529 |
fused = defaultdict(float)
|
| 530 |
bm25 = self._bm25_scores(expanded)
|
| 531 |
+
for rank, idx in enumerate(self._capped_top(
|
| 532 |
+
sorted(bm25, key=bm25.get, reverse=True))):
|
| 533 |
fused[idx] += 1.0 / (RRF_K + rank)
|
| 534 |
if self.semantic:
|
| 535 |
sem_order, confidence = self._semantic_ranking(expanded)
|
| 536 |
+
for rank, idx in enumerate(self._capped_top(sem_order)):
|
| 537 |
fused[idx] += W_SEM / (RRF_K + rank)
|
| 538 |
|
| 539 |
# Ensure explicitly-referenced sections are retrieved even if recall
|
|
@@ -10,8 +10,8 @@ or reranker are loaded.
|
|
| 10 |
import unittest
|
| 11 |
|
| 12 |
from canlex.index import (
|
| 13 |
-
LegislationIndex, SOURCE_CAP, APPENDIX_CAP,
|
| 14 |
-
_provision_units,
|
| 15 |
)
|
| 16 |
|
| 17 |
|
|
@@ -207,6 +207,47 @@ class DocTypeFlagTests(unittest.TestCase):
|
|
| 207 |
self.assertEqual(self.idx._note_tokens[5], set(tokenize("Importing goods")))
|
| 208 |
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
class CosurfaceAppendixTests(unittest.TestCase):
|
| 211 |
"""_cosurface_appendices pulls a directive appendix into the result set
|
| 212 |
when a directive result cites it but retrieval missed it."""
|
|
|
|
| 10 |
import unittest
|
| 11 |
|
| 12 |
from canlex.index import (
|
| 13 |
+
LegislationIndex, SOURCE_CAP, APPENDIX_CAP, RECALL_CAP, CANDIDATES,
|
| 14 |
+
tokenize, _section_refs, _provision_units,
|
| 15 |
)
|
| 16 |
|
| 17 |
|
|
|
|
| 207 |
self.assertEqual(self.idx._note_tokens[5], set(tokenize("Importing goods")))
|
| 208 |
|
| 209 |
|
| 210 |
+
class CappedTopTests(unittest.TestCase):
|
| 211 |
+
"""_capped_top limits how many chunks one capped source can contribute to
|
| 212 |
+
a retriever's CANDIDATES slice, so a deep decision cannot monopolise the
|
| 213 |
+
fusion pool before the result-stage diversity cap even runs."""
|
| 214 |
+
|
| 215 |
+
def test_deep_source_is_capped_and_others_backfill(self):
|
| 216 |
+
# RECALL_CAP+5 chunks of one decision ranked first, then legislation.
|
| 217 |
+
n_case = RECALL_CAP + 5
|
| 218 |
+
chunks = [chunk(doc_type="caselaw", act_code="2020 ABCA 383",
|
| 219 |
+
id=f"canlii-x-{i}") for i in range(n_case)]
|
| 220 |
+
chunks += [chunk(doc_type="legislation", section=str(i),
|
| 221 |
+
id=f"leg-{i}") for i in range(10)]
|
| 222 |
+
idx = bare_index(chunks)
|
| 223 |
+
out = idx._capped_top(range(len(chunks)))
|
| 224 |
+
n_kept = sum(1 for i in out
|
| 225 |
+
if idx.chunks[i]["doc_type"] == "caselaw")
|
| 226 |
+
self.assertEqual(n_kept, RECALL_CAP) # surplus dropped
|
| 227 |
+
self.assertEqual(sum(1 for i in out
|
| 228 |
+
if idx.chunks[i]["doc_type"] == "legislation"),
|
| 229 |
+
10) # others backfilled
|
| 230 |
+
|
| 231 |
+
def test_uncapped_sources_are_unlimited(self):
|
| 232 |
+
chunks = [chunk(doc_type="legislation", section=str(i), id=f"l-{i}")
|
| 233 |
+
for i in range(RECALL_CAP + 10)]
|
| 234 |
+
idx = bare_index(chunks)
|
| 235 |
+
out = idx._capped_top(range(len(chunks)))
|
| 236 |
+
self.assertEqual(len(out), RECALL_CAP + 10) # no cap applied
|
| 237 |
+
|
| 238 |
+
def test_respects_candidates_ceiling(self):
|
| 239 |
+
chunks = [chunk(doc_type="legislation", section=str(i), id=f"l-{i}")
|
| 240 |
+
for i in range(CANDIDATES + 20)]
|
| 241 |
+
idx = bare_index(chunks)
|
| 242 |
+
self.assertEqual(len(idx._capped_top(range(len(chunks)))), CANDIDATES)
|
| 243 |
+
|
| 244 |
+
def test_order_preserved(self):
|
| 245 |
+
chunks = [chunk(doc_type="legislation", section=str(i), id=f"l-{i}")
|
| 246 |
+
for i in range(5)]
|
| 247 |
+
idx = bare_index(chunks)
|
| 248 |
+
self.assertEqual(idx._capped_top([3, 1, 4, 0, 2]), [3, 1, 4, 0, 2])
|
| 249 |
+
|
| 250 |
+
|
| 251 |
class CosurfaceAppendixTests(unittest.TestCase):
|
| 252 |
"""_cosurface_appendices pulls a directive appendix into the result set
|
| 253 |
when a directive result cites it but retrieval missed it."""
|