Beemer Claude Fable 5 commited on
Commit
da74b8c
·
1 Parent(s): 28eef20

Filter-aware recall, accent folding + FR bridges, repealed penalty, eval slices, curated staleness watch

Browse files
Files changed (5) hide show
  1. canlex/eval.py +107 -16
  2. canlex/index.py +76 -21
  3. canlex/refresh.py +113 -3
  4. canlex/sweep.py +1 -0
  5. canlex/synonyms.py +46 -0
canlex/eval.py CHANGED
@@ -6,10 +6,26 @@ retrieval index and reports Hit@k and MRR. Re-run it after any retrieval change
6
  -- a new reranker, different embeddings, a chunking tweak -- to see whether
7
  quality moved, and read the "Misses" list to see exactly what to fix.
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  py -m canlex.eval
10
  """
11
  import json
 
12
  import sys
 
13
 
14
  from .config import ROOT
15
  from .index import LegislationIndex
@@ -17,6 +33,29 @@ from .index import LegislationIndex
17
  QUESTIONS = ROOT / "data" / "eval" / "questions.json"
18
  EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def _matches(result, answers):
22
  """True if a search result is one of the gold answers (act + section).
@@ -33,15 +72,41 @@ def _matches(result, answers):
33
  return False
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def run():
37
- if not QUESTIONS.exists():
 
38
  print(f"No question set at {QUESTIONS}.", file=sys.stderr)
39
  return
40
- items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
 
 
 
 
 
 
41
  index = LegislationIndex()
42
- ranks = [] # rank of the first gold hit per question (0 = miss)
43
  misses = []
 
44
  for item in items:
 
 
 
 
 
45
  answers = [tuple(a) for a in item["answers"]]
46
  results = index.search(item["query"], top_k=EVAL_TOP_K)
47
  rank = 0
@@ -49,29 +114,55 @@ def run():
49
  if _matches(result, answers):
50
  rank = i
51
  break
52
- ranks.append(rank)
53
  if rank == 0 or rank > 5:
54
  top = results[0] if results else None
55
- misses.append((item["query"], answers, rank, top))
56
 
57
- n = len(ranks) or 1
58
- hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
59
- mrr = sum(1.0 / r for r in ranks if r) / n
60
- print(f"CanLex retrieval evaluation -- {len(ranks)} questions\n")
61
- print(f" Hit@1: {hit(1):.2f}")
62
- print(f" Hit@3: {hit(3):.2f}")
63
- print(f" Hit@5: {hit(5):.2f}")
64
- print(f" Hit@10: {hit(10):.2f}")
65
- print(f" MRR: {mrr:.2f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  if misses:
68
  print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
69
- for query, answers, rank, top in misses:
70
  gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
71
  where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
72
  got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
73
  if top else "nothing")
74
- print(f" [{where}] {query}")
75
  print(f" gold: {gold} | top result: {got}")
76
  print()
77
 
 
6
  -- a new reranker, different embeddings, a chunking tweak -- to see whether
7
  quality moved, and read the "Misses" list to see exactly what to fix.
8
 
9
+ Question schema (per item):
10
+ query the question text
11
+ answers [[act_short, section], ...]; empty section matches any chunk
12
+ style optional tag ('standard' when absent): 'fact-pattern',
13
+ 'keyword', 'typo', 'french', 'no-answer', ... -- metrics are
14
+ reported per style so a hard new slice cannot silently drag
15
+ the comparable legacy numbers
16
+ holdout optional bool; holdout questions are excluded from parameter
17
+ sweeps (sweep.py) so knob choices cannot fit their noise --
18
+ eval.py reports them separately
19
+ 'no-answer' style items carry no answers: they exist to calibrate the
20
+ confidence signal (a good corpus-coverage score should be LOW on them),
21
+ reported as a separate table, never as misses.
22
+
23
  py -m canlex.eval
24
  """
25
  import json
26
+ import os
27
  import sys
28
+ from collections import defaultdict
29
 
30
  from .config import ROOT
31
  from .index import LegislationIndex
 
33
  QUESTIONS = ROOT / "data" / "eval" / "questions.json"
34
  EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
35
 
36
+ # The historical baseline slice: styles that existed before the 2026-07
37
+ # expansion. Numbers over this slice stay comparable across commits.
38
+ _LEGACY_STYLES = {"standard"}
39
+
40
+
41
+ def load_questions():
42
+ """The question set with defaults applied; None if the file is absent."""
43
+ if not QUESTIONS.exists():
44
+ return None
45
+ items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
46
+ for item in items:
47
+ item.setdefault("style", "standard")
48
+ item.setdefault("holdout", False)
49
+ return items
50
+
51
+
52
+ def sweep_questions():
53
+ """The slice parameter sweeps may see (sweep.py calls this): everything
54
+ except holdout items and the no-answer calibration set."""
55
+ items = load_questions() or []
56
+ return [i for i in items
57
+ if not i["holdout"] and i["style"] != "no-answer"]
58
+
59
 
60
  def _matches(result, answers):
61
  """True if a search result is one of the gold answers (act + section).
 
