Beemer commited on
Commit
7a77fc8
·
1 Parent(s): 16ed7d1

Deploy: audit round 1 -- whole-section get_section, Latin-1 request bodies, rehab s. 17 proviso, rerank pool cap, applicability caveats

Browse files
.gitignore CHANGED
@@ -7,3 +7,4 @@ canlii_key.txt
7
  data/citator_*.json
8
  US-Dispositions-Reference.pdf
9
  *.bundle
 
 
7
  data/citator_*.json
8
  US-Dispositions-Reference.pdf
9
  *.bundle
10
+ data/logs/
RUNBOOK.md CHANGED
@@ -114,9 +114,13 @@ JSON that would read as an empty corpus and disable the guard next time.
114
 
115
  ## Deploys
116
 
117
- - **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
118
- The Space rebuilds automatically. `embeddings.npz` ships in-repo via
119
- git-LFS — a fresh clone needs `git lfs install`.
 
 
 
 
120
  - **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
121
  `webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
122
  `webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
@@ -283,3 +287,20 @@ argued about.
283
  of "what is a pre-removal risk assessment" entirely. If a new corpus
284
  family lands, check which field carries its document identity before
285
  trusting the caps.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  ## Deploys
116
 
117
+ - **MCP Space** (`Beemer0/CanLex`): commit on `main`, then ship it with the
118
+ squashed `deploy`-branch recipe in "Deploying to the Hugging Face Space"
119
+ above -- never `git push space main`: the Space's history diverged at the
120
+ 2026-08-31 squash, so that push is rejected, and forcing it would re-upload
121
+ every historical `embeddings.npz`. The Space rebuilds automatically after
122
+ the push. `embeddings.npz` ships in-repo via git-LFS — a fresh clone needs
123
+ `git lfs install`.
124
  - **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
125
  `webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
126
  `webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
 
287
  of "what is a pre-removal risk assessment" entirely. If a new corpus
288
  family lands, check which field carries its document identity before
289
  trusting the caps.
290
+ - **The rerank pool is source-capped (`POOL_CAP`, 2026-09-06).** The
291
+ cross-encoder only ever sees `RERANK_POOL` (16) fused candidates, and
292
+ `RECALL_CAP` (20) let one deep decision take 14 of those slots: on "can an
293
+ officer search a traveller's cell phone at the port of entry without a
294
+ warrant" the pool was 14 Fearon paragraphs plus two unrelated "without
295
+ warrant" marginal notes, while Canfield, Pike and ENF 12 s. 6.15 sat at
296
+ fused ranks 19-32 and were never scored. `_fill_pool` now admits at most
297
+ `POOL_CAP` (5) chunks per capped source while filling the pool; `_diversify`
298
+ still governs display afterwards. Eval-gated: no slice moved. The
299
+ device-vocabulary bridge in synonyms.py is the other half of that fix.
300
+ - **Some law in the corpus does not bind its audience (`_INAPPLICABLE`).**
301
+ Canada Labour Code Part III excludes federal departments (s. 167(1)(d)),
302
+ and FPSLRA Part 2.1 is RCMP-only, yet both out-BM25'd the FB agreement on
303
+ steward questions (CLC s. 173.1 over FB art. 25.21 for shift-change
304
+ notice). Those chunks take `INAPPLICABLE_PENALTY` in the fusion and carry
305
+ an APPLICABILITY caveat whenever they are rendered. Extend the table when a
306
+ new instrument with a public-service carve-out lands.
canlex/index.py CHANGED
@@ -25,6 +25,24 @@ RERANK_POOL = int(os.environ.get("CANLEX_RERANK_POOL", "16"))
25
  # low-fusion candidates the promote-only rerank can lift).
26
  # Swept 2026-06: 50 -> 16 (with doc cap 3000 -> 1000) held
27
  # the 159-Q eval exactly (MRR +0.01) at ~5.5x lower latency.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  MN_WEIGHT = float(os.environ.get("CANLEX_MN_WEIGHT", "0.0024"))
29
  # title-match boost per unit of idf-weighted overlap between
30
  # the query and a candidate's marginal note (section title)
@@ -50,6 +68,14 @@ REPEALED_PENALTY = float(os.environ.get("CANLEX_REPEALED_PENALTY", "0.008"))
50
  # inform): a repealed section's on-topic title must not
51
  # beat live law in a close contest -- the CDSA-to-Cannabis
52
  # -Act migration is the live risk zone
 
 
 
 
 
 
 
 
53
  BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
54
  # likewise for a collective agreement's back-matter
55
  # (memoranda, letters of understanding) vs its numbered articles
