Beemer Claude Fable 5 commited on
Commit
d24e96c
·
1 Parent(s): 97efc0e

Cap per-source recall so one deep decision cannot flood the candidate pool

Browse files

Canfield 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>

Files changed (2) hide show
  1. canlex/index.py +42 -3
  2. tests/test_index.py +43 -2
canlex/index.py CHANGED
@@ -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
- order = self._np.argsort(sims)[::-1][:CANDIDATES]
 
 
 
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(sorted(bm25, key=bm25.get, reverse=True)[:CANDIDATES]):
 
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
tests/test_index.py CHANGED
@@ -10,8 +10,8 @@ or reranker are loaded.
10
  import unittest
11
 
12
  from canlex.index import (
13
- LegislationIndex, SOURCE_CAP, APPENDIX_CAP, tokenize, _section_refs,
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."""