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- canlex/eval.py +107 -16
- canlex/index.py +76 -21
- canlex/refresh.py +113 -3
- canlex/sweep.py +1 -0
- 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 |
-
|
|
|
|
| 38 |
print(f"No question set at {QUESTIONS}.", file=sys.stderr)
|
| 39 |
return
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
index = LegislationIndex()
|
| 42 |
-
ranks = [] # rank
|
| 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
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
if misses:
|
| 68 |
print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
|
| 69 |
-
for
|
| 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
|
| 134 |
-
query matches a provision even when their word forms
|
| 135 |
-
|
| 136 |
-
|
|
|
|
| 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
|
| 530 |
expanded = expand_query(query)
|
| 531 |
confidence = None
|
| 532 |
fused = defaultdict(float)
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 598 |
-
|
| 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 |
-
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
| 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, "
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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)
|