72
  return False
73
 
74
 
75
+ def _metrics(ranks):
76
+ n = len(ranks) or 1
77
+ hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
78
+ mrr = sum(1.0 / r for r in ranks if r) / n
79
+ return hit(1), hit(3), hit(5), hit(10), mrr
80
+
81
+
82
+ def _report_line(label, ranks):
83
+ h1, h3, h5, h10, mrr = _metrics(ranks)
84
+ print(f" {label:24} n={len(ranks):<4} Hit@1 {h1:.2f} Hit@3 {h3:.2f} "
85
+ f"Hit@5 {h5:.2f} Hit@10 {h10:.2f} MRR {mrr:.2f}")
86
+
87
+
88
  def run():
89
+ items = load_questions()
90
+ if items is None:
91
  print(f"No question set at {QUESTIONS}.", file=sys.stderr)
92
  return
93
+ # Sweep mode (CANLEX_EVAL_SWEEP=1, set by sweep.py): score only the
94
+ # sweep-visible slice and print the legacy vertical metric block its
95
+ # parser expects -- holdout questions stay blind to every knob choice.
96
+ sweep_mode = bool(os.environ.get("CANLEX_EVAL_SWEEP"))
97
+ if sweep_mode:
98
+ items = [i for i in items
99
+ if not i["holdout"] and i["style"] != "no-answer"]
100
  index = LegislationIndex()
101
+ ranks = [] # (item, rank); rank 0 = miss
102
  misses = []
103
+ no_answer = [] # (query, top confidence) for the calibration table
104
  for item in items:
105
+ if item["style"] == "no-answer":
106
+ results = index.search(item["query"], top_k=3)
107
+ conf = results[0].get("confidence") if results else None
108
+ no_answer.append((item["query"], conf))
109
+ continue
110
  answers = [tuple(a) for a in item["answers"]]
111
  results = index.search(item["query"], top_k=EVAL_TOP_K)
112
  rank = 0
 
114
  if _matches(result, answers):
115
  rank = i
116
  break
117
+ ranks.append((item, rank))
118
  if rank == 0 or rank > 5:
119
  top = results[0] if results else None
120
+ misses.append((item, answers, rank, top))
121
 
122
+ all_ranks = [r for _, r in ranks]
123
+ if sweep_mode:
124
+ h1, h3, h5, h10, mrr = _metrics(all_ranks)
125
+ print(f"CanLex retrieval evaluation -- {len(all_ranks)} questions "
126
+ f"(sweep slice)\n")
127
+ print(f" Hit@1: {h1:.2f}")
128
+ print(f" Hit@3: {h3:.2f}")
129
+ print(f" Hit@5: {h5:.2f}")
130
+ print(f" Hit@10: {h10:.2f}")
131
+ print(f" MRR: {mrr:.2f}")
132
+ return
133
+ legacy = [r for it, r in ranks if it["style"] in _LEGACY_STYLES]
134
+ held = [r for it, r in ranks if it["holdout"]]
135
+ swept = [r for it, r in ranks if not it["holdout"]]
136
+ by_style = defaultdict(list)
137
+ for it, r in ranks:
138
+ by_style[it["style"]].append(r)
139
+
140
+ print(f"CanLex retrieval evaluation -- {len(all_ranks)} scored questions\n")
141
+ _report_line("ALL", all_ranks)
142
+ _report_line("legacy (comparable)", legacy)
143
+ for style in sorted(by_style):
144
+ if style not in _LEGACY_STYLES:
145
+ _report_line(f"style: {style}", by_style[style])
146
+ if held:
147
+ _report_line("holdout (sweep-blind)", held)
148
+ _report_line("sweep-visible", swept)
149
+
150
+ if no_answer:
151
+ print("\nNo-answer calibration (confidence should be LOW here; the "
152
+ "weak-match hedge fires below 0.72):")
153
+ for query, conf in no_answer:
154
+ shown = f"{conf:.3f}" if conf is not None else "n/a"
155
+ flag = "" if (conf is None or conf < 0.72) else " <-- OVER-CONFIDENT"
156
+ print(f" {shown} {query[:70]}{flag}")
157
 