@@ -112,6 +138,45 @@ _APPENDIX_REF = re.compile(r"\bAppendi(?:x|ces)\s+([A-Za-z])\b(?!\s+of\b)",
112
  # A directive chunk that *is* an appendix: its title opens "Appendix C ...".
113
  _APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  _STEMMER = snowballstemmer.stemmer("english")
117
  _STEM_CACHE = {}
@@ -322,8 +387,13 @@ class LegislationIndex:
322
  self._is_backmatter = []
323
  self._is_nif = []
324
  self._is_repealed = []
 
325
  for c in self.chunks:
326
  self._note_tokens.append(set(tokenize(topical_title(c))))
 
 
 
 
327
  self._is_regulation.append(
328
  c.get("doc_type", "legislation") == "legislation"
329
  and c["act_code"].startswith(("SOR", "C.R.C")))
@@ -649,6 +719,27 @@ class LegislationIndex:
649
  break
650
  return out
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  def _diversify(self, ordered):
653
  """Reorder so no single case, memorandum, agreement or directive can
654
  monopolise the results: once a source has contributed SOURCE_CAP chunks,
@@ -714,6 +805,8 @@ class LegislationIndex:
714
  score -= REG_PENALTY
715
  if self._is_backmatter[idx]:
716
  score -= BACKMATTER_PENALTY
 
 
717
  return score
718
  # Sort by title-match descending, then by original fusion order as
719
  # a tiebreak (stable sort: keep the original rest order).
@@ -895,6 +988,8 @@ class LegislationIndex:
895
  fused[idx] -= NIF_PENALTY
896
  elif self._is_repealed[idx]:
897
  fused[idx] -= REPEALED_PENALTY
 
 
898
  elif self._is_regulation[idx]:
899
  fused[idx] -= REG_PENALTY
900
  elif self.chunks[idx].get("doc_type") == "memorandum":
@@ -916,7 +1011,7 @@ class LegislationIndex:
916
  # unreliable on long statutory text (it can score the right section
917
  # negative), so its power to demote a candidate is deliberately removed.
918
  if self.reranker:
919
- pool = candidates[:RERANK_POOL]
920
  docs = [self._rerank_doc(i) for i in pool]
921
  ce_orig = self.reranker.score(query, docs)
922
  # A repaired query is scored alongside the original and each
@@ -939,7 +1034,8 @@ class LegislationIndex:
939
  sorted(pool, key=ce.get, reverse=True))}
940
  pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
941
  fusion_rank[i]))
942
- candidates = pool + candidates[RERANK_POOL:]
 
943
 
944
  # Explicit section references are pinned to the very top -- using the
945
  # same Act-mentioned constraint as the recall step above, for the same
@@ -987,6 +1083,23 @@ class LegislationIndex:
987
  fallback = fallback or c
988
  return fallback
989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
990
  def _build_xref(self):
991
  """Index legislation by (act, section); find each Act's definitions
992
  section; link every regulation to its enabling Act and every
 
25
  # low-fusion candidates the promote-only rerank can lift).
26
  # Swept 2026-06: 50 -> 16 (with doc cap 3000 -> 1000) held
27
  # the 159-Q eval exactly (MRR +0.01) at ~5.5x lower latency.
28
+ POOL_CAP = int(os.environ.get("CANLEX_POOL_CAP", "8"))
29
+ # max chunks one capped source (a decision, a memorandum)
30
+ # may hold in the rerank pool. The pool is the cross-
31
+ # encoder's whole world: with RECALL_CAP=20 a deep
32
+ # decision can take 14 of 16 slots (Fearon, 88 paragraphs
33
+ # on cell-phone searches, for a device-search query), so
34
+ # the governing border authorities sitting at fused ranks
35
+ # 19-32 (Canfield, Pike, ENF 12 s. 6.15) are never scored
36
+ # at all -- _diversify runs after the rerank and can only
37
+ # reorder what was scored. Capping while the pool fills
38
+ # backfills those slots with the next sources. Swept
39
+ # 2026-09-06 over {5,6,8,10,12,16,off}: 5 let two
40
+ # incidental FB articles into the top 5 of the
41
+ # discipline question, so _ensure_primary no longer
42
+ # pulled art. 17 in (absent from the top 20); 6-16 keep
43
+ # it at 3 with Canfield at 4 on the device query. 8 is
44
+ # the comfortable middle. Env-sweepable; eval-gate any
45
+ # change.
46
  MN_WEIGHT = float(os.environ.get("CANLEX_MN_WEIGHT", "0.0024"))
47
  # title-match boost per unit of idf-weighted overlap between
48
  # the query and a candidate's marginal note (section title)
 
68
  # inform): a repealed section's on-topic title must not
69
  # beat live law in a close contest -- the CDSA-to-Cannabis
70
  # -Act migration is the live risk zone
71
+ INAPPLICABLE_PENALTY = float(os.environ.get("CANLEX_INAPPLICABLE_PENALTY", "0.004"))
72
+ # fusion penalty on provisions the corpus's own audience
73
+ # is excluded from (see _INAPPLICABLE): Canada Labour
74
+ # Code Part III does not apply to federal departments
75
+ # (s. 167(1)(d)), yet its hours-of-work sections out-
76
+ # score the FB agreement article that actually governs
77
+ # a shift-change grievance. The chunks stay retrievable
78
+ # and carry an applicability caveat when shown.
79
  BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
80
  # likewise for a collective agreement's back-matter
81
  # (memoranda, letters of understanding) vs its numbered articles
 
138
  # A directive chunk that *is* an appendix: its title opens "Appendix C ...".
139
  _APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
140
 
141
+ # Provisions that are law, but not law binding the corpus's audience --
142
+ # federal public-service (FB) employees. Keyed (act_code, Part prefixes);
143
+ # the value is the caveat rendered with every such chunk. These stay
144
+ # retrievable (the question may be about them) but take INAPPLICABLE_PENALTY
145
+ # in the fusion and can never be handed to a steward as the governing text.
146
+ _INAPPLICABLE = {
147
+ ("L-2", ("PART III", "PARTIE III")): (
148
+ "Part III of the Canada Labour Code (standard hours, wages, "
149
+ "vacations, holidays, leave, unjust dismissal) does NOT apply to "
150
+ "federal departments and agencies such as the CBSA -- s. 167(1)(d) "
151
+ "excludes 'a department as defined in the Financial Administration "
152
+ "Act'. For FB employees the governing texts are the FB collective "
153
+ "agreement (hours of work art. 25, leave arts. 34-52, grievances "
154
+ "art. 18) and the FPSLRA; only Part II (occupational health and "
155
+ "safety) of the Code reaches the public service."),
156
+ ("P-33.3", ("PART 2.1", "PARTIE 2.1")): (
157
+ "Part 2.1 of the FPSLRA applies only to RCMP members and reservists "
158
+ "(ss. 238.01-238.29). It does not govern FB employees, whose "
159
+ "grievance rights are in Part 2 (ss. 208-238) and the FB collective "
160
+ "agreement art. 18."),
161
+ }
162
+
163
+
164
+ def _applicability(chunk):
165
+ """The _INAPPLICABLE caveat for a legislation chunk, or None."""
166
+ if chunk.get("doc_type", "legislation") != "legislation":
167
+ return None
168
+ part = (chunk.get("part") or "").upper()
169
+ for (code, prefixes), note in _INAPPLICABLE.items():
170
+ if chunk.get("act_code") == code and part.startswith(prefixes):
171
+ return note
172
+ return None
173
+
174
+
175
+ def _piece_no(chunk):
176
+ """Ordinal of a split section's piece ('-p3' -> 3; unsplit -> 0)."""
177
+ m = _PIECE_ID.search(chunk.get("id", ""))
178
+ return int(m.group()[2:]) if m else 0
179
+
180
 
181
  _STEMMER = snowballstemmer.stemmer("english")
182
  _STEM_CACHE = {}
 
387
  self._is_backmatter = []
388
  self._is_nif = []
389
  self._is_repealed = []
390
+ self._inapplicable = []
391
  for c in self.chunks:
392
  self._note_tokens.append(set(tokenize(topical_title(c))))
393
+ note = _applicability(c)
394
+ self._inapplicable.append(bool(note))
395
+ if note:
396
+ c["applicability"] = note # rendered by server._format_section
397
  self._is_regulation.append(
398
  c.get("doc_type", "legislation") == "legislation"
399
  and c["act_code"].startswith(("SOR", "C.R.C")))
 
719
  break
720
  return out
721
 
722
+ def _fill_pool(self, candidates):
723
+ """The rerank pool: the first RERANK_POOL fused candidates, holding
724
+ at most POOL_CAP chunks of any one capped source. Skipped surplus
725
+ frees slots that the next-ranked chunks of OTHER sources backfill,
726
+ so the cross-encoder sees the field, not one deep decision."""
727
+ pool, counts = [], defaultdict(int)
728
+ for idx in candidates:
729
+ key = self._source_key(idx)
730
+ # Pieces of ONE long provision (FB art. 17 is eight) are not a
731
+ # monopoly to break: the reranker must see every piece to find
732
+ # the one that answers, and _diversify shows only two of them
733
+ # anyway. Capping them here lost the discipline article outright.
734
+ if key is not None and key[0] != "piece":
735
+ counts[key] += 1
736
+ if counts[key] > POOL_CAP:
737
+ continue
738
+ pool.append(idx)
739
+ if len(pool) >= RERANK_POOL:
740
+ break
741
+ return pool
742
+
743
  def _diversify(self, ordered):
744
  """Reorder so no single case, memorandum, agreement or directive can
745
  monopolise the results: once a source has contributed SOURCE_CAP chunks,
 
805
  score -= REG_PENALTY
806
  if self._is_backmatter[idx]:
807
  score -= BACKMATTER_PENALTY
808
+ if self._inapplicable[idx]:
809
+ score -= INAPPLICABLE_PENALTY
810
  return score
811
  # Sort by title-match descending, then by original fusion order as
812
  # a tiebreak (stable sort: keep the original rest order).
 
988
  fused[idx] -= NIF_PENALTY
989
  elif self._is_repealed[idx]:
990
  fused[idx] -= REPEALED_PENALTY
991
+ elif self._inapplicable[idx]:
992
+ fused[idx] -= INAPPLICABLE_PENALTY
993
  elif self._is_regulation[idx]:
994
  fused[idx] -= REG_PENALTY
995
  elif self.chunks[idx].get("doc_type") == "memorandum":
 
1011
  # unreliable on long statutory text (it can score the right section
1012
  # negative), so its power to demote a candidate is deliberately removed.
1013
  if self.reranker:
1014
+ pool = self._fill_pool(candidates)
1015
  docs = [self._rerank_doc(i) for i in pool]
1016
  ce_orig = self.reranker.score(query, docs)
1017
  # A repaired query is scored alongside the original and each
 
1034
  sorted(pool, key=ce.get, reverse=True))}
1035
  pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
1036
  fusion_rank[i]))
1037
+ in_pool = set(pool)
1038
+ candidates = pool + [i for i in candidates if i not in in_pool]
1039
 
1040
  # Explicit section references are pinned to the very top -- using the
1041
  # same Act-mentioned constraint as the recall step above, for the same
 
1083
  fallback = fallback or c
1084
  return fallback
1085
 
1086
+ def get_section_parts(self, act, section, lang="en"):
1087
+ """Every stored piece of one section, in order -- the chunk
1088
+ get_section returns plus its '-p2', '-p3', ... siblings (same base
1089
+ id, same language). Long provisions are split for embedding, and
1090
+ serving only the first piece silently dropped IRPA s. 36(3) and
1091
+ IRPR s. 18(2): the subsections the screening tools point at.
1092
+ Returns [] when the section is not found."""
1093
+ first = self.get_section(act, section, lang)
1094
+ if first is None:
1095
+ return []
1096
+ base = _PIECE_ID.sub("", first["id"])
1097
+ first_lang = first.get("lang", "en")
1098
+ parts = [c for c in self.chunks
1099
+ if _PIECE_ID.sub("", c.get("id", "")) == base
1100
+ and c.get("lang", "en") == first_lang]
1101
+ return sorted(parts, key=_piece_no)
1102
+
1103
  def _build_xref(self):
1104
  """Index legislation by (act, section); find each Act's definitions
1105
  section; link every regulation to its enabling Act and every
canlex/rehab.py CHANGED
@@ -17,7 +17,19 @@ Pure rule logic, verified against the corpus text of IRPR ss. 17, 18 and 18.1
17
  Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17):
