File size: 18,702 Bytes
901bf8c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | """The retrieval gate's own scorer (canlex/eval.py) and the knob sweep
(canlex/sweep.py), offline (the code audit of 2026-09-21, missing item 2:
eval.py was 0 % covered while RUNBOOK names `py -m canlex.eval` as the
release gate, so a regression in _matches / must_match / score_item /
summarize would have moved every reported number silently).
Synthetic results only: no index, no corpus, no subprocess -- the sweep's
eval run is a mocked subprocess.
python -m unittest tests.test_eval
"""
import contextlib
import io
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from canlex import eval as ev
from canlex import sweep
def res(act_short="IRPA", act_code="I-2.5", section="36", **extra):
r = {"id": f"{act_code}-s{section or 'x'}", "act_short": act_short, "act_code": act_code,
"section": section, "marginal_note": "", "doc_type": "legislation"}
r.update(extra)
return r
class MatchesTests(unittest.TestCase):
def test_an_answer_names_the_act_by_short_name_or_code_and_the_section(self):
r = res()
self.assertTrue(ev._matches(r, [("IRPA", "36")]))
self.assertTrue(ev._matches(r, [("I-2.5", "36")]))
self.assertTrue(ev._matches(r, [("irpa", "36")])) # case-folded act
self.assertFalse(ev._matches(r, [("IRPA", "35")]))
self.assertFalse(ev._matches(r, [("IRPR", "36")]))
self.assertFalse(ev._matches(r, []))
def test_an_empty_section_matches_any_chunk_of_the_act(self):
# case-law answers: the chunks carry no section number
self.assertTrue(ev._matches(res(section="36"), [("IRPA", "")]))
self.assertTrue(ev._matches(res("Baker", "fca-1", ""), [("Baker", "")]))
self.assertTrue(ev._matches(res("Baker", "fca-1", "17"), [("Baker", "")]))
self.assertFalse(ev._matches(res("Baker", "fca-1", ""), [("Tran", "")]))
# the second answer of a list still counts
self.assertTrue(ev._matches(res(), [("IRPR", "18"), ("IRPA", "")]))
class MustMatchTests(unittest.TestCase):
def test_a_marginal_note_key_compares_case_and_whitespace_folded(self):
r = res("ENF 5", "enf-5", "3", marginal_note="Deemed rehabilitation")
self.assertTrue(ev.must_match(r, {"act_short": "ENF 5", "marginal_note": "deemed rehabilitation"}))
self.assertTrue(ev.must_match(r, {"act_short": "enf-5", "marginal_note": "Deemed Rehabilitation"}))
self.assertFalse(ev.must_match(r, {"act_short": "ENF 5", "marginal_note": "Rehabilitation"}))
self.assertFalse(ev.must_match(r, {"act_short": "ENF 6", "marginal_note": "deemed rehabilitation"}))
def test_a_section_key_and_a_bare_act_key(self):
r = res()
self.assertTrue(ev.must_match(r, {"act_short": "IRPA", "section": "36"}))
self.assertFalse(ev.must_match(r, {"act_short": "IRPA", "section": "35"}))
self.assertTrue(ev.must_match(r, {"act_short": "IRPA", "section": ""}))
self.assertTrue(ev.must_match(r, {"act_short": "I-2.5"})) # any chunk of the act
self.assertFalse(ev.must_match(r, {"act_short": "Baker"}))
# a marginal-note key wins over a section key on the same item
self.assertFalse(ev.must_match(r, {"act_short": "IRPA", "section": "36", "marginal_note": "x"}))
class GuidanceActsTests(unittest.TestCase):
def test_only_guidance_chunks_count_and_the_empty_name_is_dropped(self):
chunks = [res("ENF 5", "enf-5", "", doc_type="memorandum"),
res("", "pdi-1", "", doc_type="memorandum"),
res("IRPA", "I-2.5", "36"),
res("Baker", "fca-1", "", doc_type="caselaw")]
self.assertEqual(ev.guidance_acts(chunks), frozenset({"enf 5", "enf-5", "pdi-1"}))
self.assertEqual(ev.guidance_acts([]), frozenset())
class ScoreItemTests(unittest.TestCase):
ITEM = {"query": "q", "answers": [["IRPA", "36"]]}
def ranked(self, hit_at):
rows = [res("IRPR", "SOR-2002-227", str(i)) for i in range(1, 8)]
if hit_at:
rows[hit_at - 1] = res()
return rows
def test_rank_is_the_first_matching_result_or_zero(self):
self.assertEqual(ev.score_item(self.ITEM, self.ranked(1))["rank"], 1)
self.assertEqual(ev.score_item(self.ITEM, self.ranked(3))["rank"], 3)
self.assertEqual(ev.score_item(self.ITEM, self.ranked(0))["rank"], 0)
self.assertEqual(ev.score_item(self.ITEM, [])["rank"], 0)
self.assertEqual(ev.score_item({"query": "q"}, self.ranked(1))["rank"], 0) # no answers
def test_a_cosurfaced_result_is_never_ranked(self):
rows = [dict(res(), cosurfaced=True)] + self.ranked(2)
self.assertEqual(ev.score_item(self.ITEM, rows)["rank"], 2)
rows = [dict(res(), cosurfaced=True)] + self.ranked(0)
self.assertEqual(ev.score_item(self.ITEM, rows)["rank"], 0)
def test_coverage_and_guidance_are_none_when_the_item_asks_for_neither(self):
s = ev.score_item(self.ITEM, self.ranked(1), guidance=frozenset({"enf 5"}))
self.assertEqual((s["cov6"], s["cov6_rn"], s["g6"], s["g6_rn"]), (None, None, None, None))
def test_coverage_reads_the_top_view_k_and_read_next_adds_to_it(self):
item = dict(self.ITEM, must_see=[{"act_short": "IRPA", "section": "36"},
{"act_short": "ENF 5", "marginal_note": "Deemed rehabilitation"}])
enf = res("ENF 5", "enf-5", "3", marginal_note="Deemed rehabilitation", doc_type="memorandum")
rows = self.ranked(1)
s = ev.score_item(item, rows)
self.assertEqual((s["cov6"], s["cov6_rn"]), (0.5, 0.5))
s = ev.score_item(item, rows, read_next=[enf])
self.assertEqual((s["cov6"], s["cov6_rn"]), (0.5, 1.0))
# a key satisfied past VIEW_K is not in view
rows = self.ranked(1)[:ev.VIEW_K] + [enf]
self.assertEqual(ev.score_item(item, rows)["cov6"], 0.5)
rows = self.ranked(1)[:ev.VIEW_K - 1] + [enf]
self.assertEqual(ev.score_item(item, rows)["cov6"], 1.0)
def test_guided_needs_a_guidance_result_that_is_an_answer_or_a_key(self):
guidance = frozenset({"enf 5", "enf-5"})
enf = res("ENF 5", "enf-5", "3", marginal_note="Deemed rehabilitation", doc_type="memorandum")
item = {"query": "q", "answers": [["ENF 5", ""]]}
self.assertTrue(ev.score_item(item, [enf], guidance=guidance)["g6"])
self.assertFalse(ev.score_item(item, self.ranked(0), guidance=guidance)["g6"])
# the guidance result must be one the item names: another memo does not count
other = res("ENF 6", "enf-6", "1", doc_type="memorandum")
s = ev.score_item(item, [other], read_next=[enf], guidance=guidance)
self.assertEqual((s["g6"], s["g6_rn"]), (False, True))
# named through a must_see key rather than an answer
keyed = {"query": "q", "answers": [["IRPA", "36"]],
"must_see": [{"act_short": "ENF 5", "marginal_note": "Deemed rehabilitation"}]}
self.assertTrue(ev.score_item(keyed, [enf], guidance=guidance)["g6"])
self.assertIsNone(ev.score_item(keyed, [enf], guidance=frozenset())["g6"])
class MetricsTests(unittest.TestCase):
def test_metrics_on_a_fixed_rank_list(self):
h1, h3, h5, h10, mrr = ev._metrics([1, 3, 0, 6])
self.assertEqual((h1, h3, h5, h10), (0.25, 0.5, 0.5, 0.75))
self.assertAlmostEqual(mrr, (1 + 1 / 3 + 1 / 6) / 4)
self.assertEqual(ev._metrics([]), (0.0, 0.0, 0.0, 0.0, 0.0))
self.assertEqual(ev._metrics([1, 1]), (1.0, 1.0, 1.0, 1.0, 1.0))
def test_summarize_averages_the_optional_measures_over_the_items_that_have_them(self):
scores = [{"rank": 1, "cov6": 1.0, "cov6_rn": 1.0, "g6": True, "g6_rn": True},
{"rank": 3, "cov6": 0.0, "cov6_rn": 0.5, "g6": None, "g6_rn": None},
{"rank": 0, "cov6": None, "cov6_rn": None, "g6": False, "g6_rn": True}]
m = ev.summarize(scores)
self.assertEqual((m["n"], m["hit1"], m["hit3"], m["hit10"]), (3, 1 / 3, 2 / 3, 2 / 3))
self.assertAlmostEqual(m["mrr"], (1 + 1 / 3) / 3)
self.assertEqual((m["cov6"], m["cov6_rn"], m["n_cov6"]), (0.5, 0.75, 2))
self.assertEqual((m["g6"], m["g6_rn"], m["n_g6"]), (0.5, 1.0, 2))
empty = ev.summarize([])
self.assertEqual((empty["n"], empty["cov6"], empty["n_cov6"]), (0, None, 0))
def test_the_report_line_prints_the_optional_measures_only_when_present(self):
with contextlib.redirect_stdout(io.StringIO()) as out:
ev._report_line("ALL", [{"rank": 1, "cov6": None, "cov6_rn": None, "g6": None, "g6_rn": None}])
ev._report_line("x", [{"rank": 2, "cov6": 0.5, "cov6_rn": 1.0, "g6": True, "g6_rn": True}])
lines = out.getvalue().splitlines()
self.assertIn("ALL", lines[0])
self.assertIn("Hit@1 1.00", lines[0])
self.assertNotIn("cov@6", lines[0])
self.assertIn("cov@6 0.50/1.00 (n=1)", lines[1])
self.assertIn("guid@6 1.00/1.00 (n=1)", lines[1])
class QuestionSetTests(unittest.TestCase):
def _with_questions(self, items):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
path = Path(tmp.name) / "questions.json"
path.write_text(json.dumps(items), encoding="utf-8")
return mock.patch.object(ev, "QUESTIONS", path)
def test_defaults_are_applied_and_the_sweep_slice_drops_holdouts_and_no_answer(self):
items = [{"query": "a", "answers": [["IRPA", "36"]]},
{"query": "b", "answers": [["IRPA", "36"]], "holdout": True},
{"query": "c", "style": "no-answer"},
{"query": "d", "answers": [["IRPA", "36"]], "style": "typo", "added": "2026-09-09"}]
with self._with_questions(items):
loaded = ev.load_questions()
self.assertEqual([(i["style"], i["holdout"]) for i in loaded],
[("standard", False), ("standard", True), ("no-answer", False),
("typo", False)])
self.assertEqual([i["query"] for i in ev.sweep_questions()], ["a", "d"])
self.assertTrue(ev.is_legacy(loaded[0]))
self.assertTrue(ev.is_legacy(loaded[1]))
self.assertFalse(ev.is_legacy(loaded[3])) # dated: outside the frozen slice
self.assertFalse(ev.is_legacy({"style": "typo"}))
def test_a_missing_question_file_is_none_and_an_empty_sweep(self):
with mock.patch.object(ev, "QUESTIONS", Path(tempfile.gettempdir()) / "no-such-canlex-questions.json"):
self.assertIsNone(ev.load_questions())
self.assertEqual(ev.sweep_questions(), [])
class _StubIndex:
"""A LegislationIndex stand-in for eval.run(): chunks, a canned search
and the co-surfaced guidance the index would add to a view."""
see_also_source = "stub"
def __init__(self, chunks, hits):
self.chunks, self.hits = chunks, hits
def search(self, query, top_k=6):
return list(self.hits.get(query, []))[:top_k]
def _cosurface_guidance(self, top, keep=None):
return list(top) + [len(self.chunks) - 1] # the last chunk is guidance
class ReadNextTests(unittest.TestCase):
def test_read_next_is_what_the_index_cosurfaces_past_the_top_view(self):
chunks = [res(section=str(i)) for i in range(1, 9)] + [
res("ENF 5", "enf-5", "3", doc_type="memorandum")]
index = _StubIndex(chunks, {})
positions = {c["id"]: i for i, c in enumerate(chunks)}
results = [dict(c) for c in chunks[:8]]
results[0]["cosurfaced"] = True # never part of the view
results.append({"id": "unknown"}) # not in the index
rn = ev.read_next_for(index, results, positions)
self.assertEqual([c["act_short"] for c in rn], ["ENF 5"])
class RunTests(unittest.TestCase):
"""eval.run() end to end on a stub index, in both output modes."""
def _run(self, items, hits, env=None):
chunks = [res(section="36", marginal_note="Serious criminality"), res(section="35"),
res("ENF 5", "enf-5", "3", marginal_note="Deemed rehabilitation", doc_type="memorandum")]
with mock.patch.object(ev, "load_questions", return_value=items), \
mock.patch.object(ev, "LegislationIndex", lambda: _StubIndex(chunks, hits)), \
mock.patch.dict(os.environ, env or {"CANLEX_EVAL_SWEEP": ""}), \
contextlib.redirect_stdout(io.StringIO()) as out:
ev.run()
return out.getvalue()
def _items(self):
for item in [{"query": "hit", "answers": [["IRPA", "36"]], "style": "standard"},
{"query": "miss", "answers": [["IRPA", "35"]], "style": "standard", "holdout": True},
{"query": "typo", "answers": [["IRPA", "36"]], "style": "typo", "added": "2026-09-09",
"must_see": [{"act_short": "ENF 5", "marginal_note": "Deemed rehabilitation"}]},
{"query": "nothing", "style": "no-answer"}]:
item.setdefault("holdout", False)
yield item
def _hits(self):
hit = [res(section="36", marginal_note="Serious criminality", confidence=0.9)]
return {"hit": hit, "typo": hit, "miss": [res(section="36", confidence=0.5)],
"nothing": [res(section="36", confidence=0.8)]}
def test_the_full_report_names_the_slices_the_misses_and_the_calibration_table(self):
out = self._run(list(self._items()), self._hits())
self.assertIn("3 scored questions", out)
for label in ("ALL", "legacy (comparable)", "style: typo", "holdout (sweep-blind)",
"holdout (undated)", "sweep-visible"):
self.assertIn(label, out)
self.assertIn("1 miss(es)", out)
self.assertIn("[absent (searched 20)] (standard) miss", out)
self.assertIn("gold: IRPA s.35 | top result: IRPA s.36", out)
self.assertIn("OVER-CONFIDENT", out) # 0.8 on the no-answer item
self.assertIn("read-next source: stub", out)
def test_sweep_mode_prints_the_vertical_block_over_the_sweep_slice_only(self):
out = self._run(list(self._items()), self._hits(), env={"CANLEX_EVAL_SWEEP": "1"})
self.assertIn("2 questions (sweep slice)", out) # holdout and no-answer excluded
self.assertIn("Hit@1: 1.00", out)
self.assertIn("MRR: 1.00", out)
self.assertNotIn("miss(es)", out)
self.assertIsNotNone(sweep._METRIC_RE.search(out)) # what sweep.py parses
def test_no_question_set_says_so_on_stderr(self):
with mock.patch.object(ev, "load_questions", return_value=None), \
contextlib.redirect_stderr(io.StringIO()) as err:
ev.run()
self.assertIn("No question set", err.getvalue())
class SweepRunEvalTests(unittest.TestCase):
BLOCK = ("CanLex retrieval evaluation -- 2 questions (sweep slice)\n\n"
" Hit@1: 0.50\n Hit@3: 0.75\n Hit@5: 0.80\n Hit@10: 0.90\n MRR: 0.66\n")
def _proc(self, rc=0, stdout="", stderr=""):
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr=stderr)
def test_the_metrics_are_parsed_from_the_subprocess_run_with_the_overrides_in_its_env(self):
with mock.patch.object(sweep.subprocess, "run", return_value=self._proc(0, self.BLOCK)) as run:
m = sweep._run_eval({"CANLEX_MN_WEIGHT": 0.005, "CANLEX_MN_CAP": 0.024})
self.assertEqual({k: m[k] for k in ("hit1", "hit3", "hit5", "hit10", "mrr")},
{"hit1": 0.5, "hit3": 0.75, "hit5": 0.8, "hit10": 0.9, "mrr": 0.66})
self.assertEqual(m["stdout"], self.BLOCK)
cmd, kw = run.call_args.args[0], run.call_args.kwargs
self.assertEqual(cmd[-2:], ["-m", "canlex.eval"])
self.assertEqual(kw["env"]["CANLEX_EVAL_SWEEP"], "1") # holdouts stay blind
self.assertEqual((kw["env"]["CANLEX_MN_WEIGHT"], kw["env"]["CANLEX_MN_CAP"]), ("0.005", "0.024"))
self.assertEqual(Path(kw["cwd"]), sweep.ROOT)
def test_a_failed_or_unparsable_eval_raises(self):
with mock.patch.object(sweep.subprocess, "run", return_value=self._proc(1, "", "boom")):
with self.assertRaises(RuntimeError) as cm:
sweep._run_eval({})
self.assertIn("exit 1", str(cm.exception))
self.assertIn("boom", str(cm.exception))
with mock.patch.object(sweep.subprocess, "run", return_value=self._proc(0, "no metrics here")):
with self.assertRaises(RuntimeError) as cm:
sweep._run_eval({})
self.assertIn("could not parse", str(cm.exception))
def test_the_order_is_hit5_then_mrr(self):
self.assertGreater(sweep._score({"hit5": 0.8, "mrr": 0.1}), sweep._score({"hit5": 0.7, "mrr": 0.9}))
self.assertGreater(sweep._score({"hit5": 0.8, "mrr": 0.5}), sweep._score({"hit5": 0.8, "mrr": 0.4}))
class SweepMainTests(unittest.TestCase):
def test_main_keeps_the_best_value_per_knob_and_writes_the_log_and_summary(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
log, summary = Path(tmp.name) / "sweep.log", Path(tmp.name) / "sweep.json"
def fake_eval(values):
# knob A: 2 beats the default 1 on Hit@5; knob B: nothing beats the default
hit5 = 0.7 + (0.1 if values["A"] == 2 else 0.0) - (0.2 if values["B"] == 9 else 0.0)
return {"hit1": 0.5, "hit3": 0.6, "hit5": hit5, "hit10": 0.9, "mrr": hit5 / 2, "stdout": ""}
with mock.patch.object(sweep, "KNOBS", [("A", [1, 2], 1), ("B", [3, 9], 3)]), \
mock.patch.object(sweep, "LOG", log), mock.patch.object(sweep, "SUMMARY", summary), \
mock.patch.object(sweep, "_run_eval", side_effect=fake_eval) as run, \
contextlib.redirect_stdout(io.StringIO()) as out:
sweep.main()
doc = json.loads(summary.read_text(encoding="utf-8"))
self.assertEqual(doc["best"], {"A": 2, "B": 3})
self.assertAlmostEqual(doc["best_metrics"]["hit5"], 0.8)
# baseline + one run per non-default value (the default reuses the baseline)
self.assertEqual(run.call_count, 3)
self.assertEqual(len(doc["runs"]), 5)
self.assertIn("! A: 1 -> 2", out.getvalue())
self.assertNotIn("! B:", out.getvalue())
text = log.read_text(encoding="utf-8")
self.assertIn("Sweeping A in [1, 2]", text)
self.assertIn("Best: {'A': 2, 'B': 3}", text)
if __name__ == "__main__":
unittest.main()
|