158
  if misses:
159
  print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
160
+ for item, answers, rank, top in misses:
161
  gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
162
  where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
163
  got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
164
  if top else "nothing")
165
+ print(f" [{where}] ({item['style']}) {item['query']}")
166
  print(f" gold: {gold} | top result: {got}")
167
  print()
168
 
canlex/index.py CHANGED
@@ -4,6 +4,7 @@ import math
4
  import os
5
  import re
6
  import sys
 
7
  from collections import Counter, defaultdict
8
 
9
  import snowballstemmer
@@ -37,6 +38,11 @@ REG_PENALTY = float(os.environ.get("CANLEX_REG_PENALTY", "0.004"))
37
  NIF_PENALTY = float(os.environ.get("CANLEX_NIF_PENALTY", "0.012"))
38
  # fusion penalty on not-in-force amendment chunks, so
39
  # pending law never outranks the live provision it amends
 
 
 
 
 
40
  BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
41
  # likewise for a collective agreement's back-matter
42
  # (memoranda, letters of understanding) vs its numbered articles
@@ -129,11 +135,22 @@ def _stem(word):
129
  return stemmed
130
 
131
 
 
 
 
 
 
 
 
 
 
 
132
  def tokenize(text):
133
- """Lower-case, split on word characters, and Snowball-stem each token, so a
134
- query matches a provision even when their word forms differ -- 'possession'
135
- vs 'possess', 'reporting' vs 'report', 'importation' vs 'import'."""
136
- return [_stem(w) for w in _TOKEN.findall(text.lower())]
 
137
 
138
 
139
  def _section_refs(query):
@@ -240,6 +257,7 @@ class LegislationIndex:
240
  self._is_regulation = []
241
  self._is_backmatter = []
242
  self._is_nif = []
 
243
  for c in self.chunks:
244
  self._note_tokens.append(set(tokenize(topical_title(c))))
245
  self._is_regulation.append(
@@ -249,6 +267,7 @@ class LegislationIndex:
249
  c.get("doc_type") == "agreement"
250
  and not str(c["section"])[:1].isdigit())
251
  self._is_nif.append(c.get("status") == "not-in-force")
 
252
 
253
  def _build_appendix_index(self):
254
  """Index directive appendices by (act_code, letter), so a directive
@@ -313,21 +332,32 @@ class LegislationIndex:
313
  print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
314
  f"using hybrid fusion order.", file=sys.stderr)
315
 
316
- def _bm25_scores(self, query):
 
 
 
 
317
  scores = defaultdict(float)
318
  for term in set(tokenize(query)):
319
  idf = self.idf.get(term)
320
  if idf is None:
321
  continue
322
  for idx, tf in self.postings[term]:
 
 
323
  dl = self.doc_len[idx]
324
  denom = tf + K1 * (1 - B + B * dl / self.avgdl)
325
  scores[idx] += idf * tf * (K1 + 1) / denom
326
  return scores
327
 
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.
@@ -523,19 +553,38 @@ class LegislationIndex:
523
  return {pos: (label, " ".join(snippet[:240].split()))
524
  for pos, (score, label, snippet) in best.items()}
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  def search(self, query, top_k=6, act=None, doc_type=None):
527
  """Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
528
  # Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
529
- # the recall stages; the reranker still sees the user's original query.
530
  expanded = expand_query(query)
531
  confidence = None
532
  fused = defaultdict(float)
533
- bm25 = self._bm25_scores(expanded)
 
 
 
 
 
534
  for rank, idx in enumerate(self._capped_top(
535
  sorted(bm25, key=bm25.get, reverse=True))):
536
  fused[idx] += 1.0 / (RRF_K + rank)
537
  if self.semantic:
538
- sem_order, confidence = self._semantic_ranking(expanded)
539
  for rank, idx in enumerate(self._capped_top(sem_order)):
540
  fused[idx] += W_SEM / (RRF_K + rank)
541
 
@@ -589,20 +638,15 @@ class LegislationIndex:
589
  for idx in list(fused):
590
  if self._is_nif[idx]:
591
  fused[idx] -= NIF_PENALTY
 
 
592
  elif self._is_regulation[idx]:
593
  fused[idx] -= REG_PENALTY
594
  elif self._is_backmatter[idx]:
595
  fused[idx] -= BACKMATTER_PENALTY
596
 
597
- def allowed(idx):
598
- c = self.chunks[idx]
599
- if act and act.lower() not in (c["act_short"].lower(), c["act_code"].lower()):
600
- return False
601
- if doc_type and c.get("doc_type", "legislation") != doc_type:
602
- return False
603
- return True
604
-
605
- candidates = [i for i in sorted(fused, key=fused.get, reverse=True) if allowed(i)]
606
  if not candidates:
607
  return []
608
  scores = {i: fused[i] for i in candidates}
@@ -615,8 +659,17 @@ class LegislationIndex:
615
  # negative), so its power to demote a candidate is deliberately removed.
616
  if self.reranker:
617
  pool = candidates[:RERANK_POOL]
618
- ce = dict(zip(pool, self.reranker.score(
619
- query, [self._rerank_doc(i) for i in pool])))
 
 
 
 
 
 
 
 
 
620
  fusion_rank = {idx: r for r, idx in enumerate(pool)}
621
  rerank_rank = {idx: r for r, idx in enumerate(
622
  sorted(pool, key=ce.get, reverse=True))}
@@ -659,7 +712,9 @@ class LegislationIndex:
659
  def get_section(self, act, section):
660
  act = act.lower()
661
  for c in self.chunks:
662
- if c["section"] == section and act in (c["act_short"].lower(), c["act_code"].lower()):
 
 
663
  return c
664
  return None
665
 
 
4
  import os
5
  import re
6
  import sys
7
+ import unicodedata
8
  from collections import Counter, defaultdict
9
 
10
  import snowballstemmer
 
38
  NIF_PENALTY = float(os.environ.get("CANLEX_NIF_PENALTY", "0.012"))
39
  # fusion penalty on not-in-force amendment chunks, so
40
  # pending law never outranks the live provision it amends
41
+ REPEALED_PENALTY = float(os.environ.get("CANLEX_REPEALED_PENALTY", "0.008"))
42
+ # likewise for repealed provisions (kept so lookups still
43
+ # inform): a repealed section's on-topic title must not
44
+ # beat live law in a close contest -- the CDSA-to-Cannabis
45
+ # -Act migration is the live risk zone
46
  BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
47
  # likewise for a collective agreement's back-matter
48
  # (memoranda, letters of understanding) vs its numbered articles
 
135
  return stemmed
136
 
137
 
138
+ def _fold_accents(text):
139
+ """ASCII-fold accented characters so a French or mixed query tokenizes
140
+ usefully instead of shattering: the [a-z0-9] token pattern used to split
141
+ 'détention' into ['d','tention']. NFKD + ASCII-encode drops the combining
142
+ marks ('détention' -> 'detention'), which both matches the English corpus
143
+ directly and lets the FR->EN synonym bridges fire."""
144
+ return unicodedata.normalize("NFKD", text).encode(
145
+ "ascii", "ignore").decode("ascii")
146
+
147
+
148
  def tokenize(text):
149
+ """Lower-case, accent-fold, split on word characters, and Snowball-stem
150
+ each token, so a query matches a provision even when their word forms
151
+ differ -- 'possession' vs 'possess', 'reporting' vs 'report',
152
+ 'importation' vs 'import', 'détention' vs 'detention'."""
153
+ return [_stem(w) for w in _TOKEN.findall(_fold_accents(text.lower()))]
154
 
155
 
156
  def _section_refs(query):
 
257
  self._is_regulation = []
258
  self._is_backmatter = []
259
  self._is_nif = []
260
+ self._is_repealed = []
261
  for c in self.chunks:
262
  self._note_tokens.append(set(tokenize(topical_title(c))))
263
  self._is_regulation.append(
 
267
  c.get("doc_type") == "agreement"
268
  and not str(c["section"])[:1].isdigit())
269
  self._is_nif.append(c.get("status") == "not-in-force")
270
+ self._is_repealed.append(c.get("status") == "repealed")
271
 
272
  def _build_appendix_index(self):