18
  eligible to APPLY 5 years after sentence completion (conviction branches)
19
  or 5 years after commission (act branches). Available for serious
20
- criminality too.
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  In-Canada convictions are outside the rehabilitation scheme entirely --
23
  the cure is a record suspension (IRPA s. 36(3)(b)) -- EXCEPT the IRPR
@@ -49,13 +61,41 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
49
  "recent summary convictions; at most one older summary conviction; no "
50
  "other committed acts) -- confirm each condition against the full "
51
  "record before relying on deemed rehabilitation.",
52
- "A temporary resident permit (IRPA s. 24) remains available at any "
53
- "time regardless of rehabilitation status.",
 
 
54
  "Time runs from the day AFTER completion of the ENTIRE sentence -- "
55
  "including probation, fines and driving prohibitions -- not from the "
56
  "conviction date.",
 
 
 
 
 
 
57
  ]
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  if basis == "canada-conviction":
60
  if count == "two-plus-summary-only":
61
  if years_elapsed >= 5:
@@ -78,16 +118,26 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
78
  since = ("sentence completion" if basis == "foreign-conviction"
79
  else "the commission of the act")
80
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  if not max_penalty_lt_10:
82
  # Serious criminality: deemed rehabilitation is never available.
83
  if years_elapsed >= 5:
84
- return _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
85
- "The equivalent Canadian offence carries a maximum of "
86
- "10 years or more (serious criminality), so deemed "
87
- "rehabilitation is NOT available -- but at least five "
88
- f"years have passed since {since}, so the person is "
89
- "eligible to APPLY for individual rehabilitation.",
90
- caveats)
91
  return _r("not-yet", "IRPR s. 17",
92
  "Serious criminality: deemed rehabilitation is not "
93
  "available, and the five-year eligibility period to apply "
@@ -96,12 +146,11 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
96
 
97
  if count == "multiple":
98
  if years_elapsed >= 5:
99
- return _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
100
- "More than one indictable-equivalent conviction/act: "
101
- "deemed rehabilitation is not available (IRPR s. 18(2) "
102
- "covers only a single such conviction or act), but the "
103
- "five-year period to apply for individual "
104
- "rehabilitation has run.", caveats)
105
  return _r("not-yet", "IRPR s. 17",
106
  "Multiple indictable-equivalent matters: only individual "
107
  "rehabilitation is possible, and its five-year eligibility "
@@ -128,10 +177,9 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
128
  why = (f"only {years_elapsed} of {need} years have run"
129
  if years_elapsed < need else
130
  "the record is not clean, which defeats deemed rehabilitation")
131
- return _r("eligible-to-apply", f"{para}; IRPR s. 17",
132
- f"Deemed rehabilitation is not (yet) made out ({why}), but "
133
- "five years have passed, so the person is eligible to APPLY "
134
- "for individual rehabilitation.", caveats)
135
  return _r("not-yet", f"{para}; IRPR s. 17",
136
  f"Neither deemed rehabilitation ({years_elapsed} of {need} "
137
  "years) nor application eligibility (five years) has been "
 
17
  Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17):
18
  eligible to APPLY 5 years after sentence completion (conviction branches)
19
  or 5 years after commission (act branches). Available for serious
20
+ criminality too. BUT s. 17 prescribes that period only "if the person has
21
+ not been convicted of a subsequent offence" (other than a contravention or
22
+ a YCJA matter): a later conviction restarts the clock from completion of
23
+ ITS sentence. The tool cannot tell whether the other matters on a record
24
+ pre- or post-date the one being screened, so whenever the record is not
25
+ clean it reports 'apply-conditional' and says what decides it, rather
26
+ than affirming an eligibility s. 17 may deny.
27
+
28
+ Act branch, summary-only acts: IRPA s. 36(1)(c)/(2)(c) reach only an act
29
+ that would be an INDICTABLE offence in Canada. There is no two-summary-acts
30
+ rule -- that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)) -- so two
31
+ or more summary-only committed acts ground no criminal inadmissibility and
32
+ there is nothing to rehabilitate.
33
 
34
  In-Canada convictions are outside the rehabilitation scheme entirely --
35
  the cure is a record suspension (IRPA s. 36(3)(b)) -- EXCEPT the IRPR
 
61
  "recent summary convictions; at most one older summary conviction; no "
62
  "other committed acts) -- confirm each condition against the full "
63
  "record before relying on deemed rehabilitation.",
64
+ "A temporary resident permit (IRPA s. 24) may be available to a "
65
+ "foreign national regardless of rehabilitation status -- at the "
66
+ "officer's discretion and subject to the s. 24(3.1)-(5) bars (e.g. a "
67
+ "failed refugee claimant within 12 months of the claim's rejection).",
68
  "Time runs from the day AFTER completion of the ENTIRE sentence -- "
69
  "including probation, fines and driving prohibitions -- not from the "
70
  "conviction date.",
