Beemer Claude Fable 5 commited on
Commit
4fef2ec
·
1 Parent(s): 96d30d2

Typo repair v2, bilingual guidance corpus, full PDI trees

Browse files

Misspelled words are now repaired before retrieval (trigram candidates,
Damerau-Levenshtein <= 2, prefix + frequency guards; ultra-rare stems get
the fix appended, never substituted) and the repaired text feeds the
embedder and reranker -- typo slice 0.00 -> 0.75 Hit@1. The D-memos (356
memos, 1,995 chunks) and all 11 NJC directives (1,135 chunks) gained their
French editions via --fr modes, lang-routed like the legislation; French
Hit@3 back to 0.80. pdi.py now crawls five border-relevant PDI category
trees breadth-first (242 pages, 2,068 chunks; caps logged, wipe-guards
kept). Weekly watch rows for all new files. 46,027 chunks / 51,756
vectors; legacy 0.74/0.94/0.98/0.99 MRR 0.84 (Hit@1 softened by guidance
competition, top-3 intact); holdout 0.77/1.00/1.00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

RUNBOOK.md CHANGED
@@ -9,15 +9,16 @@ Order matters — chunks feed embeddings feed the eval:
9
  ```
10
  py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
11
  py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
12
- py -m canlex.pdi # scoped IRCC PDI pages (PR card / Tran)
 
13
  py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
14
- py -m canlex.dmemo # CBSA D-Memoranda
15
  py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
16
  py -m canlex.amps # AMPS Master Penalty Document
17
  py -m canlex.charter # Charter / Constitution HTML
18
  py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
19
  py -m canlex.delegation # CBSA/IRCC delegation instruments
20
- py -m canlex.directive # NJC directives
21
  py -m canlex.agreement # FB collective agreement
22
  py -m canlex.benefits # benefit-plan booklets
23
  py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
@@ -150,12 +151,19 @@ git push mirror --all
150
  (Verifying Departure) are retired with no successor (Wayback copies exist
151
  but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
152
  replaced by the PDI section at
153
- operational-bulletins-manuals/permanent-residence/card.html (~10 HTML
154
- sub-pages) a future small scraper. ENF 2 (Evaluating Inadmissibility)
155
  and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
156
  ATIP-released copies hosted by a private law firm — NOT ingested pending
157
- the maintainer's call on unofficial sources; the Tran PDI page
158
- (standard-requirements/tran.html) partially covers ENF 2's ground.
 
 
 
 
 
 
 
159
  - CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
160
  2026-07-22): open the URL a missing-file stub prints in a real browser,
161
  copy the page text to the named `data/raw/caselaw/*.txt`, re-run
@@ -175,10 +183,48 @@ git push mirror --all
175
  regressed every slice and broke confidence calibration; don't retry
176
  without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
177
  published bilingually) is the remaining bilingual frontier.
178
- - ENF 2 is ingested from its ATIP release (authorized 2026-07-23, bannered
179
- UNOFFICIAL on every chunk). ENF 14/OP 19 (Criminal Rehabilitation) is
180
- authorized but BLOCKED: its ATIP PDF is a 56-page image scan with no text
181
- layer needs an OCR pass (tesseract / Windows OCR) before ingestion.
182
- - Typo robustness: unknown query tokens get a trigram-Dice fuzzy bridge
183
- (0.6 floor, shared 2-char prefix, 0.7 score discount) in
184
- `index._fuzzy_term`; the `typo` eval slice tracks it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ```
10
  py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
11
  py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
12
+ py -m canlex.pdi # IRCC PDI trees (TR / refugee / standard /
13
+ # identity / PR-card; capped BFS, drops logged)
14
  py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
15
+ py -m canlex.dmemo [--fr] # CBSA D-Memoranda (EN; --fr = Mémorandums D)
16
  py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
17
  py -m canlex.amps # AMPS Master Penalty Document
18
  py -m canlex.charter # Charter / Constitution HTML
19
  py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
20
  py -m canlex.delegation # CBSA/IRCC delegation instruments
21
+ py -m canlex.directive [--fr] # NJC directives (EN; --fr = éditions françaises)
22
  py -m canlex.agreement # FB collective agreement
23
  py -m canlex.benefits # benefit-plan booklets
24
  py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
 
151
  (Verifying Departure) are retired with no successor (Wayback copies exist
152
  but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
153
  replaced by the PDI section at
154
+ operational-bulletins-manuals/permanent-residence/card.html ingested by
155
+ `canlex.pdi` (the `pdi-prcard` set). ENF 2 (Evaluating Inadmissibility)
156
  and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
157
  ATIP-released copies hosted by a private law firm — NOT ingested pending
158
+ the maintainer's call on unofficial sources; the Tran PDI page now comes
159
+ in with the whole `standard-requirements` tree and partially covers
160
+ ENF 2's ground.
161
+ - PDI scope: `canlex.pdi` crawls six category trees breadth-first
162
+ (`_MAX_DEPTH` 2, `_MAX_PAGES` 160 per tree). Deliberately out of scope:
163
+ citizenship, fees, office service delivery, and the year-by-year
164
+ operational-bulletin archives (mostly expired instructions). If a tree
165
+ ever hits the page cap the run PRINTS how many in-scope pages it dropped
166
+ — raise `_MAX_PAGES` rather than letting the silence read as coverage.
167
  - CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
168
  2026-07-22): open the URL a missing-file stub prints in a real browser,
169
  copy the page text to the named `data/raw/caselaw/*.txt`, re-run
 
183
  regressed every slice and broke confidence calibration; don't retry
184
  without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
185
  published bilingually) is the remaining bilingual frontier.
186
+ - ENF 2 and ENF 14/OP 19 are both ingested from their ATIP releases
187
+ (authorized 2026-07-23, bannered UNOFFICIAL on every chunk). ENF 14's
188
+ image-scan PDF was recovered by OCR (pymupdf rasterization + Windows OCR;
189
+ cached at data/raw/enf/enf14-ocr.txt enf.py auto-falls-back to
190
+ `*-ocr.txt` when a PDF extracts under 500 chars).
191
+ - Typo robustness, two layers: unknown STEMS get the trigram bridge in
192
+ `index._fuzzy_term` (BM25 recall only), and misspelled WORDS are repaired
193
+ by `index._correct_query` (trigram candidates, Damerau-Levenshtein <= 2,
194
+ shared 2-char prefix, frequency floor 5 so OCR debris can't win) -- the
195
+ repaired text feeds the semantic embedder and the reranker. Ultra-rare
196
+ stems (df <= 2) get the correction APPENDED, never substituted, so rare
197
+ legitimate terms keep their recall. The `typo` eval slice tracks it.
198
+ - Bilingual guidance: the D-memos and NJC directives now carry their French
199
+ editions (`--fr` modes; dmemos-fr.json / directives-fr.json) alongside
200
+ the French legislation, all lang-routed. Remaining English-only: case
201
+ law, ENF/AMPS/PDI, benefits booklets, and the curated commentary.
202
+
203
+ ## Two retrieval invariants worth not re-breaking (2026-07-29)
204
+
205
+ Ingesting the French guidance and the wider PDI trees exposed both of these.
206
+ Each was measured question-by-question against the previous corpus, not
207
+ guessed at — the harness is a per-question rank dump over two corpus
208
+ variants, which is how a 5-question shuffle gets attributed instead of
209
+ argued about.
210
+
211
+ - **BM25 collection statistics are language-scoped.** `_build_bm25` keeps
212
+ `idf`/`avgdl` (whole corpus) AND `idf_en`/`avgdl_en` (English scope);
213
+ every English query scores with the English pair. An English query can
214
+ never retrieve the French twins, but a single corpus-wide idf let those
215
+ unreachable chunks set the rarity of every shared token (numbers, act
216
+ codes, folded cognates) and drag the average length: adding the French
217
+ D-memos and directives alone moved legacy Hit@1 0.767 → 0.750 without a
218
+ single French chunk being retrievable. French queries still use the
219
+ whole-corpus pair — they search both languages.
220
+ - **The diversity cap keys memoranda on the parent DOCUMENT.** `_source_key`
221
+ used `section` for every memorandum, which is the document identity only
222
+ for the flat families (`_FLAT_MEMO_CODES`: one act_code across ~360
223
+ D-memos / ~1,000 AMPS contraventions). ENF chapters, PDI pages and IRB
224
+ guidelines carry a per-document act_code and use `section` to subdivide
225
+ it, so each ENF section and each PDI page was its own "source" and
226
+ neither RECALL_CAP nor SOURCE_CAP ever bound: the ~150-page PDI refugee
227
+ tree filled the candidate pool and pushed IRPA s. 112 out of the top 20
228
+ of "what is a pre-removal risk assessment" entirely. If a new corpus
229
+ family lands, check which field carries its document identity before
230
+ trusting the caps.
canlex/directive.py CHANGED
@@ -151,11 +151,14 @@ def parse_directive(html, url, title, date):
151
 
152
 
153
  def _print_link(html):
154
- """Find a 'Print Full Directive' / 'Print all FSDs' link on a TOC page."""
 
155
  soup = BeautifulSoup(html, "html.parser")
156
  for a in soup.find_all("a", href=True):
157
  text = _norm(a.get_text(" ", strip=True)).lower()
158
- if "print full directive" in text or "print all fsd" in text:
 
 
159
  return a["href"] if a["href"].startswith("http") else BASE + a["href"]
160
  return None
161
 
@@ -222,11 +225,71 @@ def ingest(force=False, limit=None, allow_shrink=False):
222
  return True
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def main():
226
  force = "--force" in sys.argv
227
  limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
228
  if a.startswith("--limit=")), None)
229
- ok = ingest(force=force, limit=limit,
 
230
  allow_shrink="--allow-shrink" in sys.argv)
231
  sys.exit(0 if ok else 1)
232
 
 
151
 
152
 
153
  def _print_link(html):
154
+ """Find a 'Print Full Directive' / 'Print all FSDs' link on a TOC page
155
+ (or its French label on the /fr editions)."""
156
  soup = BeautifulSoup(html, "html.parser")
157
  for a in soup.find_all("a", href=True):
158
  text = _norm(a.get_text(" ", strip=True)).lower()
159
+ if ("print full directive" in text or "print all fsd" in text
160
+ or "imprimer la directive" in text
161
+ or "imprimer toutes" in text or "imprimez toutes" in text):
162
  return a["href"] if a["href"].startswith("http") else BASE + a["href"]
163
  return None
164
 
 
225
  return True
226
 
227
 
228
+ OUT_FILE_FR = OUT_FILE.with_name("directives-fr.json")
229
+
230
+
231
+ def ingest_fr(force=False, limit=None, allow_shrink=False):
232
+ """The French editions of the NJC directives, mirroring ingest().
233
+
234
+ The NJC publishes each directive at .../directive/<code>/fr with the
235
+ same markup as /en, so the parser is reused as-is. The EN index supplies
236
+ the directive list and identity (act_short stays the English title so
237
+ act filters behave one way regardless of language); chunks get
238
+ lang='fr' and a -fr id infix, and language-routed retrieval serves them
239
+ only to French queries."""
240
+ directives = directive_links(force=force)
241
+ if limit:
242
+ directives = directives[:limit]
243
+ print(f"Ingesting {len(directives)} directives du CNM (français)...")
244
+ all_chunks, failures = [], []
245
+ for url, title, date in directives:
246
+ code_match = _CODE.search(url)
247
+ code = code_match.group(1) if code_match else "x"
248
+ fr_url = re.sub(r"/en$", "/fr", url)
249
+ try:
250
+ html = _fetch(fr_url, DIRECTIVE_DIR / f"{code}-fr.html",
251
+ force=force)
252
+ chunks = parse_directive(html, fr_url, title, date)
253
+ if not chunks:
254
+ print_url = _print_link(html)
255
+ if print_url:
256
+ full = _fetch(print_url,
257
+ DIRECTIVE_DIR / f"{code}-fr-full.html",
258
+ force=force)
259
+ chunks = parse_directive(full, fr_url, title, date)
260
+ if chunks:
261
+ all_chunks.extend(chunks)
262
+ print(f" {title} (fr): {len(chunks)} chunks")
263
+ else:
264
+ failures.append((code, title, "no content parsed"))
265
+ except Exception as exc:
266
+ failures.append((code, title, f"{type(exc).__name__}: {exc}"))
267
+ for c in all_chunks:
268
+ c["id"] = c["id"].replace("directive-", "directive-fr-", 1)
269
+ c["lang"] = "fr"
270
+ if not limit:
271
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
272
+ stored_chunks(OUT_FILE_FR))
273
+ if preserved:
274
+ print(f" kept the last-good copy of {len(preserved)} "
275
+ f"unparseable directive(s)")
276
+ for _code, title, why in failures:
277
+ print(f" FAILED {title} (fr): {why}")
278
+ if not write_corpus(OUT_FILE_FR, all_chunks, indent=2,
279
+ allow_shrink=allow_shrink):
280
+ return False
281
+ print(f"\n{len(all_chunks)} chunks from "
282
+ f"{len(directives) - len(failures)} directives -> "
283
+ f"{OUT_FILE_FR.name}")
284
+ return True
285
+
286
+
287
  def main():
288
  force = "--force" in sys.argv
289
  limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
290
  if a.startswith("--limit=")), None)
291
+ runner = ingest_fr if "--fr" in sys.argv else ingest
292
+ ok = runner(force=force, limit=limit,
293
  allow_shrink="--allow-shrink" in sys.argv)
294
  sys.exit(0 if ok else 1)
295
 
canlex/dmemo.py CHANGED
@@ -32,10 +32,15 @@ DMEMO_DIR = RAW_DIR / "dmemos"
32
  OUT_FILE = PROCESSED_DIR / "dmemos.json"
33
 
34
  # <h2> headings that are page boilerplate rather than memo content.
35
- _SKIP_HEADINGS = {"contact us", "related links"}
36
- _MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-eng\.html")
37
- _URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-eng\.html")
 
 
 
38
  _DS_HREF = re.compile(r'href="([^"]+)"')
 
 
39
 
40
 
41
  def _fetch(url, dest, force=False):
@@ -46,7 +51,7 @@ def datasource_urls(raw):
46
  """Active D-memo URLs from the index's JSON datasource. Pure, for tests."""
47
  urls, seen = [], set()
48
  for row in json.loads(raw).get("data", []):
49
- if _norm(row.get("STATUS", "")).lower() == "cancelled":
50
  continue
51
  match = _DS_HREF.search(row.get("NUMBER", ""))
52
  if not match:
@@ -299,11 +304,62 @@ def ingest(force=False, limit=None, allow_shrink=False):
299
  return True
300
 
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  def main():
303
  force = "--force" in sys.argv
304
  limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
305
  if a.startswith("--limit=")), None)
306
- ok = ingest(force=force, limit=limit,
 
307
  allow_shrink="--allow-shrink" in sys.argv)
308
  sys.exit(0 if ok else 1)
309
 
 
32
  OUT_FILE = PROCESSED_DIR / "dmemos.json"
33
 
34
  # <h2> headings that are page boilerplate rather than memo content.
35
+ _SKIP_HEADINGS = {"contact us", "related links",
36
+ "contactez-nous", "liens connexes"}
37
+ # Bilingual: the French editions live at the same paths with -fra.html, so
38
+ # one pattern serves both ingest passes.
39
+ _MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-(?:eng|fra)\.html")
40
+ _URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-(?:eng|fra)\.html")
41
  _DS_HREF = re.compile(r'href="([^"]+)"')
42
+ DATASOURCE_URL_FR = DATASOURCE_URL.replace("-eng.json", "-fra.json")
43
+ OUT_FILE_FR = PROCESSED_DIR / "dmemos-fr.json"
44
 
45
 
46
  def _fetch(url, dest, force=False):
 
51
  """Active D-memo URLs from the index's JSON datasource. Pure, for tests."""
52
  urls, seen = [], set()
53
  for row in json.loads(raw).get("data", []):
54
+ if _norm(row.get("STATUS", "")).lower() in ("cancelled", "annulé"):
55
  continue
56
  match = _DS_HREF.search(row.get("NUMBER", ""))
57
  if not match:
 
304
  return True
305
 
306
 
307
+ def ingest_fr(force=False, limit=None, allow_shrink=False):
308
+ """The French editions (Mémorandums D), mirroring ingest().
309
+
310
+ Same pages at -fra.html, same WET markup, same parser; the chunks are
311
+ post-processed with lang='fr', a dmemo-fr- id prefix and the French
312
+ citation so language-routed retrieval (index.query_lang) serves them
313
+ only to French queries."""
314
+ try:
315
+ urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
316
+ DMEMO_DIR / "_datasource-fra.json",
317
+ force=force))
318
+ except Exception as exc:
319
+ print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
320
+ urls = []
321
+ if limit:
322
+ urls = urls[:limit]
323
+ print(f"Ingesting {len(urls)} Mémorandums D (français)...")
324
+ all_chunks, failures = [], []
325
+ for i, url in enumerate(urls, 1):
326
+ try:
327
+ html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
328
+ chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
329
+ if chunks:
330
+ all_chunks.extend(chunks)
331
+ else:
332
+ failures.append((url, "no content parsed"))
333
+ except Exception as exc:
334
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
335
+ if i % 50 == 0:
336
+ print(f" {i}/{len(urls)} ...")
337
+ for c in all_chunks:
338
+ c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
339
+ c["lang"] = "fr"
340
+ c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
341
+ if failures:
342
+ print(f" {len(failures)} mémorandums with no content / errors "
343
+ f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
344
+ if not limit:
345
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
346
+ stored_chunks(OUT_FILE_FR))
347
+ if preserved:
348
+ print(f" kept the last-good copy of {len(preserved)} "
349
+ f"unfetchable mémorandum(s)")
350
+ if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
351
+ return False
352
+ print(f" {len(all_chunks)} section-chunks from "
353
+ f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
354
+ return True
355
+
356
+
357
  def main():
358
  force = "--force" in sys.argv
359
  limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
360
  if a.startswith("--limit=")), None)
361
+ runner = ingest_fr if "--fr" in sys.argv else ingest
362
+ ok = runner(force=force, limit=limit,
363
  allow_shrink="--allow-shrink" in sys.argv)
364
  sys.exit(0 if ok else 1)
365
 
canlex/index.py CHANGED
@@ -71,6 +71,10 @@ APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
71
  # items are distinct provisions, so (like legislation) they are never collapsed
72
  # under the diversity cap.
73
  PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
 
 
 
 
74
 
75
  _TOKEN = re.compile(r"[a-z0-9]+")
76
  # A chunk id ending '-p<k>' marks one embeddable piece of a long section that
@@ -237,6 +241,8 @@ class LegislationIndex:
237
  raise RuntimeError(
238
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
239
  self._tri_index = None # lazy; see _fuzzy_term
 
 
240
  # Cached recall mask for the common case (English query, no
241
  # filters): everything but the French twins.
242
  self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
@@ -248,9 +254,22 @@ class LegislationIndex:
248
  self._load_reranker()
249
 
250
  def _build_bm25(self):
 
 
 
 
 
 
 
 
 
 
 
251
  self.doc_len = []
252
  self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
253
  df = defaultdict(int)
 
 
254
  for idx, c in enumerate(self.chunks):
255
  # The topical title is repeated to weight it above body text;
256
  # the Act name, code and section are indexed too, so an Act's own
@@ -265,12 +284,24 @@ class LegislationIndex:
265
  c["section"], c["text"]))
266
  counts = Counter(tokenize(blob))
267
  self.doc_len.append(sum(counts.values()))
 
 
 
 
268
  for term, tf in counts.items():
269
  self.postings[term].append((idx, tf))
270
  df[term] += 1
 
 
271
  n = len(self.chunks)
272
  self.avgdl = sum(self.doc_len) / n
273
  self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
 
 
 
 
 
 
274
 
275
  def _build_note_tokens(self):
276
  """Pre-tokenise each chunk's topical title (see topical_title) for the
@@ -390,28 +421,128 @@ class LegislationIndex:
390
  best, best_score = cand, score
391
  return best
392
 
393
- def _bm25_scores(self, query, allowed=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  """allowed: optional boolean list by chunk index. Filtered searches
395
  pass it so recall competes only within scope -- filtering after
396
  recall meant a doc_type='caselaw' search drew from whatever case law
397
- happened to survive the open-corpus top-N competition."""
 
 
 
 
 
 
 
398
  scores = defaultdict(float)
399
  for term in set(tokenize(query)):
400
- idf = self.idf.get(term)
401
  weight = 1.0
402
  if idf is None:
403
  # Unknown token: try the typo bridge at a discount, so a
404
  # misspelled query still reaches the provision while a real
405
  # match always outranks a fuzzy one.
406
  alt = self._fuzzy_term(term)
407
- if alt is None:
408
  continue
409
- term, idf, weight = alt, self.idf[alt], 0.7
410
  for idx, tf in self.postings[term]:
411
  if allowed is not None and not allowed[idx]:
412
  continue
413
  dl = self.doc_len[idx]
414
- denom = tf + K1 * (1 - B + B * dl / self.avgdl)
415
  scores[idx] += weight * idf * tf * (K1 + 1) / denom
416
  return scores
417
 
@@ -472,7 +603,23 @@ class LegislationIndex:
472
  if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
473
  return None
474
  if doc_type == "memorandum":
475
- return ("memorandum", c["section"]) # act_code is a shared constant
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  return (doc_type, c["act_code"]) # one decision, keyed by citation
477
 
478
  def _capped_top(self, ordered):
@@ -654,9 +801,14 @@ class LegislationIndex:
654
  def search(self, query, top_k=6, act=None, doc_type=None, court=None,
655
  decided_after=None):
656
  """Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
657
- # Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
658
- # the recall stages; the reranker sees the original AND expanded forms.
659
- expanded = expand_query(query)
 
 
 
 
 
660
  qlang = query_lang(query)
661
  confidence = None
662
  fused = defaultdict(float)
@@ -673,7 +825,8 @@ class LegislationIndex:
673
  mask = self._en_mask
674
  else:
675
  mask = None
676
- bm25 = self._bm25_scores(expanded, allowed=mask)
 
677
  for rank, idx in enumerate(self._capped_top(
678
  sorted(bm25, key=bm25.get, reverse=True))):
679
  fused[idx] += 1.0 / (RRF_K + rank)
@@ -756,13 +909,18 @@ class LegislationIndex:
756
  pool = candidates[:RERANK_POOL]
757
  docs = [self._rerank_doc(i) for i in pool]
758
  ce_orig = self.reranker.score(query, docs)
759
- # Optional (CANLEX_RERANK_DUAL=1): also score the expanded query
760
- # and keep each candidate's better score, closing the reranker's
761
- # abbreviation blindness ('PRRA' vs 'application for
762
- # protection'). Off by default: even promote-only, a second
763
- # scoring pass reorders relative ranks, and the 2026-07 eval
764
- # could not attribute its effect cleanly on the grown corpus.
765
- if expanded != query and os.environ.get("CANLEX_RERANK_DUAL"):
 
 
 
 
 
766
  ce_exp = self.reranker.score(expanded, docs)
767
  ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
768
  ce = dict(zip(pool, ce_orig))
 
71
  # items are distinct provisions, so (like legislation) they are never collapsed
72
  # under the diversity cap.
73
  PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
74
+ # Memorandum families whose act_code is ONE shared constant over many
75
+ # independent items, so the diversity cap keys on `section` instead (see
76
+ # _source_key): a D-memo number, an AMPS contravention code.
77
+ _FLAT_MEMO_CODES = frozenset({"D-Memo", "AMPS"})
78
 
79
  _TOKEN = re.compile(r"[a-z0-9]+")
80
  # A chunk id ending '-p<k>' marks one embeddable piece of a long section that
 
241
  raise RuntimeError(
242
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
243
  self._tri_index = None # lazy; see _fuzzy_term
244
+ self._word_vocab = None # lazy; see _correct_word (typo repair)
245
+ self._word_tri = None
246
  # Cached recall mask for the common case (English query, no
247
  # filters): everything but the French twins.
248
  self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
 
254
  self._load_reranker()
255
 
256
  def _build_bm25(self):
257
+ """BM25 postings plus TWO sets of collection statistics: one over the
258
+ whole corpus, one over the English scope alone.
259
+
260
+ An English query never retrieves the French twins (see _en_mask), yet
261
+ a single corpus-wide idf/avgdl let those unreachable chunks set the
262
+ rarity of every shared token (numbers, act codes, proper nouns, folded
263
+ cognates) and drag the average document length. Ingesting the French
264
+ D-memos and NJC directives moved legacy Hit@1 0.767 -> 0.750 that way,
265
+ without a single French chunk ever being retrievable -- the statistics
266
+ must be scoped to what the query can actually reach.
267
+ """
268
  self.doc_len = []
269
  self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
270
  df = defaultdict(int)
271
+ df_en = defaultdict(int)
272
+ en_docs = en_total_len = 0
273
  for idx, c in enumerate(self.chunks):
274
  # The topical title is repeated to weight it above body text;
275
  # the Act name, code and section are indexed too, so an Act's own
 
284
  c["section"], c["text"]))
285
  counts = Counter(tokenize(blob))
286
  self.doc_len.append(sum(counts.values()))
287
+ is_en = c.get("lang", "en") != "fr"
288
+ if is_en:
289
+ en_docs += 1
290
+ en_total_len += self.doc_len[-1]
291
  for term, tf in counts.items():
292
  self.postings[term].append((idx, tf))
293
  df[term] += 1
294
+ if is_en:
295
+ df_en[term] += 1
296
  n = len(self.chunks)
297
  self.avgdl = sum(self.doc_len) / n
298
  self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
299
+ # The English-scoped twins. A term the English corpus never uses is
300
+ # absent from idf_en rather than zero-weighted: it can only match
301
+ # chunks the mask already excludes.
302
+ self.avgdl_en = en_total_len / (en_docs or 1)
303
+ self.idf_en = {t: math.log(1 + (en_docs - d + 0.5) / (d + 0.5))
304
+ for t, d in df_en.items()}
305
 
306
  def _build_note_tokens(self):
307
  """Pre-tokenise each chunk's topical title (see topical_title) for the
 
421
  best, best_score = cand, score
422
  return best
423
 
424
+ @staticmethod
425
+ def _edit_distance(a, b, cap=3):
426
+ """Damerau-Levenshtein distance, early-exited past `cap` -- typos
427
+ are substitutions, insertions, deletions and transpositions, and a
428
+ transposition ('undeclraed') must cost 1, not 2."""
429
+ if abs(len(a) - len(b)) > cap:
430
+ return cap + 1
431
+ prev2, prev = None, list(range(len(b) + 1))
432
+ for i, ca in enumerate(a, start=1):
433
+ cur = [i]
434
+ best_row = i
435
+ for j, cb in enumerate(b, start=1):
436
+ cost = 0 if ca == cb else 1
437
+ d = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
438
+ if (prev2 is not None and i > 1 and j > 1
439
+ and ca == b[j - 2] and a[i - 2] == cb):
440
+ d = min(d, prev2[j - 2] + 1)
441
+ cur.append(d)
442
+ best_row = min(best_row, d)
443
+ if best_row > cap:
444
+ return cap + 1
445
+ prev2, prev = prev, cur
446
+ return prev[-1]
447
+
448
+ def _correct_word(self, word):
449
+ """Nearest corpus WORD (not stem) for a misspelling, or None.
450
+
451
+ Trigram index generates candidates; Damerau-Levenshtein picks the
452
+ winner (distance <= 2, shared 2-char prefix, frequency floor of 5
453
+ so OCR debris in the corpus can never be a 'correction'). The
454
+ result is real text: it feeds the semantic embedder and the
455
+ cross-encoder, which score words, not stems."""
456
+ if self._word_vocab is None:
457
+ words = Counter()
458
+ for c in self.chunks:
459
+ words.update(re.findall(
460
+ r"[a-z]{4,}", _fold_accents(c["text"].lower())))
461
+ tri = defaultdict(set)
462
+ for w, n in words.items():
463
+ if n >= 5: # junk never enters the index
464
+ for i in range(len(w) - 2):
465
+ tri[w[i:i + 3]].add(w)
466
+ self._word_vocab, self._word_tri = words, tri
467
+ tris = {word[i:i + 3] for i in range(len(word) - 2)}
468
+ candidates = set()
469
+ for t in tris:
470
+ candidates |= self._word_tri.get(t, set())
471
+ best, best_key = None, (3, 0) # (distance, -frequency)
472
+ for cand in candidates:
473
+ if abs(len(cand) - len(word)) > 2 or cand[:2] != word[:2]:
474
+ continue
475
+ dist = self._edit_distance(word, cand, cap=2)
476
+ if dist > 2:
477
+ continue
478
+ key = (dist, -self._word_vocab[cand])
479
+ if key < best_key:
480
+ best, best_key = cand, key
481
+ return best
482
+
483
+ def _correct_query(self, query):
484
+ """The query with misspelled words replaced by their nearest corpus
485
+ words; the query itself when nothing needed fixing. A word is only
486
+ 'misspelled' when its stem is absent from the whole BM25 vocabulary,
487
+ so correctly-spelled queries pass through untouched by
488
+ construction."""
489
+ out, changed = [], False
490
+ for tok in query.split():
491
+ word = _fold_accents(tok.lower()).strip(".,;:!?()'\"")
492
+ if len(word) >= 4 and word.isalpha():
493
+ stem = _stem(word)
494
+ df = len(self.postings.get(stem, ()))
495
+ if df == 0:
496
+ # Unknown everywhere: replace outright.
497
+ fix = self._correct_word(word)
498
+ if fix and fix != word:
499
+ out.append(fix)
500
+ changed = True
501
+ continue
502
+ elif df <= 2:
503
+ # Nearly unknown -- often a typo that happens to collide
504
+ # with OCR debris in the corpus. APPEND a strong
505
+ # correction rather than replacing, so a legitimately
506
+ # rare term (a chemical in a CDSA schedule, a case name)
507
+ # never loses its own recall.
508
+ fix = self._correct_word(word)
509
+ if (fix and fix != word
510
+ and self._word_vocab[fix] >= 100):
511
+ out.append(f"{tok} {fix}")
512
+ changed = True
513
+ continue
514
+ out.append(tok)
515
+ return " ".join(out) if changed else query
516
+
517
+ def _bm25_scores(self, query, allowed=None, en_only=False):
518
  """allowed: optional boolean list by chunk index. Filtered searches
519
  pass it so recall competes only within scope -- filtering after
520
  recall meant a doc_type='caselaw' search drew from whatever case law
521
+ happened to survive the open-corpus top-N competition.
522
+
523
+ en_only: score with the English-scoped collection statistics (see
524
+ _build_bm25). Set for every English query, whose mask already bars
525
+ the French twins from the results they would otherwise be weighting.
526
+ """
527
+ idf_map = self.idf_en if en_only else self.idf
528
+ avgdl = self.avgdl_en if en_only else self.avgdl
529
  scores = defaultdict(float)
530
  for term in set(tokenize(query)):
531
+ idf = idf_map.get(term)
532
  weight = 1.0
533
  if idf is None:
534
  # Unknown token: try the typo bridge at a discount, so a
535
  # misspelled query still reaches the provision while a real
536
  # match always outranks a fuzzy one.
537
  alt = self._fuzzy_term(term)
538
+ if alt is None or alt not in idf_map:
539
  continue
540
+ term, idf, weight = alt, idf_map[alt], 0.7
541
  for idx, tf in self.postings[term]:
542
  if allowed is not None and not allowed[idx]:
543
  continue
544
  dl = self.doc_len[idx]
545
+ denom = tf + K1 * (1 - B + B * dl / avgdl)
546
  scores[idx] += weight * idf * tf * (K1 + 1) / denom
547
  return scores
548
 
 
603
  if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
604
  return None
605
  if doc_type == "memorandum":
606
+ # The key must be the parent DOCUMENT, and which field carries that
607
+ # identity differs across the memorandum families. ENF chapters,
608
+ # PDI pages and the IRB guidelines each have their own act_code and
609
+ # use `section` to subdivide it, so act_code is the document.
610
+ # _FLAT_MEMO_CODES are the two families where one act_code covers
611
+ # many independent items -- ~360 D-memoranda and ~1,000 AMPS
612
+ # contraventions -- and `section` (memo number / contravention
613
+ # code) is the unit a reader would cite.
614
+ #
615
+ # Keying every memorandum on `section` (as this did) gave each ENF
616
+ # section and each PDI page its own source key, so neither cap ever
617
+ # bound at the document level: the ~150-page PDI refugee tree could
618
+ # fill the whole candidate pool and push IRPA s. 112 out of a PRRA
619
+ # query entirely.
620
+ code = c["act_code"]
621
+ return ("memorandum",
622
+ c["section"] if code in _FLAT_MEMO_CODES else code)
623
  return (doc_type, c["act_code"]) # one decision, keyed by citation
624
 
625
  def _capped_top(self, ordered):
 
801
  def search(self, query, top_k=6, act=None, doc_type=None, court=None,
802
  decided_after=None):
803
  """Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
804
+ # Repair misspellings first (no-op for correctly-spelled queries),
805
+ # then expand legal abbreviations (PRRA, H&C, ...) into statutory
806
+ # wording for the recall stages. The corrected text is what feeds
807
+ # the semantic embedder and the cross-encoder -- the stem-level
808
+ # fuzzy bridge in _bm25_scores only ever helped BM25, which is why
809
+ # typo queries used to reach the pool but not the top ranks.
810
+ corrected = self._correct_query(query)
811
+ expanded = expand_query(corrected)
812
  qlang = query_lang(query)
813
  confidence = None
814
  fused = defaultdict(float)
 
825
  mask = self._en_mask
826
  else:
827
  mask = None
828
+ bm25 = self._bm25_scores(expanded, allowed=mask,
829
+ en_only=(qlang == "en"))
830
  for rank, idx in enumerate(self._capped_top(
831
  sorted(bm25, key=bm25.get, reverse=True))):
832
  fused[idx] += 1.0 / (RRF_K + rank)
 
909
  pool = candidates[:RERANK_POOL]
910
  docs = [self._rerank_doc(i) for i in pool]
911
  ce_orig = self.reranker.score(query, docs)
912
+ # A repaired query is scored alongside the original and each
913
+ # candidate keeps its better score -- the cross-encoder cannot
914
+ # ground 'detension' against text saying 'detention'. Applies
915
+ # only when a correction fired, so legacy queries never take
916
+ # the second pass.
917
+ if corrected != query:
918
+ ce_fix = self.reranker.score(corrected, docs)
919
+ ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_fix)]
920
+ # Optional (CANLEX_RERANK_DUAL=1): likewise for the
921
+ # synonym-expanded form. Off by default: the 2026-07 eval could
922
+ # not attribute its effect cleanly on the grown corpus.
923
+ if expanded != corrected and os.environ.get("CANLEX_RERANK_DUAL"):
924
  ce_exp = self.reranker.score(expanded, docs)
925
  ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
926
  ce = dict(zip(pool, ce_orig))
canlex/pdi.py CHANGED
@@ -21,19 +21,52 @@ OUT_FILE = PROCESSED_DIR / "pdi.json"
21
  _OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
22
  "publications-manuals/operational-bulletins-manuals/")
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  SETS = [
25
  {"code": "pdi-prcard",
26
  "short": "PDI PR Card",
27
  "name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
28
  "index": _OBM + "permanent-residence/card.html",
29
- # sub-pages live under these path fragments relative to the index
30
  "scope": "/permanent-residence/card"},
31
- {"code": "pdi-tran",
32
- "short": "PDI Tran",
33
- "name": "PDI — Assessing inadmissibility for serious criminality "
34
- "following Tran v. Canada",
35
- "index": _OBM + "standard-requirements/tran.html",
36
- "scope": None}, # single page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ]
38
 
39
  _MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
@@ -116,17 +149,25 @@ def preserve_failed(chunks, failures, stored):
116
  only={url for url, _why in failures})
117
 
118
 
119
- def build(allow_shrink=False):
120
- """Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
121
- all_chunks, failures = [], []
122
- for src in SETS:
123
- index_html = fetch_cached(
124
- src["index"], RAW / f"{src['code']}-index.html",
125
- powershell=True, pause=0.8).decode("utf-8", "replace")
126
- pages = [(src["index"], index_html)]
127
- if src["scope"]:
128
- seen = {src["index"]}
129
- for href in _LINK.findall(_main(index_html)):
 
 
 
 
 
 
 
 
130
  if src["scope"] not in href:
131
  continue
132
  url = href if href.startswith("http") else \
@@ -134,15 +175,38 @@ def build(allow_shrink=False):
134
  if url in seen:
135
  continue
136
  seen.add(url)
 
 
 
137
  name = url.rsplit("/", 1)[1].replace(".html", "")
138
  try:
139
  page = fetch_cached(
140
  url, RAW / f"{src['code']}-{name}.html",
141
  powershell=True, pause=0.8).decode("utf-8", "replace")
142
- pages.append((url, page))
143
  except Exception as exc:
144
  failures.append((url, f"{type(exc).__name__}: {exc}"))
145
  print(f" !! {name}: {type(exc).__name__}: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  n = 0
147
  for page_no, (url, html) in enumerate(pages):
148
  chunks = _page_chunks(src, url, html, page_no)
 
21
  _OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
22
  "publications-manuals/operational-bulletins-manuals/")
23
 
24
+ # Each set is one PDI category tree, crawled breadth-first from its index to
25
+ # _MAX_DEPTH, capped at _MAX_PAGES pages (dropped pages are LOGGED, never
26
+ # silently skipped). Scope fragments keep the crawl inside the category. The
27
+ # selection is the border-relevant slice of the OBM tree: temporary
28
+ # residents (visitors/eTA/TRV/TRPs), refugee protection (port-of-entry
29
+ # claims), the cross-cutting standard requirements (includes the Tran
30
+ # serious-criminality page), officer designation/delegation, biometrics and
31
+ # identity, and the PR-card section (ENF 27's successor). Deliberately out:
32
+ # citizenship, fees, office service-delivery, and the year-by-year
33
+ # operational-bulletin archives (mostly expired instructions).
34
+ _MAX_DEPTH = 2
35
+ _MAX_PAGES = 160 # 60 capped out: the Temporary Residents tree alone has
36
+ # ~150 in-scope pages (2026-07 crawl)
37
+
38
  SETS = [
39
  {"code": "pdi-prcard",
40
  "short": "PDI PR Card",
41
  "name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
42
  "index": _OBM + "permanent-residence/card.html",
 
43
  "scope": "/permanent-residence/card"},
44
+ {"code": "pdi-tr",
45
+ "short": "PDI Temporary Residents",
46
+ "name": "PDI — Temporary Resident Program (visitors, eTA, TRV, permits)",
47
+ "index": _OBM + "temporary-residents.html",
48
+ "scope": "/temporary-residents"},
49
+ {"code": "pdi-refugee",
50
+ "short": "PDI Refugee Protection",
51
+ "name": "PDI — Refugee Protection (claims, eligibility, port of entry)",
52
+ "index": _OBM + "refugee-protection.html",
53
+ "scope": "/refugee-protection"},
54
+ {"code": "pdi-standard",
55
+ "short": "PDI Standard Requirements",
56
+ "name": "PDI — Standard requirements (incl. Tran serious-criminality "
57
+ "assessment)",
58
+ "index": _OBM + "standard-requirements.html",
59
+ "scope": "/standard-requirements"},
60
+ {"code": "pdi-delegation",
61
+ "short": "PDI Designation-Delegation",
62
+ "name": "PDI — Designation of officers and delegation of authority",
63
+ "index": _OBM + "legislation.html",
64
+ "scope": "operational-bulletins-manuals/legislation"},
65
+ {"code": "pdi-identity",
66
+ "short": "PDI Biometrics-Identity",
67
+ "name": "PDI — Biometrics and identity management",
68
+ "index": _OBM + "identity-management.html",
69
+ "scope": "/identity-management"},
70
  ]
71
 
72
  _MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
 
149
  only={url for url, _why in failures})
150
 
151
 
152
+ def _crawl(src, failures):
153
+ """Breadth-first crawl of one PDI category: index -> in-scope links ->
154
+ their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
155
+ Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
156
+ failures land in `failures` so preserve_failed keeps their stored
157
+ copies."""
158
+ index_html = fetch_cached(
159
+ src["index"], RAW / f"{src['code']}-index.html",
160
+ powershell=True, pause=0.8).decode("utf-8", "replace")
161
+ pages = [(src["index"], index_html)]
162
+ if not src["scope"]:
163
+ return pages
164
+ seen = {src["index"]}
165
+ frontier = [(src["index"], index_html)]
166
+ dropped = 0
167
+ for _depth in range(_MAX_DEPTH):
168
+ next_frontier = []
169
+ for _url, html in frontier:
170
+ for href in _LINK.findall(_main(html)):
171
  if src["scope"] not in href:
172
  continue
173
  url = href if href.startswith("http") else \
 
175
  if url in seen:
176
  continue
177
  seen.add(url)
178
+ if len(pages) >= _MAX_PAGES:
179
+ dropped += 1
180
+ continue
181
  name = url.rsplit("/", 1)[1].replace(".html", "")
182
  try:
183
  page = fetch_cached(
184
  url, RAW / f"{src['code']}-{name}.html",
185
  powershell=True, pause=0.8).decode("utf-8", "replace")
 
186
  except Exception as exc:
187
  failures.append((url, f"{type(exc).__name__}: {exc}"))
188
  print(f" !! {name}: {type(exc).__name__}: {exc}")
189
+ continue
190
+ pages.append((url, page))
191
+ next_frontier.append((url, page))
192
+ frontier = next_frontier
193
+ if dropped:
194
+ print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
195
+ f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
196
+ return pages
197
+
198
+
199
+ def build(allow_shrink=False):
200
+ """Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
201
+ all_chunks, failures = [], []
202
+ for src in SETS:
203
+ try:
204
+ pages = _crawl(src, failures)
205
+ except Exception as exc:
206
+ failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
207
+ print(f" !! {src['short']}: index fetch failed "
208
+ f"({type(exc).__name__}: {exc})")
209
+ continue
210
  n = 0
211
  for page_no, (url, html) in enumerate(pages):
212
  chunks = _page_chunks(src, url, html, page_no)
canlex/refresh.py CHANGED
@@ -71,7 +71,12 @@ _NON_XML = [
71
  ("commentary", "commentary.json", "py -m canlex.commentary",
72
  "Curated commentary chunks"),
73
  ("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
74
- ("pdi", "pdi.json", "py -m canlex.pdi", "IRCC PDI pages (PR card / Tran)"),
 
 
 
 
 
75
  ("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
76
  "IRB Chairperson's Guidelines"),
77
  ]
 
71
  ("commentary", "commentary.json", "py -m canlex.commentary",
72
  "Curated commentary chunks"),
73
  ("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
74
+ ("pdi", "pdi.json", "py -m canlex.pdi",
75
+ "IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
76
+ ("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
77
+ "Mémorandums D (français)"),
78
+ ("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
79
+ "Directives du CNM (français)"),
80
  ("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
81
  "IRB Chairperson's Guidelines"),
82
  ]
data/processed/directives-fr.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/dmemos-fr.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/embeddings.npz CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:b6380fda9c44fafe766c7cf83a1b40f8a7ef8a689533069d8181b3a28a79c06f
3
- size 78503650
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2fa0640d280572773d6e35b23454c4de8e589a5d658a11a79e76175cce4faab3
3
+ size 91712138
data/processed/pdi.json CHANGED
The diff for this file is too large to render. See raw diff
 
tests/test_sprint.py CHANGED
@@ -142,6 +142,145 @@ class MultiVectorTests(unittest.TestCase):
142
  self.assertTrue(all(t.startswith("A . ") for t in texts[1:]))
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  class RefreshCuratedTests(unittest.TestCase):
146
  def test_check_curated_runs_offline(self):
147
  from canlex.refresh import check_curated
 
142
  self.assertTrue(all(t.startswith("A . ") for t in texts[1:]))
143
 
144
 
145
+ class TypoCorrectionTests(unittest.TestCase):
146
+ def test_edit_distance(self):
147
+ from canlex.index import LegislationIndex as L
148
+ self.assertEqual(L._edit_distance("detension", "detention"), 1)
149
+ self.assertEqual(L._edit_distance("undeclraed", "undeclared"), 1)
150
+ self.assertEqual(L._edit_distance("serius", "serious"), 1)
151
+ self.assertEqual(L._edit_distance("same", "same"), 0)
152
+ self.assertGreater(L._edit_distance("apple", "orange"), 2)
153
+
154
+ def test_edit_distance_transposition_costs_one(self):
155
+ from canlex.index import LegislationIndex as L
156
+ self.assertEqual(L._edit_distance("sieze", "seize"), 1)
157
+
158
+ @staticmethod
159
+ def _index(text, postings=None):
160
+ """A bare index carrying just what the correction layer reads."""
161
+ from canlex.index import LegislationIndex
162
+ idx = LegislationIndex.__new__(LegislationIndex)
163
+ idx.chunks = [{"text": text}]
164
+ idx.postings = postings or {}
165
+ idx._word_vocab = idx._word_tri = None
166
+ return idx
167
+
168
+ def test_corrects_to_the_nearest_corpus_word(self):
169
+ idx = self._index("detention review " * 6)
170
+ self.assertEqual(idx._correct_word("detension"), "detention")
171
+
172
+ def test_rare_corpus_word_is_never_a_correction(self):
173
+ # Frequency floor of 5: OCR debris must not win a correction.
174
+ idx = self._index("detentlon " * 4 + "detention " * 9)
175
+ self.assertEqual(idx._correct_word("detension"), "detention")
176
+
177
+ def test_known_query_passes_through_untouched(self):
178
+ query = "detention review timelines"
179
+ idx = self._index("detention review " * 6,
180
+ postings={"detent": [0], "review": [0],
181
+ "timelin": [0]})
182
+ self.assertIs(idx._correct_query(query), query)
183
+
184
+ def test_unknown_word_is_replaced(self):
185
+ idx = self._index("detention review " * 6, postings={"review": [0]})
186
+ self.assertEqual(idx._correct_query("detension review"),
187
+ "detention review")
188
+
189
+ def test_nearly_unknown_word_is_appended_not_replaced(self):
190
+ # df <= 2 -- a rare legitimate term keeps its own recall, so the
191
+ # correction is added beside it rather than swapped in.
192
+ idx = self._index("seizure " * 120 + "seizuer ",
193
+ postings={"seizuer": [0]})
194
+ self.assertEqual(idx._correct_query("seizuer"), "seizuer seizure")
195
+
196
+
197
+ class LanguageScopedBM25Tests(unittest.TestCase):
198
+ """The French twins must not weight English retrieval (see _build_bm25)."""
199
+
200
+ @staticmethod
201
+ def _index(chunks):
202
+ from canlex.index import LegislationIndex
203
+ idx = LegislationIndex.__new__(LegislationIndex)
204
+ idx.chunks = chunks
205
+ idx._tri_index = None
206
+ idx._build_bm25()
207
+ return idx
208
+
209
+ @staticmethod
210
+ def _chunk(text, lang="en"):
211
+ return {"doc_type": "legislation", "act_code": "I-2.5", "act_name": "A",
212
+ "act_short": "A", "section": "1", "marginal_note": "",
213
+ "heading": "", "part": "", "division": "", "lang": lang,
214
+ "text": text}
215
+
216
+ def test_french_chunks_do_not_move_english_idf(self):
217
+ en = [self._chunk("detention review"), self._chunk("seizure")]
218
+ idx_en = self._index(list(en))
219
+ idx_mixed = self._index(en + [self._chunk("detention", lang="fr")] * 8)
220
+ self.assertAlmostEqual(idx_en.idf["detain"], idx_mixed.idf_en["detain"])
221
+ # ... while the whole-corpus statistic, which French queries use, does
222
+ # see them.
223
+ self.assertNotAlmostEqual(idx_en.idf["detain"], idx_mixed.idf["detain"])
224
+
225
+ def test_french_chunks_do_not_move_english_avgdl(self):
226
+ en = [self._chunk("detention review of a permanent resident")]
227
+ idx_en = self._index(list(en))
228
+ idx_mixed = self._index(en + [self._chunk("a b c d e f g h", lang="fr")])
229
+ self.assertAlmostEqual(idx_en.avgdl, idx_mixed.avgdl_en)
230
+ self.assertNotAlmostEqual(idx_en.avgdl, idx_mixed.avgdl)
231
+
232
+ def test_french_only_term_scores_nothing_in_english_scope(self):
233
+ idx = self._index([self._chunk("detention review"),
234
+ self._chunk("annulé", lang="fr")])
235
+ self.assertEqual(idx._bm25_scores("annulé", en_only=True), {})
236
+ self.assertTrue(idx._bm25_scores("annulé"))
237
+
238
+
239
+ class MemorandumSourceKeyTests(unittest.TestCase):
240
+ """The diversity cap must key each memorandum on its parent document."""
241
+
242
+ @staticmethod
243
+ def _keys(chunks):
244
+ from canlex.index import LegislationIndex
245
+ idx = LegislationIndex.__new__(LegislationIndex)
246
+ idx.chunks = [dict(c, doc_type="memorandum") for c in chunks]
247
+ return [idx._source_key(i) for i in range(len(idx.chunks))]
248
+
249
+ def test_enf_sections_share_their_chapter(self):
250
+ keys = self._keys([{"id": "enf-10-21", "act_code": "ENF-10",
251
+ "section": "ENF 10 s. 21"},
252
+ {"id": "enf-10-6", "act_code": "ENF-10",
253
+ "section": "ENF 10 s. 6"},
254
+ {"id": "enf-19-11", "act_code": "ENF-19",
255
+ "section": "ENF 19 s. 11"}])
256
+ self.assertEqual(keys[0], keys[1])
257
+ self.assertNotEqual(keys[0], keys[2])
258
+
259
+ def test_pdi_pages_share_their_tree(self):
260
+ keys = self._keys([{"id": "pdi-refugee-31-2", "act_code": "PDI-REFUGEE",
261
+ "section": "PRRA: Intake"},
262
+ {"id": "pdi-refugee-34-0", "act_code": "PDI-REFUGEE",
263
+ "section": "PRRA: Applicant"}])
264
+ self.assertEqual(keys[0], keys[1])
265
+
266
+ def test_flat_families_key_on_the_item(self):
267
+ # One act_code covers every D-memo and every AMPS contravention, so
268
+ # there the cap must bind per memo / per contravention instead.
269
+ keys = self._keys([{"id": "dmemo-D19-9-2-1", "act_code": "D-Memo",
270
+ "section": "D19-9-2"},
271
+ {"id": "dmemo-D19-9-2-2", "act_code": "D-Memo",
272
+ "section": "D19-9-2"},
273
+ {"id": "dmemo-D2-3-1-1", "act_code": "D-Memo",
274
+ "section": "D2-3-1"},
275
+ {"id": "amps-C001", "act_code": "AMPS",
276
+ "section": "C001"},
277
+ {"id": "amps-C004", "act_code": "AMPS",
278
+ "section": "C004"}])
279
+ self.assertEqual(keys[0], keys[1])
280
+ self.assertNotEqual(keys[1], keys[2])
281
+ self.assertNotEqual(keys[3], keys[4])
282
+
283
+
284
  class RefreshCuratedTests(unittest.TestCase):
285
  def test_check_curated_runs_offline(self):
286
  from canlex.refresh import check_curated