273
  """Index directive appendices by (act_code, letter), so a directive
 
332
  print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
333
  f"using hybrid fusion order.", file=sys.stderr)
334
 
335
+ def _bm25_scores(self, query, allowed=None):
336
+ """allowed: optional boolean list by chunk index. Filtered searches
337
+ pass it so recall competes only within scope -- filtering after
338
+ recall meant a doc_type='caselaw' search drew from whatever case law
339
+ happened to survive the open-corpus top-N competition."""
340
  scores = defaultdict(float)
341
  for term in set(tokenize(query)):
342
  idf = self.idf.get(term)
343
  if idf is None:
344
  continue
345
  for idx, tf in self.postings[term]:
346
+ if allowed is not None and not allowed[idx]:
347
+ continue
348
  dl = self.doc_len[idx]
349
  denom = tf + K1 * (1 - B + B * dl / self.avgdl)
350
  scores[idx] += idf * tf * (K1 + 1) / denom
351
  return scores
352
 
353
+ def _semantic_ranking(self, query, allowed=None):
354
  qv = self.embedder.encode_query(query)
355
  sims = self.vectors @ qv
356
+ if allowed is not None:
357
+ # Mask out-of-scope chunks so a filtered search ranks (and reports
358
+ # confidence) within its own scope, not corpus-wide.
359
+ sims = self._np.where(
360
+ self._np.asarray(allowed, dtype=bool), sims, -1.0)
361
  # Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
362
  # surplus chunks of any one deep source, and the extra depth is what
363
  # lets other sources backfill the freed CANDIDATES slots.
 
553
  return {pos: (label, " ".join(snippet[:240].split()))
554
  for pos, (score, label, snippet) in best.items()}
555
 
556
+ def _filter_ok(self, c, act, doc_type):
557
+ """One predicate for both recall masking and the late result filter.
558
+ The act filter matches short name, code, or full name -- an agent
559
+ passing 'Immigration and Refugee Protection Act' should not get
560
+ silence."""
561
+ if act:
562
+ a = act.lower()
563
+ if a not in (c["act_short"].lower(), c["act_code"].lower(),
564
+ c.get("act_name", "").lower()):
565
+ return False
566
+ if doc_type and c.get("doc_type", "legislation") != doc_type:
567
+ return False
568
+ return True
569
+
570
  def search(self, query, top_k=6, act=None, doc_type=None):
571
  """Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
572
  # Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
573
+ # the recall stages; the reranker sees the original AND expanded forms.
574
  expanded = expand_query(query)
575
  confidence = None
576
  fused = defaultdict(float)
577
+ # A filtered search competes only within its scope: the mask reaches
578
+ # both recall stages, so results come from the best of the whole
579
+ # filtered corpus rather than whatever survived open recall.
580
+ mask = ([self._filter_ok(c, act, doc_type) for c in self.chunks]
581
+ if (act or doc_type) else None)
582
+ bm25 = self._bm25_scores(expanded, allowed=mask)
583
  for rank, idx in enumerate(self._capped_top(
584
  sorted(bm25, key=bm25.get, reverse=True))):
585
  fused[idx] += 1.0 / (RRF_K + rank)
586
  if self.semantic:
587
+ sem_order, confidence = self._semantic_ranking(expanded, allowed=mask)
588
  for rank, idx in enumerate(self._capped_top(sem_order)):
589
  fused[idx] += W_SEM / (RRF_K + rank)
590
 
 
638
  for idx in list(fused):
639
  if self._is_nif[idx]:
640
  fused[idx] -= NIF_PENALTY
641
+ elif self._is_repealed[idx]:
642
+ fused[idx] -= REPEALED_PENALTY
643
  elif self._is_regulation[idx]:
644
  fused[idx] -= REG_PENALTY
645
  elif self._is_backmatter[idx]:
646
  fused[idx] -= BACKMATTER_PENALTY
647
 
648
+ candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
649
+ if self._filter_ok(self.chunks[i], act, doc_type)]
 
 
 
 
 
 
 
650
  if not candidates:
651
  return []
652
  scores = {i: fused[i] for i in candidates}
 
659
  # negative), so its power to demote a candidate is deliberately removed.
660
  if self.reranker:
661
  pool = candidates[:RERANK_POOL]
662
+ docs = [self._rerank_doc(i) for i in pool]
663
+ ce_orig = self.reranker.score(query, docs)
664
+ # The cross-encoder has no reason to know Canadian legal
665
+ # shorthand ('PRRA' vs 'application for protection'), so when
666
+ # expansion changed the query, score both forms and keep each
667
+ # candidate's better score -- precision of the original query
668
+ # preserved, abbreviation blindness closed.
669
+ if expanded != query:
670
+ ce_exp = self.reranker.score(expanded, docs)
671
+ ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
672
+ ce = dict(zip(pool, ce_orig))
673
  fusion_rank = {idx: r for r, idx in enumerate(pool)}
674
  rerank_rank = {idx: r for r, idx in enumerate(
675
  sorted(pool, key=ce.get, reverse=True))}
 
712
  def get_section(self, act, section):
713
  act = act.lower()
714
  for c in self.chunks:
715
+ if c["section"] == section and act in (
716
+ c["act_short"].lower(), c["act_code"].lower(),
717
+ c.get("act_name", "").lower()):
718
  return c
719
  return None
720
 
canlex/refresh.py CHANGED
@@ -65,8 +65,86 @@ _NON_XML = [
65
  ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
66
  ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
67
  "Customs Tariff Schedule ch. 98-99"),
 
 
 
 
 