71
+ "IRPR s. 17 prescribes the five-year application period only 'if the "
72
+ "person has not been convicted of a subsequent offence' (other than a "
73
+ "contravention or a Youth Criminal Justice Act matter): where a later "
74
+ "conviction exists, the five years run from completion of the "
75
+ "sentence for the MOST RECENT conviction, not from the matter being "
76
+ "screened.",
77
  ]
78
 
79
+ def _application(provision, lead):
80
+ """Eligibility to apply under s. 17 -- which a subsequent conviction
81
+ defeats. The record is described by one bool, so a dirty record is
82
+ reported as conditional and the deciding fact is named."""
83
+ if clean_record:
84
+ return _r("eligible-to-apply", provision,
85
+ f"{lead} at least five years have passed since {since}, "
86
+ "so the person is eligible to APPLY for individual "
87
+ "rehabilitation.", caveats)
88
+ return _r("apply-conditional", provision,
89
+ f"{lead} five years have passed since {since} -- but the "
90
+ "record is not clean, and IRPR s. 17 prescribes the "
91
+ "five-year period only 'if the person has not been "
92
+ "convicted of a subsequent offence'. If any other "
93
+ "conviction POSTDATES this matter, eligibility to apply "
94
+ "runs from completion of the sentence for the MOST RECENT "
95
+ "conviction: recompute years_elapsed from that date. If "
96
+ "every other matter PREDATES this one, the person is "
97
+ "eligible to apply now.", caveats)
98
+
99
  if basis == "canada-conviction":
100
  if count == "two-plus-summary-only":
101
  if years_elapsed >= 5:
 
118
  since = ("sentence completion" if basis == "foreign-conviction"
119
  else "the commission of the act")
120
 
121
+ if basis == "foreign-act" and count == "two-plus-summary-only":
122
+ return _r("not-inadmissible", "IRPA s. 36(2)(c)",
123
+ "A committed act grounds inadmissibility only where it "
124
+ "would be an INDICTABLE offence in Canada (IRPA "
125
+ "s. 36(1)(c)/(2)(c)); there is no two-summary-acts rule -- "
126
+ "that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)). "
127
+ "Two or more summary-only committed acts ground no "
128
+ "criminal inadmissibility, so there is nothing to "
129
+ "rehabilitate. Verify that none of the acts has an "
130
+ "indictable (including hybrid) Canadian equivalent.",
131
+ caveats)
132
+
133
  if not max_penalty_lt_10:
134
  # Serious criminality: deemed rehabilitation is never available.
135
  if years_elapsed >= 5:
136
+ return _application(
137
+ "IRPA s. 36(3)(c); IRPR s. 17",
138
+ "The equivalent Canadian offence carries a maximum of 10 "
139
+ "years or more (serious criminality), so deemed "
140
+ "rehabilitation is NOT available --")
 
 
141
  return _r("not-yet", "IRPR s. 17",
142
  "Serious criminality: deemed rehabilitation is not "
143
  "available, and the five-year eligibility period to apply "
 
146
 
147
  if count == "multiple":
148
  if years_elapsed >= 5:
149
+ return _application(
150
+ "IRPA s. 36(3)(c); IRPR s. 17",
151
+ "More than one indictable-equivalent conviction/act: deemed "
152
+ "rehabilitation is not available (IRPR s. 18(2) covers only "
153
+ "a single such conviction or act), but")
 
154
  return _r("not-yet", "IRPR s. 17",
155
  "Multiple indictable-equivalent matters: only individual "
156
  "rehabilitation is possible, and its five-year eligibility "
 
177
  why = (f"only {years_elapsed} of {need} years have run"
178
  if years_elapsed < need else
179
  "the record is not clean, which defeats deemed rehabilitation")
180
+ return _application(
181
+ f"{para}; IRPR s. 17",
182
+ f"Deemed rehabilitation is not (yet) made out ({why}), but")
 
183
  return _r("not-yet", f"{para}; IRPR s. 17",
184
  f"Neither deemed rehabilitation ({years_elapsed} of {need} "
185
  "years) nor application eligibility (five years) has been "
canlex/server.py CHANGED
@@ -137,6 +137,78 @@ def _guidance_age_note(current_to: str) -> str:
137
  return ""
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def _format_section(c: dict, related=None) -> str:
141
  """Render one chunk (legislation, D-Memo, or agreement) as cited Markdown."""
142
  doc_type = c.get("doc_type", "legislation")
@@ -145,6 +217,11 @@ def _format_section(c: dict, related=None) -> str:
145
  lines = [header]
146
  if location:
147
  lines.append(f"*{location}*")
 
 
 
 
 
148
  if doc_type == "memorandum":
149
  lines.append("_CBSA administrative guidance — persuasive, not binding law._")
150
  lines.append(f"(modified {_stated(c['current_to'])})")
@@ -245,7 +322,7 @@ def _format_section(c: dict, related=None) -> str:
245
  if hl:
246
  label, snippet = hl
247
  lines.append(f"**Most on point for this query:** "
248
- f"{c['citation']}{label} — {snippet}")
249
  lines.append("")
250
  lines.append(c["text"])
251
  lines.append("")
@@ -277,7 +354,7 @@ def _format_section(c: dict, related=None) -> str:
277
  return "\n".join(lines)
278
 
279
 
280
- # The eight doc_type values, as a Literal so every client schema carries them
281
  # as an enum rather than free text (regex 'pattern' constraints are dropped by
282
  # several function-calling translators, notably Gemini's).
283
  DocType = Literal["legislation", "memorandum", "agreement", "directive",
@@ -293,8 +370,9 @@ _SECTION_PREFIX = re.compile(
293
  def _normalize_section(section: str) -> str:
294
  """Accept the forms other tool outputs teach a client to write -- 's. 34',
295
  'section 34', '34(1)(c)' -- and reduce them to the bare section number the
296
- index keys on. Subsection parentheticals are truncated (the full section
297
- text contains its subsections anyway)."""
 
298
  s = _SECTION_PREFIX.sub("", section.strip())
299
  s = s.split("(", 1)[0].strip().rstrip(".")
300
  return s or section.strip()
@@ -320,9 +398,11 @@ def canlex_search_legislation(
320
  "and operational guidance), 'agreement' (collective agreements), "
321
  "'directive' (NJC directives), 'caselaw' (court and tribunal "
322
  "decisions), 'delegation' (IRPA/IRPR delegation instruments), "
323
- "'benefits' (benefit-plan member booklets), or 'commentary' "
324
  "(CanLex's curated analysis datasets, e.g. US dispositions vs the "
325
- "IRPA 'conviction' concept). Omit to search all.")] = None,
 
 
326
  court: Annotated[Optional[str], Field(
327
  description="Optional: restrict CASE-LAW results to a deciding "
328
  "court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court "
@@ -425,15 +505,13 @@ def canlex_get_section(
425
  - "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
426
  """
427
  index = _index()
428
- result = index.get_section(act, section)
429
- if result is None and section != _normalize_section(section):
430
- result = index.get_section(act, _normalize_section(section))
431
- if result is None:
432
- acts = sorted({c["act_short"] for c in index.chunks})
433
- return (f"Error: no section '{section}' found in '{act}'. "
434
- f"Loaded Acts: {', '.join(acts) or 'none'}. Check the section number, "
435
- f"or use canlex_search_legislation to locate the provision by topic.")
436
- return GROUNDING_NOTE + "\n\n" + _format_section(result, index.related(result))
437
 
438
 
439
  def _load_dispositions():
@@ -950,10 +1028,9 @@ def canlex_citing(
950
  if result is None and section != _normalize_section(section):
951
  result = index.get_section(act, _normalize_section(section))
952
  if result is None:
953
- return (f"No section '{section}' in '{act}'. Use canlex_list_acts "
954
- f"for the loaded instruments.")
955
  related = index.related(result) or {}
956
- lines = [f"# Material citing {result['citation']}", ""]
957
  memos = related.get("memoranda")
958
  if memos:
959
  lines.append("**Guidance citing this section** (persuasive, not "
@@ -1169,10 +1246,14 @@ def us_record_screening(disposition: str, state: str = "",
1169
  "offence does it equate to, and is the branch serious criminality "
1170
  "(s. 36(1)(b)) or criminality (s. 36(2)(b))? Note the maximum "
1171
  "penalty — whether it is under 10 years feeds step 3.\n"
1172
- "STEP 3 — canlex_rehabilitation: compute full years since sentence "
1173
- "completion from today's date, pass basis='foreign-conviction', the "
1174
- "max-penalty answer from step 2, the count, the elapsed years, and "
1175
- "whether the record is otherwise clean.\n\n"
 
 
 
 
1176
  "Then give a single bottom-line assessment with every authority "
1177
  "cited, every uncertainty flagged, and the reminder that this is "
1178
  "legal information, not legal advice, and that an officer's "
@@ -1221,6 +1302,88 @@ def _res_equivalency_framework() -> str:
1221
  return "The equivalency dataset is not present in this deployment."
1222
 
1223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1224
  if __name__ == "__main__":
1225
  try:
1226
  idx = _index()
@@ -1231,7 +1394,10 @@ if __name__ == "__main__":
1231
  sys.exit(1)
1232
  # CANLEX_HTTP switches on the remote (web) transport; default is local stdio.
1233
  if os.environ.get("CANLEX_HTTP"):
 
1234
  print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr)
1235
- mcp.run(transport="streamable-http")
 
 
1236
  else:
1237
  mcp.run()
 
137
  return ""
138
 
139
 
140
+ # The '(part k of n)' / '(part k)' marker the ingesters append to a split
141
+ # section's citation or title. Stripped wherever a whole section is served
142
+ # or a pin-cite is built, so the client never emits 'IRPA, s. 36 (part 1 of
143
+ # 2)(1)(c)' as a citation.
144
+ _PART_NOTE = re.compile(
145
+ r"\s*\((?:part|partie)\s+\d+(?:\s+(?:of|de)\s+\d+)?\)", re.IGNORECASE)
146
+
147
+
148
+ def _clean_citation(citation: str) -> str:
149
+ return _PART_NOTE.sub("", citation)
150
+
151
+
152
+ def _assemble(parts):
153
+ """One chunk carrying the whole section. Long provisions are stored as
154
+ '-p1', '-p2', ... pieces (IRPA s. 36 is two, IRPR s. 18 three, the CDSA's
155
+ Schedule I ten) and get_section used to serve only the first piece -- so
156
+ IRPA s. 36(3), the hybrid-deeming and rehabilitation rules the screening
157
+ tools point the client at, was unreachable by any tool."""
158
+ if len(parts) == 1:
159
+ return parts[0]
160
+ whole = dict(parts[0])
161
+ whole["citation"] = _clean_citation(parts[0]["citation"])
162
+ whole["marginal_note"] = _PART_NOTE.sub("", parts[0]["marginal_note"])
163
+ whole["text"] = "\n\n".join(p["text"] for p in parts)
164
+ whole["assembled_parts"] = len(parts)
165
+ whole.pop("highlight", None)
166
+ return whole
167
+
168
+
169
+ def _not_found(index, act: str, section: str) -> str:
170
+ """A get_section/citing miss: name the closest instruments, not the whole
171
+ inventory (~325 names, 195 of them decisions get_section cannot serve --
172
+ the old dump cost ~1.1k tokens per miss and taught the client that
173
+ 'Baker' is an Act)."""
174
+ import difflib
175
+ known = {}
176
+ for c in index.chunks:
177
+ if c.get("doc_type", "legislation") == "legislation":
178
+ known.setdefault(c["act_short"], c.get("act_name", ""))
179
+ act_lc = act.strip().lower()
180
+ loaded = any(act_lc in (short.lower(), name.lower())
181
+ for short, name in known.items()) or any(
182
+ act_lc == c["act_code"].lower() for c in index.chunks)
183
+ if loaded:
184
+ return (f"Error: '{act}' is loaded but has no section '{section}'. "
185
+ "Check the number (a subsection form like '36(1)(a)' is "
186
+ "fine -- the whole section is returned), or call "
187
+ f"canlex_toc(act='{act}') to list its sections.")
188
+ # Whole-word overlap first, character similarity second: 'Immigration
189
+ # Act' must find IRPA and the Immigration Division Rules, not whichever
190
+ # short names happen to share letters with it.
191
+ stop = {"act", "the", "and", "of", "on", "regulations", "regs", "rules"}
192
+ q_words = {w for w in re.findall(r"[a-z0-9]+", act_lc) if w not in stop}
193
+ scored = []
194
+ for short, name in known.items():
195
+ words = {w for w in re.findall(r"[a-z0-9]+", f"{short} {name}".lower())
196
+ if w not in stop}
197
+ overlap = (len(q_words & words) / len(q_words)) if q_words else 0.0
198
+ ratio = max(difflib.SequenceMatcher(None, act_lc, short.lower()).ratio(),
199
+ difflib.SequenceMatcher(None, act_lc, name.lower()).ratio())
200
+ score = overlap + ratio
201
+ if score >= 0.8:
202
+ scored.append((score, short))
203
+ shorts = [short for _, short in sorted(scored, reverse=True)[:3]]
204
+ hint = (" Did you mean: " + "; ".join(
205
+ f"{sh} ({known[sh]})" if known[sh] else sh for sh in shorts) + "?"
206
+ if shorts else "")
207
+ return (f"Error: no loaded instrument is named '{act}'.{hint} "
208
+ "canlex_list_acts gives the full inventory; "
209
+ "canlex_search_legislation finds a provision by topic.")
210
+
211
+
212
  def _format_section(c: dict, related=None) -> str:
213
  """Render one chunk (legislation, D-Memo, or agreement) as cited Markdown."""
214
  doc_type = c.get("doc_type", "legislation")
 
217
  lines = [header]
218
  if location:
219
  lines.append(f"*{location}*")
220
+ if c.get("assembled_parts", 0) > 1:
221
+ lines.append(f"_Complete text, assembled from "
222
+ f"{c['assembled_parts']} stored parts._")
223
+ if c.get("applicability"):
224
+ lines.append(f"_**⚠ APPLICABILITY:** {c['applicability']}_")
225
  if doc_type == "memorandum":
226
  lines.append("_CBSA administrative guidance — persuasive, not binding law._")
227
  lines.append(f"(modified {_stated(c['current_to'])})")
 
322
  if hl:
323
  label, snippet = hl
324
  lines.append(f"**Most on point for this query:** "
325
+ f"{_clean_citation(c['citation'])}{label} — {snippet}")
326
  lines.append("")
327
  lines.append(c["text"])
328
  lines.append("")
 
354
  return "\n".join(lines)
355
 
356
 
357
+ # The nine doc_type values, as a Literal so every client schema carries them
358
  # as an enum rather than free text (regex 'pattern' constraints are dropped by
359
  # several function-calling translators, notably Gemini's).
360
  DocType = Literal["legislation", "memorandum", "agreement", "directive",
 
370
  def _normalize_section(section: str) -> str:
371
  """Accept the forms other tool outputs teach a client to write -- 's. 34',
372
  'section 34', '34(1)(c)' -- and reduce them to the bare section number the
373
+ index keys on. Subsection parentheticals are truncated: the section is
374
+ served whole (every stored part, see _assemble), so '36(3)(a)' resolves
375
+ to the text that contains subsection (3)."""
376
  s = _SECTION_PREFIX.sub("", section.strip())
377
  s = s.split("(", 1)[0].strip().rstrip(".")
378
  return s or section.strip()
 
398
  "and operational guidance), 'agreement' (collective agreements), "
399
  "'directive' (NJC directives), 'caselaw' (court and tribunal "
400
  "decisions), 'delegation' (IRPA/IRPR delegation instruments), "
401
+ "'benefits' (benefit-plan member booklets), 'commentary' "
402
  "(CanLex's curated analysis datasets, e.g. US dispositions vs the "
403
+ "IRPA 'conviction' concept), or 'union' (CIU/PSAC by-laws, "
404
+ "regulations, branch rules, member guides and site VSSAs). Omit to "
405
+ "search all.")] = None,
406
  court: Annotated[Optional[str], Field(
407
  description="Optional: restrict CASE-LAW results to a deciding "
408
  "court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court "
 
505
  - "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
506
  """
507
  index = _index()
508
+ parts = index.get_section_parts(act, section)
509
+ if not parts and section != _normalize_section(section):
510
+ parts = index.get_section_parts(act, _normalize_section(section))
511
+ if not parts:
512
+ return _not_found(index, act, section)
513
+ return (GROUNDING_NOTE + "\n\n"
514
+ + _format_section(_assemble(parts), index.related(parts[0])))
 
 
515
 
516
 
517
  def _load_dispositions():
 
1028
  if result is None and section != _normalize_section(section):
1029
  result = index.get_section(act, _normalize_section(section))
1030
  if result is None:
1031
+ return _not_found(index, act, section)
 
1032
  related = index.related(result) or {}
1033
+ lines = [f"# Material citing {_clean_citation(result['citation'])}", ""]
1034
  memos = related.get("memoranda")
1035
  if memos:
1036
  lines.append("**Guidance citing this section** (persuasive, not "
 
1246
  "offence does it equate to, and is the branch serious criminality "
1247
  "(s. 36(1)(b)) or criminality (s. 36(2)(b))? Note the maximum "
1248
  "penalty — whether it is under 10 years feeds step 3.\n"
1249
+ "STEP 3 — canlex_rehabilitation: if step 1 found a conviction (YES "
1250
+ "or LIKELY YES), pass basis='foreign-conviction' with full years "
1251
+ "since completion of the ENTIRE sentence, computed from today's "
1252
+ "date; if step 1 found NO conviction and the act branch is in play, "
1253
+ "pass basis='foreign-act' with full years since the act was "
1254
+ "COMMITTED. Pass the max-penalty answer from step 2, the count, and "
1255
+ "whether the record is otherwise clean (any later conviction makes "
1256
+ "eligibility to apply conditional -- IRPR s. 17).\n\n"
1257
  "Then give a single bottom-line assessment with every authority "
1258
  "cited, every uncertainty flagged, and the reminder that this is "
1259
  "legal information, not legal advice, and that an officer's "
 
1302
  return "The equivalency dataset is not present in this deployment."
1303
 
1304
 
1305
+ def _utf8_body(body: bytes):
1306
+ """(body, changed): the request body re-encoded to UTF-8 when it was not.
1307
+
1308
+ The Space's run log showed a real client posting a Latin-1 body -- 'é'
1309
+ as 0xE9 -- and the MCP transport answering HTTP 500 with a
1310
+ UnicodeDecodeError, i.e. every accented (French) query from that client
1311
+ failed. JSON-RPC bodies are text, so a body that is not valid UTF-8 is
1312
+ re-read as cp1252 (Latin-1's superset on Windows clients) and re-encoded.
1313
+ """
1314
+ try:
1315
+ body.decode("utf-8")
1316
+ return body, False
1317
+ except UnicodeDecodeError:
1318
+ pass
1319
+ for enc in ("cp1252", "latin-1"):
1320
+ try:
1321
+ return body.decode(enc).encode("utf-8"), True
1322
+ except UnicodeDecodeError:
1323
+ continue
1324
+ return body, False
1325
+
1326
+
1327
+ _seen_clients = set()
1328
+
1329
+
1330
+ def _note_client(body: bytes) -> None:
1331
+ """One stderr line per distinct MCP client (name + version) at
1332
+ initialize -- the only way to tell which client sent a malformed body."""
1333
+ if b'"initialize"' not in body:
1334
+ return
1335
+ try:
1336
+ info = json.loads(body).get("params", {}).get("clientInfo", {})
1337
+ except Exception:
1338
+ return
1339
+ tag = f"{info.get('name', '?')} {info.get('version', '')}".strip()
1340
+ if tag not in _seen_clients:
1341
+ _seen_clients.add(tag)
1342
+ print(f"CanLex MCP: client {tag}", file=sys.stderr)
1343
+
1344
+
1345
+ class _Utf8Body:
1346
+ """ASGI wrapper around the streamable-HTTP app: buffers each POST body,
1347
+ re-encodes it to UTF-8 if needed (see _utf8_body) and logs the client at
1348
+ initialize. Non-POST requests pass straight through."""
1349
+
1350
+ def __init__(self, app):
1351
+ self.app = app
1352
+
1353
+ async def __call__(self, scope, receive, send):
1354
+ if scope["type"] != "http" or scope.get("method") != "POST":
1355
+ await self.app(scope, receive, send)
1356
+ return
1357
+ chunks, queued = [], []
1358
+ while True:
1359
+ message = await receive()
1360
+ if message["type"] != "http.request":
1361
+ queued.append(message) # e.g. http.disconnect
1362
+ break
1363
+ chunks.append(message.get("body", b""))
1364
+ if not message.get("more_body"):
1365
+ break
1366
+ body, changed = _utf8_body(b"".join(chunks))
1367
+ if changed:
1368
+ scope = dict(scope)
1369
+ scope["headers"] = [
1370
+ (k, v) for k, v in scope.get("headers", [])
1371
+ if k.lower() != b"content-length"
1372
+ ] + [(b"content-length", str(len(body)).encode())]
1373
+ print("CanLex MCP: re-encoded a non-UTF-8 request body as UTF-8",
1374
+ file=sys.stderr)
1375
+ _note_client(body)
1376
+ replay = [{"type": "http.request", "body": body,
1377
+ "more_body": False}] + queued
1378
+
1379
+ async def receive_replay():
1380
+ if replay:
1381
+ return replay.pop(0)
1382
+ return await receive()
1383
+
1384
+ await self.app(scope, receive_replay, send)
1385
+
1386
+
1387
  if __name__ == "__main__":
1388
  try:
1389
  idx = _index()
 
1394
  sys.exit(1)
1395
  # CANLEX_HTTP switches on the remote (web) transport; default is local stdio.
1396
  if os.environ.get("CANLEX_HTTP"):
1397
+ import uvicorn
1398
  print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr)
1399
+ uvicorn.run(_Utf8Body(mcp.streamable_http_app()),
1400
+ host=mcp.settings.host, port=mcp.settings.port,
1401
+ log_level=mcp.settings.log_level.lower())
1402
  else:
1403
  mcp.run()
canlex/synonyms.py CHANGED
@@ -50,6 +50,17 @@ _SYNONYMS = [
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.
 
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
+ # Device examinations: officers say 'cell phone' / 'laptop'; Customs Act
54
+ # s. 99 says 'goods' and ENF 12 s. 6.15 'digital devices'. Without the
55
+ # bridge an arrest-context SCC case (Fearon) and two unrelated 'without
56
+ # warrant' marginal notes outrank the governing border authorities.
57
+ (r"(?:cell ?phones?|smart ?phones?|mobile (?:phones?|devices?)|laptops?"
58
+ r"|tablets?|digital devices?|electronic devices?)",
59
+ "examination of goods digital device electronic device"),
60
+ # Money: 'cash', 'USD', '10k' never reach PCMLTFA s. 12's own words
61
+ # ('currency or monetary instruments ... prescribed amount').
62
+ (r"(?:cash|us ?dollars|usd|10 ?k|ten thousand dollars)",
63
+ "currency or monetary instruments report prescribed amount"),
64
  (r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
65
  # The single most common US-screening term: bridge DUI shorthand to the
66
  # Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
data/eval/questions.json CHANGED
@@ -457,6 +457,10 @@
457
  [
458
  "Canada Labour Code",
459
  "169"
 
 
 
 
460
  ]
461
  ],
462
  "holdout": true
@@ -888,6 +892,10 @@
888
  [
889
  "Canada Labour Code",
890
  "239"
 
 
 
 
891
  ]
892
  ]
893
  },
@@ -2674,5 +2682,71 @@
2674
  ]
2675
  ],
2676
  "style": "french"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2677
  }
2678
- ]
 
457
  [
458
  "Canada Labour Code",
459
  "169"
460
+ ],
461
+ [
462
+ "FB Agreement",
463
+ "25"
464
  ]
465
  ],
466
  "holdout": true
 
892
  [
893
  "Canada Labour Code",
894
  "239"
895
+ ],
896
+ [
897
+ "FB Agreement",
898
+ "35"
899
  ]
900
  ]