68
  ]
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # NJC directive dates as published: the index prints prose ('June 1, 1993' --
71
  # double spaces occur in the raw markup) while chunks may store either that
72
  # prose or the ISO fallback directive.py derives from page text, so both sides
@@ -362,18 +440,26 @@ def run(as_json=False):
362
  deleg = check_delegation()
363
  dirs = check_directives()
364
  nonxml = non_xml_currency()
 
 
365
  pending = pending_in_force(today)
366
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
367
  errors = [r for r in leg if r["status"] == "error"]
368
  deleg_stale = [r for r in deleg if r["status"] == "stale"]
369
  dirs_stale = [r for r in dirs if r["status"] == "stale"]
 
 
370
 
371
  if as_json:
372
  print(json.dumps({"checked": today, "legislation": leg,
373
  "delegation": deleg, "directives": dirs,
374
- "non_xml": nonxml, "pending_in_force": pending,
 
 
375
  "stale_count": (len(stale) + len(deleg_stale)
376
- + len(dirs_stale)),
 
 
377
  "error_count": len(errors)}, indent=2))
378
  else:
379
  print(f"CanLex corpus staleness check — {today}\n")
@@ -441,6 +527,29 @@ def run(as_json=False):
441
  print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
442
  f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  if pending:
445
  print("\nPending bills now in force (re-ingest the affected Acts):")
446
  for b in pending:
@@ -449,7 +558,8 @@ def run(as_json=False):
449
  print(f" {b['note']}")
450
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
451
 
452
- return 1 if (stale or errors or deleg_stale or dirs_stale) else 0
 
453
 
454
 
455
  def main():
 
65
  ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
66
  ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
67
  "Customs Tariff Schedule ch. 98-99"),
68
+ ("enf", "enf.json", "py -m canlex.enf", "ENF operational manuals"),
69
+ ("amps", "amps.json", "py -m canlex.amps", "AMPS Master Penalty Document"),
70
+ ("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
71
+ ("commentary", "commentary.json", "py -m canlex.commentary",
72
+ "Curated commentary chunks"),
73
  ]
74
 
75
+ # Curated datasets age: state legislatures amend deferred-adjudication
76
+ # statutes every session, so a reviewed date older than this many days earns
77
+ # a warning even though nothing upstream can be diffed automatically.
78
+ _CURATED_MAX_AGE_DAYS = 180
79
+
80
+
81
+ def check_curated(today):
82
+ """Age-check the curated datasets and detect edit-without-regen drift.
83
+
84
+ Two signals: (1) each data/curated/*.json 'reviewed' date older than
85
+ _CURATED_MAX_AGE_DAYS -> aging (re-verify against the state sources);
86
+ (2) the newest commentary.json current_to differing from the newest
87
+ curated 'reviewed' -> the curated file was edited without rerunning
88
+ canlex.commentary + canlex.embed, so production serves stale chunks."""
89
+ from .config import DATA_DIR
90
+ rows = []
91
+ newest_reviewed = ""
92
+ for path in sorted((DATA_DIR / "curated").glob("*.json")):
93
+ try:
94
+ reviewed = json.loads(
95
+ path.read_text(encoding="utf-8")).get("reviewed", "")
96
+ except Exception as exc:
97
+ rows.append({"file": path.name, "reviewed": "",
98
+ "status": "error", "error": f"{type(exc).__name__}: {exc}"})
99
+ continue
100
+ newest_reviewed = max(newest_reviewed, reviewed or "")
101
+ status = "ok"
102
+ if reviewed:
103
+ age = (datetime.date.fromisoformat(today)
104
+ - datetime.date.fromisoformat(reviewed)).days
105
+ if age > _CURATED_MAX_AGE_DAYS:
106
+ status = "aging"
107
+ rows.append({"file": path.name, "reviewed": reviewed,
108
+ "status": status, "error": ""})
109
+ commentary = PROCESSED_DIR / "commentary.json"
110
+ if newest_reviewed and commentary.exists():
111
+ chunks = json.loads(commentary.read_text(encoding="utf-8"))
112
+ newest_chunk = max((c.get("current_to", "") for c in chunks), default="")
113
+ if newest_chunk != newest_reviewed:
114
+ rows.append({"file": "commentary.json",
115
+ "reviewed": newest_chunk, "status": "drift",
116
+ "error": f"curated reviewed {newest_reviewed} but "
117
+ f"chunks say {newest_chunk or '?'} -- rerun "
118
+ f"canlex.commentary && canlex.embed"})
119
+ return rows
120
+
121
+
122
+ _DMEMO_INDEX = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
123
+
124
+
125
+ def check_dmemo_index():
126
+ """Newest 'Date modified' on the CBSA D-memo index vs stored currency.
127
+ A newer date upstream means at least one memo changed since ingestion --
128
+ cheap (one fetch), coarse (does not name the memo)."""
129
+ stored = ""
130
+ path = PROCESSED_DIR / "dmemos.json"
131
+ if path.exists():
132
+ chunks = json.loads(path.read_text(encoding="utf-8"))
133
+ stored = max((c.get("current_to", "") for c in chunks), default="")
134
+ try:
135
+ req = urllib.request.Request(
136
+ _DMEMO_INDEX, headers={"User-Agent": BROWSER_UA})
137
+ with urllib.request.urlopen(req, timeout=60) as resp:
138
+ html = resp.read().decode("utf-8", "replace")
139
+ dates = re.findall(r"\b(20\d{2}-\d{2}-\d{2})\b", html)
140
+ remote = max(dates) if dates else ""
141
+ except Exception as exc:
142
+ return {"stored": stored, "remote": "", "status": "error",
143
+ "error": f"{type(exc).__name__}: {exc}"}
144
+ status = "stale" if (remote and stored and remote > stored) else \
145
+ ("ok" if remote else "no-date")
146
+ return {"stored": stored, "remote": remote, "status": status, "error": ""}
147
+
148
  # NJC directive dates as published: the index prints prose ('June 1, 1993' --
149
  # double spaces occur in the raw markup) while chunks may store either that
150
  # prose or the ISO fallback directive.py derives from page text, so both sides
 
440
  deleg = check_delegation()
441
  dirs = check_directives()
442
  nonxml = non_xml_currency()
443
+ curated = check_curated(today)
444
+ dmemo_idx = check_dmemo_index()
445
  pending = pending_in_force(today)
446
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
447
  errors = [r for r in leg if r["status"] == "error"]
448
  deleg_stale = [r for r in deleg if r["status"] == "stale"]
449
  dirs_stale = [r for r in dirs if r["status"] == "stale"]
450
+ curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
451
+ dmemo_stale = dmemo_idx["status"] == "stale"
452
 
453
  if as_json:
454
  print(json.dumps({"checked": today, "legislation": leg,
455
  "delegation": deleg, "directives": dirs,
456
+ "non_xml": nonxml, "curated": curated,
457
+ "dmemo_index": dmemo_idx,
458
+ "pending_in_force": pending,
459
  "stale_count": (len(stale) + len(deleg_stale)
460
+ + len(dirs_stale)
461
+ + len(curated_flag)
462
+ + (1 if dmemo_stale else 0)),
463
  "error_count": len(errors)}, indent=2))
464
  else:
465
  print(f"CanLex corpus staleness check — {today}\n")
 
527
  print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
528
  f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
529
 
530
+ print("\nCurated datasets (age + regen-drift check):")
531
+ for r in curated:
532
+ if r["status"] == "aging":
533
+ print(f" AGING {r['file']}: reviewed {r['reviewed']} — older "
534
+ f"than {_CURATED_MAX_AGE_DAYS} days; re-verify the state "
535
+ f"rows against their statutes and bump 'reviewed'")
536
+ elif r["status"] == "drift":
537
+ print(f" DRIFT {r['file']}: {r['error']}")
538
+ elif r["status"] == "error":
539
+ print(f" ERROR {r['file']}: {r['error'][:60]}")
540
+ else:
541
+ print(f" ok {r['file']}: reviewed {r['reviewed'] or '?'}")
542
+
543
+ if dmemo_idx["status"] == "stale":
544
+ print(f"\nD-memo index: STALE — newest upstream 'Date modified' "
545
+ f"{dmemo_idx['remote']} > stored {dmemo_idx['stored']}; "
546
+ f"run py -m canlex.dmemo --force && py -m canlex.embed")
547
+ elif dmemo_idx["status"] == "error":
548
+ print(f"\nD-memo index: ERROR {dmemo_idx['error'][:60]}")
549
+ else:
550
+ print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
551
+ f"<= stored {dmemo_idx['stored'] or '?'})")
552
+
553
  if pending:
554
  print("\nPending bills now in force (re-ingest the affected Acts):")
555
  for b in pending:
 
558
  print(f" {b['note']}")
559
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
560
 
561
+ return 1 if (stale or errors or deleg_stale or dirs_stale
562
+ or curated_flag or dmemo_stale) else 0
563
 
564
 
565
  def main():