901
  },
 
2682
  ]
2683
  ],
2684
  "style": "french"
2685
+ },
2686
+ {
2687
+ "query": "can an officer search a traveller's cell phone at the port of entry without a warrant",
2688
+ "answers": [
2689
+ [
2690
+ "Canfield",
2691
+ ""
2692
+ ],
2693
+ [
2694
+ "Pike",
2695
+ ""
2696
+ ],
2697
+ [
2698
+ "ENF 12",
2699
+ "ENF 12 s. 6.15"
2700
+ ],
2701
+ [
2702
+ "Customs Act",
2703
+ "99"
2704
+ ]
2705
+ ],
2706
+ "style": "fact-pattern"
2707
+ },
2708
+ {
2709
+ "query": "how much notice does the employer have to give before changing my shift schedule, and what is the premium if less",
2710
+ "answers": [
2711
+ [
2712
+ "FB Agreement",
2713
+ "25"
2714
+ ]
2715
+ ],
2716
+ "style": "standard"
2717
+ },
2718
+ {
2719
+ "query": "time limit to present a grievance at the first level under the FB collective agreement",
2720
+ "answers": [
2721
+ [
2722
+ "FB Agreement",
2723
+ "18"
2724
+ ]
2725
+ ],
2726
+ "style": "standard"
2727
+ },
2728
+ {
2729
+ "query": "traveller has USD 9,500 in cash, does it have to be reported and how is the CAD 10,000 threshold converted",
2730
+ "answers": [
2731
+ [
2732
+ "PCMLTFA",
2733
+ "12"
2734
+ ],
2735
+ [
2736
+ "Cross-border Currency Reporting Regs",
2737
+ "2"
2738
+ ]
2739
+ ],
2740
+ "style": "fact-pattern"
2741
+ },
2742
+ {
2743
+ "query": "refugee claimant at the land border arriving from the US: Safe Third Country exceptions for family members",
2744
+ "answers": [
2745
+ [
2746
+ "IRPR",
2747
+ "159.5"
2748
+ ]
2749
+ ],
2750
+ "style": "fact-pattern"
2751
  }
2752
+ ]
tests/test_rehab.py CHANGED
@@ -19,10 +19,26 @@ class DeemedRehabTests(unittest.TestCase):
19
  r = assess("foreign-conviction", True, "one", 9, True)
20
  self.assertEqual(r["status"], "eligible-to-apply")
21
 
22
- def test_dirty_record_defeats_deemed(self):
 
 
 
23
  r = assess("foreign-conviction", True, "one", 12, False)
 
 
 
 
 
 
24
  self.assertEqual(r["status"], "eligible-to-apply")
25
 
 
 
 
 
 
 
 
26
  def test_two_summary_convictions_deemed_after_5_years(self):
27
  r = assess("foreign-conviction", True, "two-plus-summary-only", 5, True)
28
  self.assertEqual(r["status"], "deemed-rehabilitated")
@@ -40,6 +56,11 @@ class SeriousCriminalityTests(unittest.TestCase):
40
  self.assertEqual(r["status"], "eligible-to-apply")
41
  self.assertIn("17", r["provision"])
42
 
 
 
 
 
 
43
  def test_under_5_years_nothing_available(self):
44
  r = assess("foreign-conviction", False, "one", 3, True)
45
  self.assertEqual(r["status"], "not-yet")
 
19
  r = assess("foreign-conviction", True, "one", 9, True)
20
  self.assertEqual(r["status"], "eligible-to-apply")
21
 
22
+ def test_dirty_record_defeats_deemed_and_makes_applying_conditional(self):
23
+ # IRPR s. 17: the five-year period runs only 'if the person has not
24
+ # been convicted of a subsequent offence' -- a dirty record cannot be
25
+ # reported as a clean 'eligible to apply'.
26
  r = assess("foreign-conviction", True, "one", 12, False)
27
+ self.assertEqual(r["status"], "apply-conditional")
28
+ self.assertIn("subsequent offence", r["explanation"])
29
+ self.assertTrue(any("subsequent offence" in c for c in r["caveats"]))
30
+
31
+ def test_clean_record_short_of_deemed_is_plainly_eligible(self):
32
+ r = assess("foreign-conviction", True, "one", 7, True)
33
  self.assertEqual(r["status"], "eligible-to-apply")
34
 
35
+ def test_two_summary_only_acts_are_not_inadmissible(self):
36
+ # s. 36(2)(c) needs an indictable-equivalent act; the two-summary
37
+ # rule exists only for convictions.
38
+ r = assess("foreign-act", True, "two-plus-summary-only", 2, True)
39
+ self.assertEqual(r["status"], "not-inadmissible")
40
+ self.assertIn("36(2)(c)", r["provision"])
41
+
42
  def test_two_summary_convictions_deemed_after_5_years(self):