canlex/sweep.py CHANGED
@@ -41,6 +41,7 @@ _METRIC_RE = re.compile(
41
  def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
42
  """Run canlex.eval once with the given env overrides; return metrics dict."""
43
  env = dict(os.environ)
 
44
  for k, v in env_overrides.items():
45
  env[k] = f"{v}"
46
  proc = subprocess.run(
 
41
  def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
42
  """Run canlex.eval once with the given env overrides; return metrics dict."""
43
  env = dict(os.environ)
44
+ env["CANLEX_EVAL_SWEEP"] = "1" # holdout questions stay sweep-blind
45
  for k, v in env_overrides.items():
46
  env[k] = f"{v}"
47
  proc = subprocess.run(
canlex/synonyms.py CHANGED
@@ -44,6 +44,21 @@ _SYNONYMS = [
44
  "prescribed period five years completion of imposed sentence"),
45
  (r"misrep", "misrepresentation"),
46
  (r"ircc", "immigration refugees and citizenship canada"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # Border and customs
48
  (r"cbsa", "canada border services agency"),
49
  (r"bsos?", "border services officer"),
@@ -51,6 +66,37 @@ _SYNONYMS = [
51
  # Financial-crime and labour
52
  (r"fintrac", "financial transactions and reports analysis centre"),
53
  (r"njc", "national joint council"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  ]
55
 
56
  _COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)
 
44
  "prescribed period five years completion of imposed sentence"),
45
  (r"misrep", "misrepresentation"),
46
  (r"ircc", "immigration refugees and citizenship canada"),
47
+ (r"eta", "electronic travel authorization"),
48
+ (r"trv", "temporary resident visa"),
49
+ (r"arc", "authorization to return to canada removal order"),
50
+ (r"danger opinion", "danger to the public refoulement protection"),
51
+ (r"ministerial relief", "national interest exception inadmissibility"),
52
+ (r"poe", "port of entry"),
53
+ (r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
54
+ # The single most common US-screening term: bridge DUI shorthand to the
55
+ # Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
56
+ (r"(?:dwi|dui|drunk driving)",
57
+ "operation while impaired blood alcohol concentration conveyance"),
58
+ (r"personal exemption",
59
+ "returning persons exemption duty free allowance"),
60
+ (r"(?:nexus|trusted traveller)",
61
+ "presentation of persons alternative manner authorization"),
62
  # Border and customs
63
  (r"cbsa", "canada border services agency"),
64
  (r"bsos?", "border services officer"),
 
66
  # Financial-crime and labour
67
  (r"fintrac", "financial transactions and reports analysis centre"),
68
  (r"njc", "national joint council"),
69
+ # French -> English bridges. The corpus is English-only, but officers work
70
+ # bilingually and drafting agents emit French terms; with the tokenizer's
71
+ # accent folding these triggers match either accented or folded forms.
72
+ # High-precision statutory anchors only.
73
+ (r"erar", "pre-removal risk assessment application for protection"),
74
+ (r"asfc", "canada border services agency"),
75
+ (r"interdiction de territoire", "inadmissibility inadmissible"),
76
+ (r"grande criminalit[ée]", "serious criminality"),
77
+ (r"criminalit[ée]", "criminality convicted offence"),
78
+ (r"renvoi", "removal order"),
79
+ (r"saisie?s?", "seizure seized goods"),
80
+ (r"r[ée]sidents? permanents?", "permanent resident"),
81
+ (r"[ée]trangers?", "foreign national"),
82
+ (r"d[ée]tention", "detention detained review"),
83
+ (r"r[ée]fugi[ée]s?", "refugee protection convention"),
84
+ (r"douanes?", "customs duties"),
85
+ (r"marchandises?", "goods imported"),
86
+ (r"d[ée]claration", "report declaration"),
87
+ (r"armes? [àa] feu", "firearm weapon"),
88
+ (r"contr[ôo]le", "examination search"),
89
+ (r"agents? des services frontaliers", "border services officer"),
90
+ (r"permis de travail", "work permit"),
91
+ (r"permis d'[ée]tudes", "study permit"),
92
+ (r"visa de r[ée]sident temporaire", "temporary resident visa"),
93
+ (r"r[ée]adaptation", "rehabilitation prescribed period"),
94
+ (r"expulsion", "deportation removal order"),
95
+ (r"passeurs?", "human smuggling organized entry"),
96
+ (r"fouille", "search of the person strip search"),
97
+ (r"griefs?", "grievance adjudication"),
98
+ (r"convention collective", "collective agreement"),
99
+ (r"heures suppl[ée]mentaires", "overtime hours of work"),
100
  ]
101
 
102
  _COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)