43
  r = assess("foreign-conviction", True, "two-plus-summary-only", 5, True)
44
  self.assertEqual(r["status"], "deemed-rehabilitated")
 
56
  self.assertEqual(r["status"], "eligible-to-apply")
57
  self.assertIn("17", r["provision"])
58
 
59
+ def test_serious_criminality_dirty_record_is_conditional(self):
60
+ r = assess("foreign-conviction", False, "one", 30, False)
61
+ self.assertEqual(r["status"], "apply-conditional")
62
+ self.assertIn("MOST RECENT", r["explanation"])
63
+
64
  def test_under_5_years_nothing_available(self):
65
  r = assess("foreign-conviction", False, "one", 3, True)
66
  self.assertEqual(r["status"], "not-yet")
tests/test_server.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the MCP tool layer (canlex/server.py).
2
+
3
+ Offline: the tools are exercised against a stub LegislationIndex built via
4
+ __new__ (no corpus, embeddings or reranker), and the ASGI body wrapper is
5
+ driven with hand-made scope/receive callables.
6
+
7
+ python -m unittest discover -s tests
8
+ """
9
+ import asyncio
10
+ import unittest
11
+
12
+ from canlex import server
13
+ from canlex.index import LegislationIndex
14
+
15
+
16
+ def chunk(id, section, text, marginal_note="Title", citation=None,
17
+ act_short="IRPA", act_code="I-2.5",
18
+ act_name="Immigration and Refugee Protection Act", **extra):
19
+ c = {"id": id, "doc_type": "legislation", "act_code": act_code,
20
+ "act_short": act_short, "act_name": act_name, "section": section,
21
+ "marginal_note": marginal_note, "part": "", "division": "",
22
+ "heading": "", "text": text, "history": "", "last_amended": "",
23
+ "in_force": "", "status": "in force", "current_to": "2026-03-31",
24
+ "citation": citation or f"{act_short}, s. {section}",
25
+ "source_url": "https://example.test"}
26
+ c.update(extra)
27
+ return c
28
+
29
+
30
+ def stub_index(chunks):
31
+ idx = LegislationIndex.__new__(LegislationIndex)
32
+ idx.chunks = chunks
33
+ idx.related = lambda c: {}
34
+ return idx
35
+
36
+
37
+ class GetSectionTests(unittest.TestCase):
38
+ def setUp(self):
39
+ server._INDEX = stub_index([
40
+ chunk("I-2.5-s35", "35", "35 (1) A permanent resident ..."),
41
+ chunk("I-2.5-s36-p1", "36",
42
+ "36 (1) A permanent resident or a foreign national is "
43
+ "inadmissible on grounds of serious criminality ...",
44
+ marginal_note="Serious criminality",
45
+ citation="IRPA, s. 36 (part 1 of 2)"),
46
+ chunk("I-2.5-s36-p2", "36",
47
+ "(3) The following provisions govern subsections (1) and (2): "
48
+ "(a) an offence that may be prosecuted either summarily or by "
49
+ "way of indictment is deemed to be an indictable offence ...",
50
+ marginal_note="Serious criminality",
51
+ citation="IRPA, s. 36 (part 2 of 2)"),
52
+ ])
53
+
54
+ def tearDown(self):
55
+ server._INDEX = None
56
+
57
+ def test_every_stored_part_is_served(self):
58
+ out = server.canlex_get_section("IRPA", "36(3)(a)")
59
+ self.assertIn("(3) The following provisions govern", out)
60
+ self.assertIn("assembled from 2 stored parts", out)
61
+ self.assertIn("### IRPA, s. 36 — Serious criminality", out)
62
+ self.assertNotIn("(part 1 of 2)", out)
63
+
64
+ def test_unsplit_section_is_unchanged(self):
65
+ out = server.canlex_get_section("IRPA", "s. 35")
66
+ self.assertIn("### IRPA, s. 35", out)
67
+ self.assertNotIn("assembled", out)
68
+
69
+ def test_unknown_act_gets_closest_match_not_the_inventory(self):
70
+ out = server.canlex_get_section("Immigration Act", "34")
71
+ self.assertIn("Did you mean", out)
72
+ self.assertIn("IRPA", out)
73
+ self.assertNotIn("Loaded Acts:", out)
74
+
75
+ def test_known_act_missing_section_says_so(self):
76
+ out = server.canlex_get_section("IRPA", "999")
77
+ self.assertIn("is loaded but has no section '999'", out)
78
+ self.assertIn("canlex_toc", out)
79
+
80
+
81
+ class CitationCleaningTests(unittest.TestCase):
82
+ def test_part_marker_is_stripped(self):
83
+ self.assertEqual(server._clean_citation("IRPA, s. 36 (part 1 of 2)"),
84
+ "IRPA, s. 36")
85
+ self.assertEqual(server._clean_citation("IRPA, art. 36 (partie 2 de 2)"),
86
+ "IRPA, art. 36")
87
+ self.assertEqual(server._clean_citation("FB Agreement, art. 25"),
88
+ "FB Agreement, art. 25")
89
+
90
+
91
+ def _run(app, scope, messages):
92
+ seen = {}
93
+
94
+ async def inner(scope, receive, send):
95
+ msg = await receive()
96
+ seen["body"] = msg["body"]
97
+ seen["headers"] = dict(scope.get("headers", []))
98
+ seen["next"] = await receive()
99
+
100
+ async def receive():
101
+ return messages.pop(0)
102
+
103
+ asyncio.run(app(inner)(scope, receive, None))
104
+ return seen
105
+
106
+
107
+ class Utf8BodyTests(unittest.TestCase):
108
+ def test_latin1_body_is_reencoded_and_length_fixed(self):
109
+ body = "détention à la frontière".encode("latin-1")
110
+ scope = {"type": "http", "method": "POST",
111
+ "headers": [(b"content-length", str(len(body)).encode())]}
112
+ seen = _run(server._Utf8Body, scope, [
113
+ {"type": "http.request", "body": body, "more_body": False},
114
+ {"type": "http.disconnect"}])
115
+ text = seen["body"].decode("utf-8")
116
+ self.assertEqual(text, "détention à la frontière")
117
+ self.assertEqual(seen["headers"][b"content-length"],
118
+ str(len(text.encode("utf-8"))).encode())
119
+ self.assertEqual(seen["next"]["type"], "http.disconnect")
120
+
121
+ def test_utf8_body_passes_untouched_across_chunks(self):
122
+ body = "détention".encode("utf-8")
123
+ scope = {"type": "http", "method": "POST",
124
+ "headers": [(b"content-length", str(len(body)).encode())]}
125
+ seen = _run(server._Utf8Body, scope, [
126
+ {"type": "http.request", "body": body[:3], "more_body": True},
127
+ {"type": "http.request", "body": body[3:], "more_body": False},
128
+ {"type": "http.disconnect"}])
129
+ self.assertEqual(seen["body"], body)
130
+
131
+ def test_get_requests_pass_straight_through(self):
132
+ calls = []
133
+
134
+ async def inner(scope, receive, send):
135
+ calls.append(receive)
136
+
137
+ async def receive():
138
+ raise AssertionError("must not be read by the wrapper")
139
+
140
+ asyncio.run(server._Utf8Body(inner)(
141
+ {"type": "http", "method": "GET", "headers": []}, receive, None))
142
+ self.assertIs(calls[0], receive)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ unittest.main()