Beemer0 Beemer claude opus 5 (1m context) Beemer claude fable 5 commited on
Commit
ed26ce9
·
0 Parent(s):

Super-squash branch 'main' using huggingface_hub

Browse files

Co-authored-by: Beemer <Beemer@users.noreply.huggingface.co>
Co-authored-by: claude opus 5 (1m context) <claude opus 5 (1m context)@users.noreply.huggingface.co>
Co-authored-by: Beemer <Beemer@users.noreply.huggingface.co>
Co-authored-by: claude fable 5 <claude fable 5@users.noreply.huggingface.co>

This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the build context small and keep secrets / regenerated files out.
2
+ .git/
3
+ .venv/
4
+ venv/
5
+ __pycache__/
6
+ **/__pycache__/
7
+ *.pyc
8
+
9
+ # Raw downloads are not needed -- only data/processed/ is.
10
+ data/raw/
11
+
12
+ # Local-only: the CanLII key (injected as a runtime secret) and disk caches.
13
+ canlii_key.txt
14
+ data/citator_dbmap.json
15
+ data/citator_cache.json
16
+
17
+ # Local MCP client config; not used by the remote server.
18
+ .mcp.json
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ data/processed/caselaw.json filter=lfs diff=lfs merge=lfs -text
2
+ data/processed/embeddings.npz filter=lfs diff=lfs merge=lfs -text
3
+ data/processed/I-3.3.json filter=lfs diff=lfs merge=lfs -text
4
+ data/processed/I-3.3-fr.json filter=lfs diff=lfs merge=lfs -text
.github/workflows/ci.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CI for the GitHub mirror (create the mirror per RUNBOOK.md; this workflow
2
+ # activates the moment the repo exists). Runs the offline test suite and the
3
+ # curated-dataset schema validation on every push -- the cheap failures that
4
+ # should never reach the production Space. The full retrieval eval needs the
5
+ # ONNX models and LFS embeddings, so it stays a manual/scheduled job.
6
+ name: tests
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ offline-tests:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ lfs: false # tests are offline; embeddings not needed
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ # The pinned set the Space builds with: the suite imports numpy,
24
+ # onnxruntime, huggingface_hub and tokenizers at collection time
25
+ # (test_embed, test_calllog), so a shorter list errors 25 tests before
26
+ # they run. No model is downloaded by any test (2026-09-21 audit).
27
+ - name: Install the pinned dependencies (no model downloads)
28
+ run: pip install -r requirements.txt
29
+ - name: Offline test suite
30
+ run: python -m unittest discover -s tests -v
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ data/raw/
6
+ # point-in-time section pages cached by tools/pit_history.py (build-time
7
+ # research only; already covered by data/raw/, listed so it stays ignored)
8
+ data/raw/pit/
9
+ canlii_key.txt
10
+ data/citator_*.json
11
+ US-Dispositions-Reference.pdf
12
+ *.bundle
13
+ data/logs/
14
+ # derived caches rebuilt on demand (canlex/bm25.py: the BM25 postings)
15
+ data/cache/
.mcp.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mcpServers": {
3
+ "canlex": {
4
+ "command": "C:\\projects\\CanLex\\.venv\\Scripts\\python.exe",
5
+ "args": ["C:\\projects\\CanLex\\canlex\\server.py"],
6
+ "env": {
7
+ "CANLEX_LOG_DIR": "C:\\projects\\CanLex\\data\\logs",
8
+ "HF_HUB_OFFLINE": "1"
9
+ }
10
+ }
11
+ }
12
+ }
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CanLex MCP server -- remote (streamable-HTTP) image.
2
+ #
3
+ # Builds anywhere: Hugging Face Spaces, Google Cloud Run, Fly.io, plain Docker.
4
+ # Retrieval is fully local and key-free; the optional CanLII case citator reads
5
+ # its key from the CANLII_API_KEY environment variable (supplied as a host
6
+ # secret -- the key is never copied into the image).
7
+ FROM python:3.12-slim
8
+
9
+ # libgomp1 is the OpenMP runtime that onnxruntime (the reranker) links against.
10
+ RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends libgomp1 \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Run as a non-root user (UID 1000) -- required by Hugging Face Spaces.
15
+ RUN useradd --create-home --home-dir /app --uid 1000 app
16
+ WORKDIR /app
17
+
18
+ # Python dependencies first, so this layer caches across code changes. The
19
+ # image installs requirements.lock: the whole set, transitive dependencies
20
+ # included, each pinned with its sha256 hashes -- `uv pip compile` of
21
+ # requirements.txt for Linux / Python 3.12 (RUNBOOK.md, "Audit follow-up
22
+ # implementation (2026-09-21): group C"). Hash-checking mode refuses any
23
+ # distribution but the locked one, so every build installs the same set (until
24
+ # the 2026-09-21 audit only the 8 direct pins were fixed and starlette, anyio
25
+ # and the other transitives were re-resolved at each build).
26
+ COPY requirements.lock .
27
+ RUN pip install --no-cache-dir --require-hashes -r requirements.lock
28
+
29
+ # Application code.
30
+ COPY --chown=app:app canlex/ ./canlex/
31
+
32
+ # Pre-fetch the embedder and cross-encoder models so the cache is baked into the
33
+ # image and the first request needs no network. The corpus is NOT re-embedded
34
+ # here -- the committed embeddings.npz is used as-is; only the bge query model
35
+ # (for embedding live queries) and the reranker are downloaded.
36
+ #
37
+ # Layer order (the code audit of 2026-09-21, optimization 9): this step reads
38
+ # canlex/ and the network only -- Embedder() and Reranker() read nothing under
39
+ # data/ at init -- so it runs BEFORE the corpus and curated COPYs below. Docker
40
+ # rebuilds every layer after the first one whose input changed; after those
41
+ # COPYs, every corpus refresh, curated change and composed release (which
42
+ # rewrites embeddings.npz) re-ran this step and re-downloaded the 331 MB of model
43
+ # files. Here only a change under canlex/ or to requirements.lock does. (Whether
44
+ # the Hugging Face builder keeps layers between builds is outside our control.)
45
+ # The user and environment it needs come first; the image's final user,
46
+ # environment and files are what they were before the move.
47
+ USER app
48
+ # CANLEX_LOG_DIR turns on the local call log (canlex/calllog.py: one JSON line
49
+ # per tool call, arguments included; loganalyze.py mines it). On the Space this
50
+ # path is ephemeral -- lost on every restart or rebuild. The durable log is the
51
+ # private Hugging Face dataset the server pushes to when the Space secrets
52
+ # CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN are set (RUNBOOK.md, "Call log"); the
53
+ # HF_HUB_OFFLINE=1 below still refuses every other Hub request, model files
54
+ # included -- calllog lets only that dataset's requests through.
55
+ ENV HOME=/app \
56
+ HF_HOME=/app/.hf_cache \
57
+ CANLEX_HTTP=1 \
58
+ CANLEX_LOG_DIR=/tmp/canlex-logs \
59
+ PORT=7860 \
60
+ PYTHONUNBUFFERED=1
61
+
62
+ RUN python -c "from canlex.embed import Embedder; Embedder()" \
63
+ && python -c "from canlex.rerank import Reranker; Reranker()"
64
+
65
+ # The processed corpus (section-chunk JSON) and the prebuilt semantic
66
+ # embeddings (embeddings.npz, committed via git-LFS). Shipping the embeddings
67
+ # as an artifact keeps the build fast: embedding ~20k chunks on the build host
68
+ # took ~33 min and was tripping Hugging Face's job timeout as the corpus grew.
69
+ # Regenerate with 'python -m canlex.embed' after any corpus change and commit
70
+ # the result.
71
+ COPY --chown=app:app data/processed/*.json ./data/processed/
72
+ COPY --chown=app:app data/processed/embeddings.npz ./data/processed/
73
+ # Curated commentary datasets (the canlex_us_disposition tool reads the
74
+ # structured source file directly, not just its processed chunks).
75
+ COPY --chown=app:app data/curated/ ./data/curated/
76
+
77
+ # From here on, model files are served from the baked cache, never fetched.
78
+ ENV HF_HUB_OFFLINE=1
79
+
80
+ EXPOSE 7860
81
+ CMD ["python", "-m", "canlex.server"]
README.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CanLex MCP
3
+ sdk: docker
4
+ app_port: 7860
5
+ pinned: false
6
+ ---
7
+
8
+ # CanLex MCP
9
+
10
+ A remote [Model Context Protocol](https://modelcontextprotocol.io) server for
11
+ Canadian legal research, built for front-line border-services work. The
12
+ legislation corpus is **bilingual**: every Act and regulation also carries its
13
+ parallel French text, and a French-language query searches both languages
14
+ automatically. Fourteen read-only tools over streamable HTTP (or local stdio):
15
+
16
+ - **canlex_search_legislation** — hybrid (BM25 + semantic) search with a
17
+ cross-encoder reranker over the whole corpus: federal border, customs,
18
+ criminal, firearms, drug, food/health, labour and privacy legislation with
19
+ their Schedules (80 Justice Laws instruments, from IRPA and the Customs Act to the
20
+ sanctions Acts, the Criminal Records Act, the Income Tax Act and the
21
+ public-service labour statutes); the ENF operational manuals and IRCC program delivery
22
+ instructions; CBSA D-Memoranda and the AMPS Master Penalty Document; the
23
+ Charter; the Safe Third Country Agreement; Treasury Board collective
24
+ agreements; NJC directives; the CBSA Code of Conduct and the Treasury
25
+ Board people-management instruments; CBSA detention policy and the IRB
26
+ Chairperson's Guidelines and the IRB Legal Services papers on criminal
27
+ equivalency and criminal refusals; IRCC's rehabilitation application guide;
28
+ ~245 leading decisions; delegation instruments; benefit-plan booklets; and
29
+ CanLex's curated commentary.
30
+ - **canlex_get_section** — fetch one provision verbatim by Act and section
31
+ (accepts 's. 34', '34(1)(c)', 'Schedule I').
32
+ - **canlex_us_disposition** — step 1 of the IRPA s. 36 screening chain: is a
33
+ foreign disposition a "conviction"? Curated for US records, with a
34
+ 51-jurisdiction state-by-state dataset; a non-US record (the optional
35
+ `country`, or a state value that is not a US state or territory) gets the
36
+ framework and the entry-level verdict, labelled "non-US record: curated
37
+ coverage is thin". The tool names keep their `us_` prefix so saved client
38
+ configs keep working. The answer ends with a READ NEXT block: the official
39
+ guidance and statute on the point, from the corpus (chunk id, status and a
40
+ verbatim quote; unofficial ATIP copies of withdrawn chapters flagged and
41
+ listed after the official sources).
42
+ - **canlex_us_equivalency** — step 2: the Canadian equivalent of a foreign
43
+ offence (US-curated pairings), its verified maximum penalty, and the
44
+ inadmissibility branch, with the pairing's READ NEXT block.
45
+ - **canlex_rehabilitation** — step 3: deemed rehabilitation / eligibility to
46
+ apply, under IRPA s. 36(3)(c) and IRPR ss. 17–18.1 — from dates, with the
47
+ earliest eligibility dates computed.
48
+ - **canlex_screen_record** — the three steps in one call for a foreign record
49
+ (US state rows where they exist; optional `country`), from the record's
50
+ own words and dates: a screening memo with the draft allegation paragraph
51
+ (both paragraphs when the disposition or a tie is unsettled), the next
52
+ steps at the port of entry (s. 44 report, the removal path by paragraph,
53
+ TRP, rehabilitation application with the IRPR fees, family members by
54
+ application type), what the chain assumed, an "s. 37 also to consider"
55
+ block where the offence or the optional `organization` fact raises
56
+ organized criminality, a "Read next" line per step, and one structured
57
+ result line. Where the record's words reach curated content still held for
58
+ review that the deployment lacks, the memo says so ("HELD FOR REVIEW -- not
59
+ in this deployment") and lists it among what the chain assumed. A conviction in
60
+ Canada (country 'Canada' or a province) is screened on s. 36(1)(a)/(2)(a)
61
+ instead: discharges, conditional sentences, s. 18.1, the record suspension
62
+ as the cure, the removal path and a permanent resident's appeal line. An
63
+ optional `date_of_birth` (or `age_at_offence`) prints the youth block,
64
+ statute and IRCC policy kept apart. At the top, **THE ANSWER CHANGES WITH
65
+ THESE FACTS** lists each missing fact that changes the branch, the allegation
66
+ paragraph or the rehabilitation status (the chain re-run with every plausible
67
+ value; CANLEX_RESULT `decisive_missing`). An optional `conviction_date`
68
+ bounds the maximum at commission; `sentence_imposed` with `sentence_date`
69
+ adds a completion date **IF SERVED EXACTLY AS IMPOSED** (Guide 5312's rules;
70
+ never a verdict, and a fine blocks it); `detail: "card"` returns a result
71
+ card of about ten lines instead of the memo. The full memo ends with a
72
+ **DRAFT s. 44(1) narrative skeleton** in the ENF 5 s. 13.1 form, in English,
73
+ French or both (`language`): the proposed IRPA paragraph with its words, the
74
+ offence as recorded, the Canadian provision (the pairing's curated report
75
+ provision, chosen by the matched limb and the maximum at commission) and the
76
+ maximum in the statute's own words. Every bracket is to be verified; what
77
+ CanLex does not hold (name, place, the conviction date unless given, the
78
+ status evidence) stays `[CONFIRM: ...]`, and a result that is not settled
79
+ gets one allegation per reading, never a settled one.
80
+ - **canlex_screen_history** — a whole record at once: every entry through the
81
+ chain (foreign or Canadian), then the record-level count for IRPR s. 18(2)
82
+ (equal or missing offence dates on a two-offence count give both readings),
83
+ deemed rehabilitation tested limb by limb (IRPR s. 18(2)(a)-(c) and s. 18.1:
84
+ a table with the entry that decides each limb and the date it starts or stops
85
+ holding; both readings where an unsettled reading decides it; an optional
86
+ `conviction_date` per entry, which also bounds the maximum at commission)
87
+ and the s. 17 clock from the most recent conviction, Canadian ones
88
+ included. The decisive facts, `detail: "card"` and the narrative skeleton
89
+ (one allegation per entry that grounds one; the two-offence limb over the
90
+ summary-only convictions) as for canlex_screen_record; an entry's `relief`
91
+ (a pardon, expungement, sealing or record suspension, or 'none') is read as
92
+ the disposition's later history.
93
+ - **canlex_screen** — the same record pipeline, with the entries as typed
94
+ objects (one per conviction, disposition or charge, in the record's own
95
+ words; an unknown key is refused, so a misspelt key never silently changes
96
+ an answer). There is no free-text rap-sheet parsing. A record-level
97
+ `relief` applies to every entry without its own; 'none' confirms there is
98
+ none. Returns the result card by default (`detail: "full"` for everything,
99
+ the narrative skeleton included). Every screening tool's CANLEX_RESULT carries
100
+ `confidence`: what each step rests on (the entry's and pairing's authority
101
+ status, whether the state row was verified, how many cited decisions are in
102
+ the corpus, where the maximum came from, the s. 18(2) limbs left unknown).
103
+ - **canlex_transborder** — an offence committed on entering Canada (an
104
+ undeclared firearm, cannabis or drugs): transborder criminality under IRPA
105
+ s. 36(2.1) and IRPR s. 19 — whether the offence is prescribed, whether the
106
+ Minister's delegate or the Immigration Division makes the deportation
107
+ order (IRPR ss. 228(1)(a.01), 229(1)(d.1)), no rehabilitation, the
108
+ contraventions check, TRP and a draft allegation. Foreign nationals only.
109
+ - **canlex_list_acts** — the corpus inventory and its currency.
110
+ - **canlex_whats_new** — provisions amended or newly in force since a date.
111
+ - **canlex_citing** — reverse cross-references: what cites a given section.
112
+ - **canlex_toc** — an Act's table of contents (Parts and marginal notes).
113
+ - **canlex_case** — live CanLII case citator (citation graph by neutral
114
+ citation or URL).
115
+
116
+ The server also publishes an MCP prompt (`us_record_screening`, the guided
117
+ three-step screening flow) and resources (the corpus inventory and the
118
+ conviction/equivalency methodology texts) that any MCP client can use.
119
+
120
+ The labour side now carries the full union stack alongside the FB collective
121
+ agreement: the CIU National By-Laws (EN/FR), CIU Regulations, Branch Rules
122
+ and local modifications, the Branch Elections Kit, the PSAC Constitution,
123
+ the union's member guides, and every published site VSSA — all bannered as
124
+ bargaining-agent documents, distinct from employer policy.
125
+
126
+ Retrieval runs fully locally inside the container and needs no API key. The
127
+ optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
128
+ variable. A weekly staleness check (`canlex.refresh --check`, run every
129
+ Monday by a Claude Code scheduled task on the maintainer's machine --
130
+ RUNBOOK.md, "The staleness watch") watches upstream sources for drift; see
131
+ RUNBOOK.md for the maintainer's refresh choreography.
132
+
133
+ Add this Space's `/mcp` URL (`https://beemer0-canlex.hf.space/mcp`) as a
134
+ custom connector in any Claude client, or point any other MCP-capable agent
135
+ at it. CanLex is an MCP server only: it has no chat page of its own, and it
136
+ calls no hosted model API.
137
+
138
+ **Call log (2026-09-16):** tool calls to this server, including their
139
+ arguments, are logged by the operator, privately, to improve CanLex
140
+ (RUNBOOK.md, "Call log"). The server says the same to every MCP client when it
141
+ connects (its MCP instructions) and at its root URL,
142
+ `https://beemer0-canlex.hf.space/`.
143
+
144
+ **2026-09-16:** the companion Gradio chat page (the separate `Canlex-web`
145
+ Space) and its Google Gemini API integration were retired. The `webapp/`
146
+ directory, its tests and the predeploy parity step were removed; the git
147
+ history keeps them.
RUNBOOK.md ADDED
The diff for this file is too large to render. See raw diff
 
canlex/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """CanLex - Canadian legal retrieval for Claude."""
canlex/_common.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for the ingestion modules.
2
+
3
+ The ingesters grew organically and each carried private copies of the same
4
+ plumbing; the copies were AST-diffed and consolidated here (the audit found the
5
+ download helper in 8 near-identical copies, the line splitter in 5, and the
6
+ whitespace collapser in 13). Deliberate per-source differences -- a politeness
7
+ UA, a longer timeout, a different split target -- stay at the call sites as
8
+ arguments. caselaw.py keeps its own fetch (retry/throttle for Lexum's CAPTCHA)
9
+ and its own splitter (sentence-boundary windowing, a different algorithm).
10
+ """
11
+ import base64
12
+ import contextlib
13
+ import http.client
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from collections import Counter
22
+ from pathlib import Path
23
+
24
+ # The browser User-Agent used for government sites whose CDN denylists obvious
25
+ # non-browser clients (the pages are public; politeness comes from caching and
26
+ # the pauses below). The polite UA is for hosts that accept ordinary clients.
27
+ BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
29
+ POLITE_UA = "CanLex/0.1"
30
+
31
+ # Month-name lookup for parsing prose dates ("June 21, 2026").
32
+ MONTHS = {m: i for i, m in enumerate(
33
+ ["january", "february", "march", "april", "may", "june", "july", "august",
34
+ "september", "october", "november", "december"], start=1)}
35
+
36
+
37
+ # Invisible format characters (Unicode category Cf) that sources leave INSIDE
38
+ # words: the soft hyphen U+00AD ('perform\xadance', C-46 s. 176), the zero-width
39
+ # space, non-joiner and joiner U+200B-U+200D (after the IRB guidelines' heading
40
+ # numbers; inside the French CITES names), the word joiner U+2060 and a stray
41
+ # byte-order mark U+FEFF. None is whitespace, so \s never matched them: they
42
+ # split a word into two tokens for BM25 and broke any curated verify phrase
43
+ # spanning one. Measured on the 2026-09-21 corpus: 237 chunks carried one.
44
+ FORMAT_CHARS = re.compile("[\u00ad\u200b\u200c\u200d\u2060\ufeff]")
45
+
46
+
47
+ def strip_format_chars(text):
48
+ """`text` without the invisible in-word format characters (FORMAT_CHARS)."""
49
+ return FORMAT_CHARS.sub("", text or "")
50
+
51
+
52
+ def norm_ws(text):
53
+ """Collapse all whitespace to single spaces and trim, after dropping the
54
+ invisible in-word format characters (FORMAT_CHARS, 2026-09-22 audit
55
+ follow-up: 'assist\\xadance' now reads 'assistance').
56
+
57
+ \\s in a Python 3 str pattern already matches NBSP, en/em/thin spaces and
58
+ friends, so no pre-replacement is needed (several old copies carried a
59
+ redundant .replace("\\xa0", " ")).
60
+ """
61
+ return re.sub(r"\s+", " ", strip_format_chars(text)).strip()
62
+
63
+
64
+ # --- line-break hyphens in PDF text ------------------------------------------
65
+ # A PDF's text layer keeps the typesetter's line breaks, so a word hyphenated
66
+ # at a line end reaches the chunk as 'cross-\nexamine' and the lexical leg
67
+ # never sees 'cross-examine' (374 PDF chunks, 2026-09-21 audit). Only 63 of
68
+ # the 398 breaks measured have an un-hyphenated form attested elsewhere in the
69
+ # corpus, so the hyphen is never deleted blindly: by default only the line
70
+ # break goes ('cross-examine'), and the hyphen is dropped only when the
71
+ # document itself uses the joined word ('immi-\ngration' where 'immigration'
72
+ # occurs). Inside a URL the hyphen always stays ('laws-\nlois.justice.gc.ca').
73
+ _WORD = re.compile(r"[^\W\d_]+")
74
+ # a word and its hyphen (a printed one or a soft hyphen) closing a line
75
+ _BREAK_TAIL = re.compile(r"([^\W\d_]+)[-\u00ad][ \t]*$")
76
+ # the continuation that opens the next line: letters, then anything up to a space
77
+ _BREAK_HEAD = re.compile(r"[ \t]*(([^\W\d_]+)\S*)")
78
+ _URL_TOKEN = re.compile(r"://|^www\.|\.(?:ca|com|org|gov|gc|net|int)\b|/", re.I)
79
+ # a soft hyphen that ends a line: the printed hyphen of a broken word
80
+ # (WinAnsiEncoding maps the hyphen glyph to 0xAD, which pypdf reads as U+00AD)
81
+ _SOFT_BREAK = re.compile("\u00ad(?=[ \t]*(?:\n|$))")
82
+
83
+
84
+ def document_words(texts):
85
+ """Lower-cased word counts over `texts` -- a document's own vocabulary,
86
+ for dehyphenate."""
87
+ words = Counter()
88
+ for text in texts:
89
+ words.update(w.lower() for w in _WORD.findall(text or ""))
90
+ return words
91
+
92
+
93
+ def mark_soft_breaks(text):
94
+ """A soft hyphen that ends a line of PDF text becomes '-', so the break
95
+ survives norm_ws (which drops every U+00AD) for dehyphenate to read."""
96
+ return _SOFT_BREAK.sub("-", text or "")
97
+
98
+
99
+ def _joined(prev, tail, fragment, letters, words):
100
+ """`prev` (which ends in `tail` and a hyphen) with its broken word closed
101
+ up with `fragment` (whose leading letters are `letters`)."""
102
+ stem = prev.rstrip(" \t")[:-1] # without the hyphen
103
+ token = stem.split()[-1] if stem.split() else ""
104
+ keep = (bool(_URL_TOKEN.search(token)) or not words
105
+ or not letters[0].islower()
106
+ or not words.get((tail + letters).lower()))
107
+ return stem + ("-" if keep else "") + fragment
108
+
109
+
110
+ def dehyphenate(text, words=None):
111
+ """`text` with each line-end hyphen break closed up (see above).
112
+
113
+ The continuation -- the next line's first run of non-space characters --
114
+ moves up onto the hyphenated line and the rest of that line stays where
115
+ it was, so line structure (and anything keyed on it) is untouched; a line
116
+ left empty is dropped. `words` is the document's vocabulary
117
+ (document_words): the hyphen goes only when the joined word is in it and
118
+ the continuation opens lower-case, never inside a URL. With no `words`
119
+ every hyphen is kept. A printed hyphen or a soft hyphen (U+00AD) counts;
120
+ the kept one is always '-'. Pure, for tests."""
121
+ out = []
122
+ for line in (text or "").split("\n"):
123
+ prev = out[-1] if out else ""
124
+ tail = _BREAK_TAIL.search(prev)
125
+ head = _BREAK_HEAD.match(line) if tail else None
126
+ if head:
127
+ out[-1] = _joined(prev, tail.group(1), head.group(1), head.group(2), words)
128
+ line = line[head.end():].lstrip(" \t")
129
+ if not line:
130
+ continue
131
+ out.append(line)
132
+ return "\n".join(out)
133
+
134
+
135
+ def _ps_literal(text):
136
+ """`text` as a single-quoted PowerShell string literal: the only escape
137
+ inside single quotes is a doubled quote, so a URL or a checkout path with
138
+ an apostrophe (C:\\Users\\O'Brien) cannot end the literal early."""
139
+ return "'" + str(text).replace("'", "''") + "'"
140
+
141
+
142
+ def powershell_download(url, dest, *, ua=BROWSER_UA, timeout=180):
143
+ """Fetch `url` into `dest` through PowerShell's (.NET) HTTP stack, which
144
+ canada.ca accepts while blocking Python's client at the TLS layer.
145
+
146
+ The script goes to PowerShell as -EncodedCommand (base64 of UTF-16LE),
147
+ the way refresh._powershell_probe already does, so no quoting survives
148
+ the Windows command line to be re-parsed; url, dest and ua are quoted
149
+ as PowerShell literals. Until 2026-09-21 the request was spliced into a
150
+ single-quoted -Command string, and one apostrophe in either was a
151
+ PowerShell parse error, not an HTTP error. Raises CalledProcessError or
152
+ TimeoutExpired; the caller decides what happens to `dest`.
153
+
154
+ When the server answered with an error status, the script writes
155
+ 'CANLEX_HTTP_STATUS <n>' (PS_STATUS_MARKER) on a stderr line of its own
156
+ before rethrowing, so is_transient reads the status PowerShell got, not
157
+ the error page's text (verification 2026-09-22, D-2)."""
158
+ script = (f"$ProgressPreference = 'SilentlyContinue'\n"
159
+ "try {\n"
160
+ f" Invoke-WebRequest -Uri {_ps_literal(url)} -OutFile {_ps_literal(dest)} "
161
+ f"-UseBasicParsing -UserAgent {_ps_literal(ua)}\n"
162
+ "} catch {\n"
163
+ " if ($_.Exception.Response) {\n"
164
+ f" [Console]::Error.WriteLine('{PS_STATUS_MARKER} ' + "
165
+ "[int]$_.Exception.Response.StatusCode.value__)\n"
166
+ " }\n"
167
+ " throw\n"
168
+ "}\n")
169
+ encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
170
+ subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
171
+ "-EncodedCommand", encoded],
172
+ check=True, capture_output=True, timeout=timeout)
173
+
174
+
175
+ def fetch_cached(url, dest, *, ua=BROWSER_UA, timeout=60, pause=0.5,
176
+ force=False, powershell=False):
177
+ """Download a URL to `dest` once and reuse the cached bytes thereafter.
178
+
179
+ powershell=True routes the request through PowerShell's (.NET) HTTP stack,
180
+ which canada.ca accepts while blocking Python's client at the TLS layer.
181
+ `pause` is a politeness sleep after an actual download (0 to skip);
182
+ `timeout` applies to the urllib path (the PowerShell path keeps the fixed
183
+ 180s subprocess budget every prior copy used).
184
+
185
+ The download lands in `<dest>.part` and is renamed into place only once
186
+ it succeeded, on both paths: a fetch that times out or errors leaves
187
+ nothing at `dest`, so the next call fetches again instead of serving a
188
+ truncated file as the cached copy (2026-09-21 audit: a stalled canada.ca
189
+ response left a 0-byte page in data/raw that every later plain run
190
+ reused, and a PDI page silently dropped from the corpus).
191
+
192
+ A transient failure (a 5xx, a dropped connection, a timeout) is retried
193
+ once (with_retry); a 404 is not, so enf.is_missing_upstream still reads
194
+ a retired chapter on the first answer.
195
+ """
196
+ if dest.exists() and not force:
197
+ return dest.read_bytes()
198
+ dest.parent.mkdir(parents=True, exist_ok=True)
199
+ part = dest.with_name(dest.name + ".part")
200
+ try:
201
+ with_retry(lambda: _download(url, part, ua=ua, timeout=timeout,
202
+ powershell=powershell), what=url)
203
+ os.replace(part, dest)
204
+ finally:
205
+ with contextlib.suppress(OSError):
206
+ part.unlink()
207
+ if pause:
208
+ time.sleep(pause)
209
+ return dest.read_bytes()
210
+
211
+
212
+ def _download(url, part, *, ua, timeout, powershell):
213
+ """One attempt at fetching `url` into `part` (see fetch_cached)."""
214
+ if powershell:
215
+ powershell_download(url, part, ua=ua)
216
+ if not part.exists():
217
+ raise OSError(f"{url}: PowerShell returned without writing {part.name}")
218
+ return
219
+ req = urllib.request.Request(url, headers={"User-Agent": ua})
220
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
221
+ part.write_bytes(resp.read())
222
+
223
+
224
+ # --- transient fetch failures ------------------------------------------------
225
+ # One 5xx or connection reset used to fail an ingest item outright (and, with
226
+ # the preserve-on-failure rule, pin its stale copy until the next run). Two
227
+ # attempts in all; the pause before the second grows with the attempt number.
228
+ # A 4xx is an answer, not a hiccup -- enf reads a 404 as IRCC retiring a
229
+ # chapter (enf.is_missing_upstream) -- so it is never retried.
230
+ FETCH_ATTEMPTS = 2
231
+ FETCH_RETRY_DELAY = 5.0 # seconds before the second attempt
232
+ # The PowerShell path's HTTP status, as powershell_download's script writes it
233
+ # on a stderr line of its own. Until the 2026-09-22 verification (D-2) the
234
+ # status was read as '(NNN)' anywhere in stderr, but Windows PowerShell 5.1
235
+ # reports a failed Invoke-WebRequest as CLIXML carrying the error PAGE's text:
236
+ # a 503 page with a body was never retried, and a 404 page quoting a phone
237
+ # number '(514) 555-0100' was. CLIXML escapes the page's own line breaks
238
+ # (_x000D__x000A_), so a page cannot put the marker at the start of a line.
239
+ PS_STATUS_MARKER = "CANLEX_HTTP_STATUS"
240
+ _PS_STATUS = re.compile(rf"^{PS_STATUS_MARKER} (\d{{3}})\r?$", re.M)
241
+
242
+
243
+ def powershell_status(stderr):
244
+ """The HTTP status the PowerShell fetch's marker line reports
245
+ (PS_STATUS_MARKER), or None when the stderr carries no marker (no HTTP
246
+ answer, or a script that predates the marker). Only the marker line is
247
+ read, never the error page's own text."""
248
+ if isinstance(stderr, bytes):
249
+ stderr = stderr.decode("utf-8", "replace")
250
+ m = _PS_STATUS.search(stderr or "")
251
+ return int(m.group(1)) if m else None
252
+
253
+
254
+ # .NET's own words for a failure with no HTTP answer (so no page, and no marker)
255
+ _PS_TRANSIENT = re.compile(r"timed out|timeout|unable to connect|connection was closed"
256
+ r"|forcibly closed|temporar", re.I)
257
+
258
+
259
+ def is_transient(exc):
260
+ """True when a fetch failure is worth one more try: an HTTP 5xx, a
261
+ URLError without a status (DNS, refused, reset), a timeout, a dropped
262
+ connection, a truncated body, or a PowerShell failure whose status
263
+ marker (PS_STATUS_MARKER) is a 5xx or which got no HTTP answer and
264
+ reports a connection-level error. Never a 4xx, and never a status read
265
+ from the error page's text. Pure, for tests."""
266
+ if isinstance(exc, urllib.error.HTTPError):
267
+ return exc.code >= 500
268
+ if isinstance(exc, (urllib.error.URLError, subprocess.TimeoutExpired,
269
+ TimeoutError, ConnectionError, http.client.IncompleteRead)):
270
+ return True
271
+ if isinstance(exc, subprocess.CalledProcessError):
272
+ stderr = exc.stderr or ""
273
+ if isinstance(stderr, bytes):
274
+ stderr = stderr.decode("utf-8", "replace")
275
+ status = _PS_STATUS.search(stderr)
276
+ if status:
277
+ return status.group(1).startswith("5")
278
+ return bool(_PS_TRANSIENT.search(stderr))
279
+ return False
280
+
281
+
282
+ def with_retry(fetch, *, what="", attempts=None, delay=None):
283
+ """fetch() -- again after a pause when it fails transiently (is_transient),
284
+ up to FETCH_ATTEMPTS attempts in all. The last failure, or any
285
+ non-transient one, propagates unchanged."""
286
+ attempts = FETCH_ATTEMPTS if attempts is None else attempts
287
+ delay = FETCH_RETRY_DELAY if delay is None else delay
288
+ for attempt in range(1, attempts + 1):
289
+ try:
290
+ return fetch()
291
+ except Exception as exc:
292
+ if attempt >= attempts or not is_transient(exc):
293
+ raise
294
+ print(f" retrying {what or 'fetch'} in {delay * attempt:g}s "
295
+ f"after {type(exc).__name__}: {str(exc)[:80]}")
296
+ time.sleep(delay * attempt)
297
+ return None
298
+
299
+
300
+ # Windows refuses to rename onto a file another process holds open without
301
+ # FILE_SHARE_DELETE -- Python's own open(), most scanners, indexers and
302
+ # editors -- with [WinError 5] Access is denied, where the plain write_text
303
+ # the atomic helpers replaced succeeds (verification 2026-09-22, D-3). Such a
304
+ # hold is usually brief, so the rename is tried REPLACE_ATTEMPTS times in all,
305
+ # REPLACE_RETRY_DELAY apart, before the PermissionError propagates.
306
+ REPLACE_ATTEMPTS = 5
307
+ REPLACE_RETRY_DELAY = 0.2 # seconds between rename attempts
308
+
309
+
310
+ def _replace(tmp, path):
311
+ """os.replace(tmp, path), retried on PermissionError (REPLACE_ATTEMPTS
312
+ in all); the last PermissionError, or any other OSError, propagates."""
313
+ for attempt in range(1, REPLACE_ATTEMPTS + 1):
314
+ try:
315
+ os.replace(tmp, path)
316
+ return
317
+ except PermissionError:
318
+ if attempt >= REPLACE_ATTEMPTS:
319
+ raise
320
+ time.sleep(REPLACE_RETRY_DELAY)
321
+
322
+
323
+ def atomic_write_text(path, text, *, encoding="utf-8"):
324
+ """Write `text` to `path` through a sibling temporary file renamed into
325
+ place, so a run killed mid-write leaves the old file (or none), never a
326
+ truncated one. For the small marker and cache files the ingesters and
327
+ the staleness watch keep (the guidance last-seen marker, the CIU
328
+ decisions date, the raw case-law pages). A rename refused because
329
+ another process briefly holds `path` open is retried (_replace)."""
330
+ path = Path(path)
331
+ path.parent.mkdir(parents=True, exist_ok=True)
332
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
333
+ try:
334
+ tmp.write_text(text, encoding=encoding)
335
+ _replace(tmp, path)
336
+ finally:
337
+ with contextlib.suppress(OSError):
338
+ tmp.unlink()
339
+ return path
340
+
341
+
342
+ def atomic_write_bytes(path, data):
343
+ """atomic_write_text for bytes (the Justice Laws XML cache)."""
344
+ path = Path(path)
345
+ path.parent.mkdir(parents=True, exist_ok=True)
346
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
347
+ try:
348
+ tmp.write_bytes(data)
349
+ _replace(tmp, path)
350
+ finally:
351
+ with contextlib.suppress(OSError):
352
+ tmp.unlink()
353
+ return path
354
+
355
+
356
+ def split_lines(text, target=1800, fold_stubs=False):
357
+ """Split text into ~target-sized pieces at line boundaries.
358
+
359
+ fold_stubs=True additionally folds a small (<200-char) leading or trailing
360
+ remnant into its neighbour so a section never yields a stub piece -- the
361
+ behaviour ingest.py and benefits.py always had. The other callers
362
+ deliberately keep fold_stubs=False: enabling it would change their chunk
363
+ boundaries, which is an eval-gated change, not a refactor.
364
+ """
365
+ if len(text) <= target:
366
+ return [text]
367
+ pieces, buf, size = [], [], 0
368
+ for line in text.split("\n"):
369
+ if size + len(line) > target and buf:
370
+ pieces.append("\n".join(buf))
371
+ buf, size = [], 0
372
+ buf.append(line)
373
+ size += len(line) + 1
374
+ if buf:
375
+ pieces.append("\n".join(buf))
376
+ if fold_stubs:
377
+ if len(pieces) > 1 and len(pieces[-1]) < 200:
378
+ pieces[-2] = pieces[-2] + "\n" + pieces[-1]
379
+ pieces.pop()
380
+ if len(pieces) > 1 and len(pieces[0]) < 200:
381
+ pieces[1] = pieces[0] + "\n" + pieces[1]
382
+ pieces.pop(0)
383
+ return pieces
384
+
385
+
386
+ # Every ingester rebuilds its whole processed file from a fresh scrape, so an
387
+ # upstream page that changes shape -- or a run that loses its network halfway --
388
+ # shows up as a collapse in chunk count, and the write turns that into data
389
+ # loss. On 2026-07-27 CBSA's rebuilt D-memo index scraped to zero memos and the
390
+ # write replaced 1,870 good chunks with an empty list; one line of stdout was
391
+ # the only warning. Refuse to write below this share of what is already stored.
392
+ CORPUS_SHRINK_GUARD = 0.8
393
+
394
+
395
+ def stored_chunks(path):
396
+ """The chunks already written to `path`, or [] if absent or unreadable."""
397
+ if not path.exists():
398
+ return []
399
+ try:
400
+ return json.loads(path.read_text(encoding="utf-8"))
401
+ except (ValueError, OSError):
402
+ return []
403
+
404
+
405
+ def safe_to_write(new_count, old_count, ratio=CORPUS_SHRINK_GUARD):
406
+ """False when writing `new_count` chunks would gut an existing corpus.
407
+
408
+ Pure, for tests. The write is the irreversible step, so it is the one to
409
+ gate -- a first-ever run (old_count 0) is always allowed.
410
+ """
411
+ return old_count == 0 or new_count >= old_count * ratio
412
+
413
+
414
+ def preserve_dropped(chunks, stored, *, key=lambda c: c["section"], only=None):
415
+ """Re-attach the last-good chunks of items this run failed to produce.
416
+
417
+ `key` maps a chunk to the item it belongs to; `only` restricts preservation
418
+ to a set of item identities (pass the ones that actually errored, so items
419
+ dropped ON PURPOSE -- cancelled memos, retired manuals -- are not
420
+ resurrected). Returns (chunks, preserved identities).
421
+ """
422
+ def identity(chunk):
423
+ # A chunk written by an older schema may not carry the identity field
424
+ # at all. It simply is not preservable -- but crashing here would take
425
+ # down the rebuild at the exact moment the guard exists to protect it.
426
+ try:
427
+ return key(chunk)
428
+ except (KeyError, IndexError, TypeError):
429
+ return None
430
+
431
+ fresh = {identity(c) for c in chunks}
432
+ by_item = {}
433
+ for chunk in stored:
434
+ item = identity(chunk)
435
+ if item is not None:
436
+ by_item.setdefault(item, []).append(chunk)
437
+ dropped = sorted(item for item in by_item
438
+ if item not in fresh and (only is None or item in only))
439
+ for item in dropped:
440
+ chunks.extend(by_item[item])
441
+ return chunks, dropped
442
+
443
+
444
+ def write_corpus(path, chunks, *, indent=2, allow_shrink=False,
445
+ ratio=CORPUS_SHRINK_GUARD):
446
+ """Write `chunks` to `path`, unless that would gut the stored corpus.
447
+
448
+ Returns True if written, False if refused -- callers exit non-zero on False
449
+ so a broken scrape fails loudly instead of quietly emptying a file.
450
+ """
451
+ stored = stored_chunks(path)
452
+ if not allow_shrink and not safe_to_write(len(chunks), len(stored), ratio):
453
+ print(f" REFUSING to write: {len(chunks)} chunks would replace "
454
+ f"{len(stored)} already in {path.name}. The upstream source "
455
+ f"likely changed shape -- check the scrape before writing "
456
+ f"(re-run with --allow-shrink once the drop is understood).")
457
+ return False
458
+ path.parent.mkdir(parents=True, exist_ok=True)
459
+ # Write-then-rename, not write-in-place: a run killed mid-write (Ctrl-C on
460
+ # the scheduled refresh, a full disk) would otherwise leave truncated JSON
461
+ # behind, and stored_chunks reads unparseable as empty -- which disables
462
+ # the guard above on the very next run, when it is needed most.
463
+ tmp = path.with_name(path.name + ".tmp")
464
+ tmp.write_text(json.dumps(chunks, ensure_ascii=False, indent=indent),
465
+ encoding="utf-8")
466
+ os.replace(tmp, path)
467
+ return True
468
+
469
+
470
+ def dup_suffix(n):
471
+ """The letter suffix for the n-th repeat of an id (n >= 1).
472
+
473
+ Bijective base 26 over a..z on n + 1: 1 -> 'b', ..., 25 -> 'z', then
474
+ 26 -> 'aa', 27 -> 'ab', ... 'az', 'ba', ... The first 25 are exactly the
475
+ single letters uniquify_ids has always produced, so every id already in a
476
+ corpus keeps its spelling; only repeats past 'z' change, and those used to
477
+ run on into '{', '|', DEL and C1 control characters (chr(ord('a') + n))."""
478
+ if n < 1:
479
+ raise ValueError("a first occurrence takes no suffix")
480
+ k, out = n + 1, ""
481
+ while k:
482
+ k, rem = divmod(k - 1, 26)
483
+ out = chr(ord("a") + rem) + out
484
+ return out
485
+
486
+
487
+ def uniquify_ids(chunks):
488
+ """Rename duplicate chunk ids in place (second occurrence -> id-b, -c...,
489
+ -z, then -aa, -ab, ...; see dup_suffix).
490
+
491
+ Duplicate ids silently orphan chunks downstream: the embeddings loader
492
+ keys rows by id, so one twin gets vectors and the other degrades to
493
+ BM25-only with nothing but a stderr note. Generators whose source
494
+ numbering can repeat (a manual's TOC line and body heading both reading
495
+ '5.1'; delegation items re-using an instrument number) run their output
496
+ through this before writing."""
497
+ seen = {}
498
+ for c in chunks:
499
+ n = seen.get(c["id"], 0)
500
+ seen[c["id"]] = n + 1
501
+ if n:
502
+ c["id"] = f"{c['id']}-{dup_suffix(n)}"
503
+ return chunks
canlex/agreement.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest Treasury Board collective agreements (HTML) into Article-level chunks.
2
+
3
+ A collective agreement is a binding contract between the Treasury Board and a
4
+ bargaining agent for one occupational group. Chunks are tagged
5
+ doc_type="agreement" so CanLex keeps them distinct from legislation and guidance.
6
+ """
7
+ import re
8
+ import sys
9
+
10
+ from bs4 import BeautifulSoup
11
+
12
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
13
+ split_lines, stored_chunks, write_corpus)
14
+ from .config import RAW_DIR, PROCESSED_DIR
15
+
16
+ AGREEMENT_DIR = RAW_DIR / "agreements"
17
+ OUT_FILE = PROCESSED_DIR / "agreements.json"
18
+
19
+ # Treasury Board collective agreements to ingest. Add an entry to ingest more.
20
+ AGREEMENTS = {
21
+ "FB": {
22
+ "short": "FB Agreement",
23
+ "name": "FB Group Collective Agreement (Border Services)",
24
+ "cite": "FB Collective Agreement",
25
+ "url": "https://www.canada.ca/en/treasury-board-secretariat/topics/pay/"
26
+ "collective-agreements/fb.html",
27
+ # TBS publishes the agreement bilingually; the French edition is the
28
+ # lang='fr' twin (agreements-fr.json), reachable by French queries only.
29
+ "url_fr": "https://www.canada.ca/fr/secretariat-conseil-tresor/sujets/"
30
+ "remuneration/conventions-collectives/fb.html",
31
+ "cite_fr": "Convention collective FB",
32
+ },
33
+ }
34
+ OUT_FILE_FR = OUT_FILE.parent / "agreements-fr.json"
35
+
36
+ _SKIP_HEADINGS = {"table of contents", "note to readers", "page details",
37
+ "on this page", "table des matières", "note aux lecteurs",
38
+ "détails de la page", "sur cette page"}
39
+ _CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
40
+ _ARTICLE = re.compile(r"Article\s+(\S+?)\s*[:–-]\s*(.+)", re.I)
41
+ _APPENDIX = re.compile(r"(?:Appendix|Annexe)\b", re.I)
42
+
43
+
44
+ def _fetch(url, dest, force=False):
45
+ """Download a page. canada.ca blocks Python's HTTP client at the TLS layer,
46
+ so fetch via PowerShell's (.NET) HTTP stack, which the site accepts."""
47
+ return fetch_cached(url, dest, force=force, powershell=True)
48
+
49
+
50
+ _PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
51
+
52
+
53
+ def _split(text, target=_PIECE_TARGET):
54
+ """Split long text into ~target-sized pieces at line boundaries."""
55
+ return split_lines(text, target)
56
+
57
+
58
+ def _block_text(heading):
59
+ """Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
60
+ lines = []
61
+ for sib in heading.find_next_siblings():
62
+ if sib.name in ("h2", "h3"):
63
+ break
64
+ if sib.name in ("ul", "ol"):
65
+ for li in sib.find_all("li", recursive=False):
66
+ item = _norm(li.get_text(" ", strip=True))
67
+ if item:
68
+ lines.append(f"- {item}")
69
+ elif sib.name in _CONTENT_TAGS:
70
+ text = _norm(sib.get_text(" ", strip=True))
71
+ if text:
72
+ lines.append(text)
73
+ return "\n".join(lines)
74
+
75
+
76
+ def parse_agreement(html, code, lang="en"):
77
+ """Parse a collective agreement page into one chunk per Article / Appendix.
78
+ lang='fr' parses the French edition: same article numbers, French titles
79
+ and citation, '-fr-' id infix and lang='fr' on every chunk."""
80
+ meta = AGREEMENTS[code]
81
+ cite = meta["cite_fr"] if lang == "fr" else meta["cite"]
82
+ url = meta["url_fr"] if lang == "fr" else meta["url"]
83
+ soup = BeautifulSoup(html, "html.parser")
84
+ main = soup.find("main")
85
+ if main is None:
86
+ return []
87
+
88
+ # The first <time datetime> is the agreement's expiry; the dateModified one
89
+ # is the page's date.
90
+ expiry = ""
91
+ for t in main.find_all("time"):
92
+ if t.get("datetime") and t.get("property") != "dateModified":
93
+ expiry = _norm(t.get("datetime"))
94
+ break
95
+
96
+ for tag in main.find_all(["section", "div"]):
97
+ tag.unwrap() # flatten so each heading and its content become siblings
98
+
99
+ chunks = []
100
+ current_part = ""
101
+ sec_idx = 0
102
+ for h in main.find_all(["h2", "h3"]):
103
+ if "wb-inv" in (h.get("class") or []):
104
+ continue
105
+ text = _norm(h.get_text(" ", strip=True)).lstrip("*").strip()
106
+ if not text or text.lower() in _SKIP_HEADINGS:
107
+ continue
108
+ if h.name == "h2":
109
+ current_part = text
110
+ body = _block_text(h)
111
+ if not body:
112
+ continue
113
+
114
+ article = _ARTICLE.match(text)
115
+ if article:
116
+ number = article.group(1).rstrip(":.")
117
+ note = article.group(2).strip()
118
+ citation = (f"{cite}, article {number}" if lang == "fr"
119
+ else f"{cite}, Article {number}")
120
+ elif _APPENDIX.match(text):
121
+ number, note = text, ""
122
+ citation = f"{cite}, {text}"
123
+ else:
124
+ # Memoranda of Agreement/Understanding have long titles: keep them in
125
+ # the note only, so the rendered header does not repeat them.
126
+ number, note = "", text
127
+ citation = cite
128
+
129
+ sec_idx += 1
130
+ # Split a long article so each piece fits the embed window; pieces share
131
+ # the base id and take a '-p<k>' suffix so the diversity cap groups them.
132
+ pieces = _split(body)
133
+ for k, piece in enumerate(pieces, start=1):
134
+ base = (f"agreement-fr-{code}-{sec_idx}" if lang == "fr"
135
+ else f"agreement-{code}-{sec_idx}")
136
+ chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
137
+ pnote = note if len(pieces) == 1 else (
138
+ f"{note} (partie {k})" if lang == "fr" else f"{note} (part {k})").strip()
139
+ chunks.append({
140
+ "id": chunk_id,
141
+ "doc_type": "agreement",
142
+ "act_code": code,
143
+ "act_short": meta["short"],
144
+ "act_name": meta["name"],
145
+ "section": number,
146
+ "marginal_note": pnote,
147
+ "part": current_part if h.name == "h3" else "",
148
+ "division": "",
149
+ "heading": "",
150
+ "text": piece,
151
+ "history": "",
152
+ "last_amended": "",
153
+ "current_to": expiry,
154
+ "citation": citation,
155
+ "source_url": url,
156
+ **({"lang": "fr"} if lang == "fr" else {}),
157
+ })
158
+ return chunks
159
+
160
+
161
+ def preserve_failed(chunks, failures, stored):
162
+ """Re-attach the last-good chunks of agreements this run could not rebuild.
163
+
164
+ Ingestion is a full rebuild, so an agreement whose page fails to fetch --
165
+ or whose canada.ca layout changes so that nothing parses out of it --
166
+ disappears from the corpus. An agreement that is still in force is better
167
+ served from the copy we already had than not at all. Restricted to the
168
+ codes that actually failed, so one removed from AGREEMENTS on purpose is
169
+ not resurrected. Returns (chunks, preserved codes).
170
+ """
171
+ failed = {code for code, _why in failures}
172
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
173
+ only=failed)
174
+
175
+
176
+ def ingest(force=False, allow_shrink=False):
177
+ all_chunks, failures = [], []
178
+ for code, meta in AGREEMENTS.items():
179
+ print(f"Ingesting {code} ({meta['short']})...")
180
+ try:
181
+ html = _fetch(meta["url"], AGREEMENT_DIR / f"{code}.html", force=force)
182
+ chunks = parse_agreement(html, code)
183
+ print(f" {len(chunks)} chunks")
184
+ all_chunks.extend(chunks)
185
+ if not chunks:
186
+ # A page that parses to nothing is a redesign upstream, not an
187
+ # empty agreement -- same class of failure as a dead fetch.
188
+ failures.append((code, "no content parsed"))
189
+ except Exception as exc:
190
+ failures.append((code, f"{type(exc).__name__}: {exc}"))
191
+ print(f" FAILED {code}: {type(exc).__name__}: {exc}")
192
+
193
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
194
+ stored_chunks(OUT_FILE))
195
+ if preserved:
196
+ print(f" kept the last-good copy of {len(preserved)} agreement(s): "
197
+ f"{', '.join(preserved)}")
198
+ if not write_corpus(OUT_FILE, all_chunks, indent=2,
199
+ allow_shrink=allow_shrink):
200
+ return False
201
+ print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
202
+ return True
203
+
204
+
205
+ def ingest_fr(force=False, allow_shrink=False):
206
+ """The French editions -> agreements-fr.json (same shape as ingest)."""
207
+ all_chunks, failures = [], []
208
+ for code, meta in AGREEMENTS.items():
209
+ if not meta.get("url_fr"):
210
+ continue
211
+ print(f"Ingesting {code} (French edition)...")
212
+ try:
213
+ html = _fetch(meta["url_fr"], AGREEMENT_DIR / f"{code}-fr.html",
214
+ force=force)
215
+ chunks = parse_agreement(html, code, lang="fr")
216
+ print(f" {len(chunks)} chunks")
217
+ all_chunks.extend(chunks)
218
+ if not chunks:
219
+ failures.append((code, "no content parsed"))
220
+ except Exception as exc:
221
+ failures.append((code, f"{type(exc).__name__}: {exc}"))
222
+ print(f" FAILED {code}: {type(exc).__name__}: {exc}")
223
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
224
+ stored_chunks(OUT_FILE_FR))
225
+ if preserved:
226
+ print(f" kept the last-good copy of: {', '.join(preserved)}")
227
+ if not write_corpus(OUT_FILE_FR, all_chunks, indent=2,
228
+ allow_shrink=allow_shrink):
229
+ return False
230
+ print(f"\n{len(all_chunks)} French chunks -> {OUT_FILE_FR.name}")
231
+ return True
232
+
233
+
234
+ def main():
235
+ run = ingest_fr if "--fr" in sys.argv else ingest
236
+ ok = run(force="--force" in sys.argv,
237
+ allow_shrink="--allow-shrink" in sys.argv)
238
+ sys.exit(0 if ok else 1)
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
canlex/airports.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aerodromes by the names officers use (2026-10-15).
2
+
3
+ The Canadian Aviation Security Regulations, 2012 (SOR/2011-318) list the 89
4
+ aerodromes of Schedules 1-3 by city and ICAO location indicator only ('Vancouver |
5
+ CYVR'), so a search for 'YVR', 'Pearson' or 'Billy Bishop' never met the row that
6
+ answers it. airport_terms() reads an IATA code or an airport's name in a query and
7
+ returns the Schedules' own words for each aerodrome named ('CYVR Vancouver'), which
8
+ LegislationIndex.search appends for its recall stages. The statute text is never
9
+ changed: this table only decides which schedule row a query can reach.
10
+
11
+ Sources. The ICAO indicator, the city as written and the class are the Regulations'
12
+ own (Schedules 1-3, consolidation current to 2025-04-14); tests/test_airports.py holds
13
+ this table to the ingested schedules, so an amendment to them fails the test until the
14
+ table follows. The IATA codes and the airports' names: Wikidata (CC0), retrieved
15
+ 2026-10-15, every code and name checked against a second source the same day.
16
+
17
+ Three kinds of words reach an aerodrome:
18
+ - its IATA code, as a whole word in either case -- except the codes that are also
19
+ words ('yam', 'yay', 'yow'), which count only in capitals;
20
+ - a DISTINCTIVE name, on its own ('Billy Bishop', 'Toronto Pearson', 'CFB Gander');
21
+ - a city or a name that also means a person or a place ('Pearson', 'Trudeau',
22
+ 'Regina'), only beside an airport word ('Pearson airport', 'aéroport de Québec',
23
+ 'is Pearson class 1', 'Calgary International').
24
+ A city alone never counts, and a more specific name beats the city it contains:
25
+ 'Toronto Pearson airport' reaches CYYZ, not the other two Toronto aerodromes.
26
+ """
27
+ import re
28
+ import unicodedata
29
+ from typing import NamedTuple
30
+
31
+
32
+ class Aerodrome(NamedTuple):
33
+ icao: str
34
+ iata: str
35
+ city: str # as Schedules 1-3 write it
36
+ cls: int # the Schedule: 1, 2 or 3
37
+ places: tuple = () # how a query names the place; counts beside an airport word
38
+ names: tuple = () # the airport's own names that need an airport word too
39
+ distinct: tuple = () # names that mean this aerodrome on their own
40
+
41
+
42
+ AERODROMES = (
43
+ # --- Schedule 1: Class 1 aerodromes ---
44
+ Aerodrome("CYYC", "YYC", "Calgary", 1, ("calgary",)),
45
+ Aerodrome("CYEG", "YEG", "Edmonton", 1, ("edmonton",)),
46
+ Aerodrome("CYHZ", "YHZ", "Halifax", 1, ("halifax",),
47
+ ("stanfield", "robert l stanfield"), ("halifax stanfield",)),
48
+ Aerodrome("CYMX", "YMX", "Montréal", 1, ("montreal",), ("mirabel",),
49
+ ("montreal mirabel",)),
50
+ Aerodrome("CYUL", "YUL", "Montréal", 1, ("montreal",),
51
+ ("trudeau", "pierre elliott trudeau", "dorval"), ("montreal trudeau",)),
52
+ Aerodrome("CYOW", "YOW", "Ottawa", 1, ("ottawa",), ("macdonald cartier",),
53
+ ("ottawa macdonald cartier",)),
54
+ Aerodrome("CYYZ", "YYZ", "Toronto", 1, ("toronto",),
55
+ ("pearson", "lester b pearson"), ("toronto pearson",)),
56
+ Aerodrome("CYVR", "YVR", "Vancouver", 1, ("vancouver",)),
57
+ Aerodrome("CYWG", "YWG", "Winnipeg", 1, ("winnipeg",),
58
+ ("richardson", "james armstrong richardson", "j a richardson"),
59
+ ("winnipeg richardson", "cfb winnipeg", "17 wing")),
60
+ # --- Schedule 2: Class 2 aerodromes ---
61
+ Aerodrome("CYYG", "YYG", "Charlottetown", 2, ("charlottetown",),
62
+ ("alexander b campbell",)), # renamed May 2026
63
+ Aerodrome("CYFC", "YFC", "Fredericton", 2, ("fredericton",)),
64
+ Aerodrome("CYQX", "YQX", "Gander", 2, ("gander",), (), ("cfb gander", "9 wing")),
65
+ Aerodrome("CYFB", "YFB", "Iqaluit", 2, ("iqaluit",), ("frobisher bay",)),
66
+ Aerodrome("CYLW", "YLW", "Kelowna", 2, ("kelowna",)),
67
+ Aerodrome("CYXU", "YXU", "London", 2, ("london ontario", "london ont", "london on")),
68
+ Aerodrome("CYQM", "YQM", "Moncton", 2, ("moncton",),
69
+ ("romeo leblanc",), ("greater moncton",)), # Roméo LeBlanc since 2016
70
+ Aerodrome("CYXS", "YXS", "Prince George", 2, ("prince george",)),
71
+ Aerodrome("CYQB", "YQB", "Québec", 2, ("quebec", "quebec city", "ville de quebec"),
72
+ ("jean lesage",)),
73
+ Aerodrome("CYQR", "YQR", "Regina", 2, ("regina",)),
74
+ Aerodrome("CYSJ", "YSJ", "Saint John", 2, ("saint john",)),
75
+ Aerodrome("CYYT", "YYT", "St. John’s", 2, ("st john's", "st johns", "saint john's")),
76
+ Aerodrome("CYXE", "YXE", "Saskatoon", 2, ("saskatoon",),
77
+ ("diefenbaker", "john g diefenbaker")),
78
+ Aerodrome("CYSB", "YSB", "Sudbury", 2, ("sudbury", "greater sudbury")),
79
+ Aerodrome("CYQT", "YQT", "Thunder Bay", 2, ("thunder bay",)),
80
+ Aerodrome("CYTZ", "YTZ", "Toronto", 2, ("toronto",),
81
+ ("toronto island", "toronto city", "toronto city centre"),
82
+ ("billy bishop",)),
83
+ Aerodrome("CYYJ", "YYJ", "Victoria", 2, ("victoria",)),
84
+ Aerodrome("CYXY", "YXY", "Whitehorse", 2, ("whitehorse",), (), ("erik nielsen",)),
85
+ Aerodrome("CYQG", "YQG", "Windsor", 2, ("windsor",)),
86
+ Aerodrome("CYZF", "YZF", "Yellowknife", 2, ("yellowknife",)),
87
+ # --- Schedule 3: Class 3 aerodromes ---
88
+ Aerodrome("CYXX", "YXX", "Abbotsford", 3, ("abbotsford",)),
89
+ Aerodrome("CYTF", "YTF", "Alma", 3, ("alma",)),
90
+ Aerodrome("CYBG", "YBG", "Bagotville", 3, ("bagotville",), ("saguenay",),
91
+ ("cfb bagotville", "3 wing")),
92
+ Aerodrome("CYBC", "YBC", "Baie-Comeau", 3, ("baie comeau",)),
93
+ Aerodrome("CZBF", "ZBF", "Bathurst", 3, ("bathurst",)),
94
+ Aerodrome("CYBR", "YBR", "Brandon", 3, ("brandon",)),
95
+ Aerodrome("CYBL", "YBL", "Campbell River", 3, ("campbell river",)),
96
+ Aerodrome("CYCG", "YCG", "Castlegar", 3, ("castlegar",), ("west kootenay",)),
97
+ Aerodrome("CYCL", "YCL", "Charlo", 3, ("charlo",)),
98
+ Aerodrome("CYMT", "YMT", "Chibougamau/Chapais", 3, ("chibougamau", "chapais")),
99
+ Aerodrome("CZUM", "ZUM", "Churchill Falls", 3, ("churchill falls",)),
100
+ Aerodrome("CYQQ", "YQQ", "Comox", 3, ("comox", "comox valley"), (),
101
+ ("cfb comox", "19 wing")),
102
+ Aerodrome("CYXC", "YXC", "Cranbrook", 3, ("cranbrook",), ("canadian rockies",)),
103
+ Aerodrome("CYDQ", "YDQ", "Dawson Creek", 3, ("dawson creek",)),
104
+ Aerodrome("CYDF", "YDF", "Deer Lake", 3, ("deer lake",)),
105
+ Aerodrome("CYMM", "YMM", "Fort McMurray", 3, ("fort mcmurray",)),
106
+ Aerodrome("CYXJ", "YXJ", "Fort St. John", 3, ("fort st john", "fort saint john"),
107
+ ("north peace",)),
108
+ Aerodrome("CYGP", "YGP", "Gaspé", 3, ("gaspe",), ("michel pouliot",)),
109
+ Aerodrome("CYYR", "YYR", "Goose Bay", 3, ("goose bay", "happy valley goose bay"), (),
110
+ ("cfb goose bay", "5 wing")),
111
+ Aerodrome("CYQU", "YQU", "Grande Prairie", 3, ("grande prairie",)),
112
+ Aerodrome("CYHM", "YHM", "Hamilton", 3, ("hamilton",), ("munro", "john c munro")),
113
+ Aerodrome("CYGR", "YGR", "Îles-de-la-Madeleine", 3,
114
+ ("iles de la madeleine", "magdalen islands")),
115
+ Aerodrome("CYKA", "YKA", "Kamloops", 3, ("kamloops",)),
116
+ Aerodrome("CYGK", "YGK", "Kingston", 3, ("kingston ontario", "kingston ont", "kingston on"),
117
+ ("norman rogers",)),
118
+ Aerodrome("CYKF", "YKF", "Kitchener/Waterloo", 3,
119
+ ("kitchener", "waterloo", "kitchener waterloo"), (), ("region of waterloo",)),
120
+ Aerodrome("CYVP", "YVP", "Kuujjuaq", 3, ("kuujjuaq",)),
121
+ Aerodrome("CYGW", "YGW", "Kuujjuarapik", 3, ("kuujjuarapik",)),
122
+ Aerodrome("CYGL", "YGL", "La Grande Rivière", 3,
123
+ ("la grande riviere", "radisson", "grande riviere")),
124
+ Aerodrome("CYFJ", "YTM", "La Macaza / Mont-Tremblant", 3, ("la macaza", "mont tremblant")),
125
+ Aerodrome("CYQL", "YQL", "Lethbridge", 3, ("lethbridge", "lethbridge county")),
126
+ Aerodrome("CYLL", "YLL", "Lloydminster", 3, ("lloydminster",)),
127
+ Aerodrome("CYBX", "YBX", "Lourdes-de-Blanc-Sablon", 3,
128
+ ("lourdes de blanc sablon", "blanc sablon")),
129
+ Aerodrome("CYXH", "YXH", "Medicine Hat", 3, ("medicine hat",)),
130
+ Aerodrome("CYYY", "YYY", "Mont-Joli", 3, ("mont joli",)),
131
+ Aerodrome("CYCD", "YCD", "Nanaimo", 3, ("nanaimo",)),
132
+ Aerodrome("CYYB", "YYB", "North Bay", 3, ("north bay",), ("jack garland",)),
133
+ Aerodrome("CYYF", "YYF", "Penticton", 3, ("penticton",)),
134
+ Aerodrome("CYPA", "YPA", "Prince Albert", 3, ("prince albert",), ("glass field",)),
135
+ Aerodrome("CYPR", "YPR", "Prince Rupert", 3, ("prince rupert",)),
136
+ Aerodrome("CYQZ", "YQZ", "Quesnel", 3, ("quesnel",)),
137
+ Aerodrome("CYQF", "YQF", "Red Deer", 3, ("red deer",)),
138
+ Aerodrome("CYRJ", "YRJ", "Roberval", 3, ("roberval",)),
139
+ Aerodrome("CYUY", "YUY", "Rouyn-Noranda", 3, ("rouyn noranda",)),
140
+ Aerodrome("CYAY", "YAY", "St. Anthony", 3, ("st anthony", "saint anthony")),
141
+ Aerodrome("CYSL", "YSL", "Saint-Léonard", 3, ("saint leonard", "st leonard")),
142
+ Aerodrome("CYZP", "YZP", "Sandspit", 3, ("sandspit",), (),
143
+ ("k'il kun xidgwangs daanaay",)),
144
+ Aerodrome("CYZR", "YZR", "Sarnia", 3, ("sarnia",), ("chris hadfield",)),
145
+ Aerodrome("CYAM", "YAM", "Sault Ste. Marie", 3, ("sault ste marie", "sault sainte marie")),
146
+ Aerodrome("CYZV", "YZV", "Sept-Îles", 3, ("sept iles",)),
147
+ Aerodrome("CYYD", "YYD", "Smithers", 3, ("smithers",)),
148
+ Aerodrome("CYJT", "YJT", "Stephenville", 3, ("stephenville",)),
149
+ Aerodrome("CYQY", "YQY", "Sydney", 3, ("sydney nova scotia", "sydney ns", "sydney n s"),
150
+ ("mccurdy", "douglas mccurdy", "j a douglas mccurdy")),
151
+ Aerodrome("CYXT", "YXT", "Terrace", 3, ("terrace",), ("northwest regional",)),
152
+ Aerodrome("CYTH", "YTH", "Thompson", 3, ("thompson",)),
153
+ Aerodrome("CYTS", "YTS", "Timmins", 3, ("timmins",), ("victor m power",)),
154
+ # Buttonville closed on 2023-11-30 and IATA has withdrawn YKZ; the Regulations
155
+ # still list it, so its name still reaches the row
156
+ Aerodrome("CYKZ", "", "Toronto", 3, ("toronto",), (), ("buttonville",)),
157
+ Aerodrome("CYVO", "YVO", "Val-d’Or", 3, ("val d'or",)),
158
+ Aerodrome("CYWK", "YWK", "Wabush", 3, ("wabush",)),
159
+ Aerodrome("CYWL", "YWL", "Williams Lake", 3, ("williams lake",)),
160
+ Aerodrome("CYQI", "YQI", "Yarmouth", 3, ("yarmouth",)),
161
+ )
162
+
163
+ # IATA codes that are also words an officer may type ('can I import a yam'): they
164
+ # count only as written in capitals.
165
+ _WORD_CODES = frozenset({"YAM", "YAY", "YOW"})
166
+
167
+ # Words that make a place or a person's name mean an airport. 'International',
168
+ # 'Regional' and 'Municipal' count only right after the name ('Calgary International',
169
+ # 'North Peace Regional'): 'Vancouver international students' is not an airport.
170
+ # 'Terminal' is not one ('Vancouver cruise terminal').
171
+ _CONTEXT = re.compile(r"(?<![\w'])(?:airports?|aerodromes?|airfields?|aeroports?|aerogares?"
172
+ r"|class [123]|classe [123]|categorie [123])(?![\w'])")
173
+ _INTERNATIONAL = re.compile(r"\s*(?:internationale?|regionale?|municipale?)(?![\w'])")
174
+ _WINDOW = 3 # words either side of a name in which an airport word must sit
175
+
176
+
177
+ def _fold(text):
178
+ """Lower case, accents and curly quotes folded, hyphens, slashes and other
179
+ punctuation as spaces ('Montréal-Trudeau' -> 'montreal trudeau', 'St. John’s'
180
+ -> "st john's")."""
181
+ text = unicodedata.normalize("NFKD", text or "")
182
+ text = "".join(ch for ch in text if not unicodedata.combining(ch)).lower()
183
+ text = text.replace("’", "'").replace("‘", "'")
184
+ return " ".join(re.sub(r"[^\w']+", " ", text).split())
185
+
186
+
187
+ def _phrase(words):
188
+ return re.compile(r"(?<![\w'])" + re.escape(words) + r"(?![\w'])")
189
+
190
+
191
+ _ENTRIES = tuple(
192
+ (a, tuple(_phrase(p) for p in a.distinct),
193
+ tuple(_phrase(p) for p in a.names), tuple(_phrase(p) for p in a.places))
194
+ for a in AERODROMES)
195
+ _BY_IATA = {a.iata: a for a in AERODROMES if a.iata}
196
+
197
+
198
+ def _in_context(folded, start, end):
199
+ """True when an airport word sits within _WINDOW words of folded[start:end]."""
200
+ before = " ".join(folded[:start].split()[-_WINDOW:])
201
+ after = folded[end:]
202
+ return bool(_CONTEXT.search(before) or _CONTEXT.search(" ".join(after.split()[:_WINDOW]))
203
+ or _INTERNATIONAL.match(after))
204
+
205
+
206
+ def airport_terms(query):
207
+ """['CYVR Vancouver', ...]: the Schedules' words for every aerodrome `query` names
208
+ by IATA code, a distinctive name, or a city or name beside an airport word, in
209
+ the order of the table; [] when it names none."""
210
+ found = {}
211
+ for token in re.findall(r"[A-Za-z]+", query or ""):
212
+ a = _BY_IATA.get(token.upper())
213
+ if a and (token.isupper() or a.iata not in _WORD_CODES):
214
+ found[a.icao] = a
215
+ folded = _fold(query)
216
+ covered = [] # spans a distinctive or a named match took: no city inside them
217
+ for a, distinct, names, _ in _ENTRIES:
218
+ for pattern in distinct:
219
+ for m in pattern.finditer(folded):
220
+ found[a.icao] = a
221
+ covered.append(m.span())
222
+ for pattern in names:
223
+ for m in pattern.finditer(folded):
224
+ if _in_context(folded, *m.span()):
225
+ found[a.icao] = a
226
+ covered.append(m.span())
227
+ # longest place first, so a place inside a longer one ('saint john' inside 'fort
228
+ # saint john') yields to it, as a city yields to a name that contains it
229
+ places = sorted(((m.span(), a) for a, _, _, patterns in _ENTRIES for p in patterns
230
+ for m in p.finditer(folded)), key=lambda x: x[0][0] - x[0][1])
231
+ for (s, e), a in places:
232
+ if any(cs <= s and e <= ce and (cs, ce) != (s, e) for cs, ce in covered):
233
+ continue
234
+ if _in_context(folded, s, e):
235
+ found[a.icao] = a
236
+ covered.append((s, e))
237
+ return [f"{a.icao} {a.city}" for a in AERODROMES if a.icao in found]
canlex/amps.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the CBSA Master Penalty Document (AMPS contraventions).
2
+
3
+ The Master Penalty Document is the public per-contravention reference for the
4
+ Administrative Monetary Penalty System: each page (C001 onward) gives the
5
+ contravention text, the penalty amounts by occurrence level, and the
6
+ designated provision it enforces. Together with the Designated Provisions
7
+ (Customs) Regulations schedule, it answers the trade-compliance staple
8
+ "what does contravention X cost on a second occurrence?" -- which the corpus
9
+ previously could not.
10
+
11
+ Same fetch/cache/parse pattern as dmemo.py: index page -> per-contravention
12
+ pages, cached under data/raw/amps, one chunk per contravention (they are
13
+ short), doc_type='memorandum' (administrative guidance, not law).
14
+
15
+ python -m canlex.amps
16
+ """
17
+ import re
18
+ import sys
19
+
20
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
21
+ stored_chunks, write_corpus)
22
+ from .config import PROCESSED_DIR, RAW_DIR
23
+
24
+ INDEX_URL = "https://www.cbsa-asfc.gc.ca/trade-commerce/amps/mpd-dmi-eng.html"
25
+ BASE = "https://www.cbsa-asfc.gc.ca"
26
+ RAW = RAW_DIR / "amps"
27
+
28
+ # Hrefs on the index may be absolute or relative; key on the c-number page
29
+ # name alone.
30
+ _LINK = re.compile(r'href="([^"]*?contraventions-infractions/'
31
+ r'([a-z]\d{3})-eng\.html)"')
32
+ _MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
33
+ _TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
34
+
35
+
36
+ def _strip(html):
37
+ """Readable text: table cells joined with ' | ', block ends -> newlines."""
38
+ html = re.sub(r"</t[dh]>", " | ", html)
39
+ html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
40
+ html = re.sub(r"<[^>]+>", " ", html)
41
+ lines = [_norm(l).strip("| ").strip() for l in html.split("\n")]
42
+ return "\n".join(l for l in lines if l)
43
+
44
+
45
+ def _main_content(html):
46
+ """The page's main block, shorn of nav/footer boilerplate."""
47
+ m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
48
+ return m.group(1) if m else html
49
+
50
+
51
+ def parse_page(html, code, url):
52
+ content = _main_content(html)
53
+ title_m = _TITLE.search(content)
54
+ title = _norm(re.sub(r"<[^>]+>", " ", title_m.group(1))) if title_m else code.upper()
55
+ body = _strip(content[title_m.end():] if title_m else content)
56
+ mod = _MODIFIED.search(html)
57
+ modified = mod.group(1) if mod else ""
58
+ # The first non-empty line after the title is the contravention text
59
+ # itself -- promote it to the marginal note so retrieval titles carry it.
60
+ first = next((l for l in body.split("\n") if len(l) > 30), "")
61
+ return {
62
+ "id": f"amps-{code}",
63
+ "doc_type": "memorandum",
64
+ "act_code": "AMPS",
65
+ "act_short": "AMPS",
66
+ "act_name": "AMPS Master Penalty Document",
67
+ "section": code.upper(),
68
+ "marginal_note": first[:160],
69
+ "part": "Master Penalty Document",
70
+ "division": "",
71
+ "heading": title,
72
+ "text": body,
73
+ "history": "",
74
+ "last_amended": modified,
75
+ "current_to": modified,
76
+ "citation": f"AMPS contravention {code.upper()}",
77
+ "source_url": url,
78
+ }
79
+
80
+
81
+ def _fetch(url, dest, force=False):
82
+ """cbsa-asfc.gc.ca serves this section empty to Python's HTTP client;
83
+ route through PowerShell (like canada.ca fetches) and treat an empty
84
+ cache as a miss."""
85
+ if dest.exists() and dest.stat().st_size == 0:
86
+ dest.unlink()
87
+ data = fetch_cached(url, dest, powershell=True, pause=0.4, force=force)
88
+ if not data.strip():
89
+ raise ValueError(f"empty response for {url}")
90
+ return data
91
+
92
+
93
+ def preserve_failed(chunks, failed, stored):
94
+ """Re-attach the last-good chunks of contraventions this run could not fetch.
95
+
96
+ Ingestion is a full rebuild, so a page that 404s or times out silently
97
+ leaves the corpus -- and a penalty still in force is worse missing than
98
+ slightly stale. `failed` carries the (code, why) pairs build() collects;
99
+ codes are lower-case on the index but upper-case in a chunk's `section`,
100
+ so match on the upper-cased form. Restricted to codes that actually
101
+ errored, so a contravention CBSA has genuinely retired stays gone.
102
+ Returns (chunks, preserved codes).
103
+ """
104
+ codes = {code.upper() for code, _why in failed}
105
+ return preserve_dropped(chunks, stored, key=lambda c: c["section"].upper(),
106
+ only=codes)
107
+
108
+
109
+ def build(limit=None, allow_shrink=False, force=False):
110
+ html = _fetch(INDEX_URL, RAW / "index.html",
111
+ force=force).decode("utf-8", "replace")
112
+ links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
113
+ key=lambda t: t[1])
114
+ if limit:
115
+ links = links[:limit]
116
+ print(f"amps: {len(links)} contravention pages on the index")
117
+ chunks, failed = [], []
118
+ for path, code in links:
119
+ if path.startswith("http"):
120
+ url = path
121
+ elif path.startswith("/"):
122
+ url = BASE + path
123
+ else:
124
+ url = INDEX_URL.rsplit("/", 1)[0] + "/" + path
125
+ try:
126
+ page = _fetch(url, RAW / f"{code}.html",
127
+ force=force).decode("utf-8", "replace")
128
+ chunks.append(parse_page(page, code, url))
129
+ except Exception as exc:
130
+ failed.append((code, f"{type(exc).__name__}: {exc}"))
131
+ out = PROCESSED_DIR / "amps.json"
132
+ # Printed before the write so a refusal still shows what went wrong.
133
+ if failed:
134
+ print(f"amps: {len(failed)} failed: {failed[:5]}")
135
+ if not limit:
136
+ chunks, preserved = preserve_failed(chunks, failed, stored_chunks(out))
137
+ if preserved:
138
+ print(f"amps: kept the last-good copy of {len(preserved)} "
139
+ f"unfetchable contravention(s): {', '.join(preserved)}")
140
+ # The guard applies to --limit runs too: a smoke test over five
141
+ # contraventions must not be able to write a five-page corpus over the real
142
+ # one. indent=2 matches what is already on disk -- changing it would
143
+ # rewrite every line of the file.
144
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
145
+ return False
146
+ print(f"amps: {len(chunks)} contraventions -> {out.name}")
147
+ return True
148
+
149
+
150
+ def main():
151
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
152
+ force="--force" in sys.argv)
153
+ sys.exit(0 if ok else 1)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
canlex/authorities.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The authority registry (roadmap T3.2; track A step A12, 2026-09-14).
2
+
3
+ data/curated/authorities.json (HELD FOR REVIEW) links every decision the
4
+ curated datasets and the code cite to the corpus: a stable key per authority,
5
+ an alias table of the exact cite strings used, the caselaw.json decision key
6
+ (act_short / act_code, exact), pinned chunks each with a verbatim 'contains'
7
+ phrase, an ordered list of the secondary chunks that describe an authority
8
+ the corpus does not hold (official guidance before archived IRB papers,
9
+ before withdrawn ATIP copies, before guidance that cites the reversed trial
10
+ decision in Saini), a status (in-corpus / secondary-only / unavailable) and
11
+ the subsequent history. resolve() reads a cite and returns the pinned chunk
12
+ and its excerpt; the pointer lines tell an officer where the text is:
13
+
14
+ full text in CanLex: canlex_search_legislation doc_type caselaw, act=...
15
+ not in the CanLex corpus; quoted in <secondary>
16
+
17
+ Never canlex_case -- it needs a CanLII key and returns the citator, not the
18
+ decision. The file is optional: a composed release ships without held files,
19
+ and every renderer then prints HELD_NOTE instead of pointers.
20
+
21
+ Rendering is gated on the registry's own top-level "render": true (2026-09-15,
22
+ wave-3 verifier). The approved f5cf54d registry has no such flag: its notes
23
+ record corrections to the curated holdings that were never applied, so a
24
+ release composed on that baseline prints no pointers (and keeps
25
+ server.SUPERSEDED_CITES) rather than a 'full text in CanLex' pointer beside an
26
+ uncorrected holding. The corrected registry sets the flag.
27
+
28
+ verify_lines() prints, wherever a tool shows a pairing's verified maximum, the
29
+ sentence of the statute that contains each verify phrase, tagged with the Act,
30
+ section, current_to and chunk id -- read from data/processed, never typed.
31
+ Where the phrase occurs more than once (s. 344's 'imprisonment for life' is in
32
+ the firearm clause (a) and in '(b) in any other case'), the general clause is
33
+ quoted.
34
+ """
35
+ import functools
36
+ import json
37
+ import re
38
+
39
+ from .config import DATA_DIR, PROCESSED_DIR
40
+
41
+ FILE = "authorities.json"
42
+ PATH = DATA_DIR / "curated" / FILE
43
+
44
+ STATUSES = ("in-corpus", "secondary-only", "unavailable")
45
+ # The order secondary chunks are listed in (official first; the reversed
46
+ # trial decision's citers last).
47
+ SECONDARY_ORDER = ("in-corpus decision", "official", "archived IRB paper",
48
+ "withdrawn (unofficial ATIP copy)", "cites trial-level Saini (reversed)")
49
+
50
+ # ---- officer-facing wording (registered in canlex/reviewed_code.py) ----------
51
+ HELD_NOTE = ("Authority registry (data/curated/authorities.json): HELD FOR REVIEW -- not "
52
+ "in this deployment, so the cites above are not linked to the corpus. Where a "
53
+ "decision is in the corpus, canlex_search_legislation (doc_type 'caselaw') "
54
+ "finds it by name; canlex_case needs a CanLII key and returns the citator, not "
55
+ "the decision.")
56
+ READ_NOTE = ("Each authority above says where its text is: 'full text in CanLex' means "
57
+ "canlex_search_legislation (doc_type 'caselaw', act=<name>) returns the "
58
+ "decision; 'not in the CanLex corpus' means only the listed guidance or later "
59
+ "decisions describe it. The registry's subsequent-history line is the only "
60
+ "treatment check CanLex holds -- confirm a decision's currency elsewhere "
61
+ "before relying on it.")
62
+ # The tail printed when this deployment's registry is not rendered (the
63
+ # approved f5cf54d baseline): no pointers above, and still never canlex_case.
64
+ NO_POINTER_NOTE = ("Retrieve a cited decision's text with canlex_search_legislation "
65
+ "(doc_type 'caselaw', the decision's name) where the CanLex corpus "
66
+ "holds it. CanLex holds no treatment check beyond the SUBSEQUENT "
67
+ "HISTORY notes it prints -- confirm a decision's currency elsewhere "
68
+ "before relying on it.")
69
+ UNLINKED = "not in the CanLex authority registry (unlinked cite; see data/curated/authorities.json)"
70
+ STATUS_TEXT = {
71
+ "in-corpus": "full text in CanLex: canlex_search_legislation doc_type caselaw, act={short!r}",
72
+ "absent": ("not in this deployment's corpus (the registry lists it as in-corpus, "
73
+ "act={short!r}); quoted in {where}"),
74
+ "secondary-only": "not in the CanLex corpus; quoted in {where}",
75
+ "unavailable": "not in the CanLex corpus ({why}); described in {where}",
76
+ }
77
+ NO_SECONDARY = "no corpus source describes it"
78
+ VERIFY_LABEL = "VERIFIED TEXT"
79
+ VERIFY_MISSING = ("{act} s. {section}: corpus text not available in this deployment "
80
+ "(phrase: {phrase!r})")
81
+ VERIFY_NOT_FOUND = ("{act} s. {section}{when}: phrase {phrase!r} NOT FOUND in the corpus "
82
+ "text -- the quoted maximum needs re-verification")
83
+
84
+ _QUOTES = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'",
85
+ " ": " "})
86
+
87
+
88
+ def _norm(s):
89
+ return re.sub(r"\s+", " ", (s or "").translate(_QUOTES)).strip()
90
+
91
+
92
+ def _fold(cite):
93
+ """A cite string as compared: case, spacing and dotted initials folded, so
94
+ 'Canada (M.C.I.) v Saini' still finds 'Canada (MCI) v Saini'."""
95
+ s = _norm(cite).lower().replace(".", "")
96
+ return re.sub(r"\s+", " ", s)
97
+
98
+
99
+ # ---- the registry ---------------------------------------------------------------
100
+
101
+ @functools.lru_cache(maxsize=4)
102
+ def _load(path_str):
103
+ try:
104
+ with open(path_str, encoding="utf-8") as fh:
105
+ data = json.loads(fh.read())
106
+ except (OSError, ValueError):
107
+ return None
108
+ if not isinstance(data, dict) or not isinstance(data.get("authorities"), dict):
109
+ return None
110
+ folded = {_fold(cite): key for cite, key in (data.get("aliases") or {}).items()}
111
+ data["_folded"] = folded
112
+ return data
113
+
114
+
115
+ def load(path=None):
116
+ """The registry, or None when this deployment lacks the (held) file."""
117
+ return _load(str(path or PATH))
118
+
119
+
120
+ def renderable(registry=None):
121
+ """True when the registry says its rows may be printed beside the curated
122
+ holdings (top-level "render": true -- see the module docstring)."""
123
+ reg = registry if registry is not None else load()
124
+ return bool(reg and reg.get("render") is True)
125
+
126
+
127
+ def read_note(registry=None):
128
+ """The closing line of an answer that cites decisions: READ_NOTE when the
129
+ registry's pointers are printed, NO_POINTER_NOTE otherwise."""
130
+ return READ_NOTE if renderable(registry) else NO_POINTER_NOTE
131
+
132
+
133
+ def reload():
134
+ _load.cache_clear()
135
+ _corpus_file.cache_clear()
136
+ _act_sections.cache_clear()
137
+ statute_sentence.cache_clear()
138
+
139
+
140
+ def lookup(cite, registry=None):
141
+ """(key, row) for an exact cite string (folded), or (None, None)."""
142
+ reg = registry if registry is not None else load()
143
+ if not reg or not cite:
144
+ return None, None
145
+ key = (reg.get("aliases") or {}).get(cite) or reg["_folded"].get(_fold(cite))
146
+ if key is None and cite in reg["authorities"]:
147
+ key = cite
148
+ if key is None:
149
+ return None, None
150
+ return key, reg["authorities"].get(key)
151
+
152
+
153
+ # ---- the corpus: caselaw keys and the chunks the registry names ------------------
154
+
155
+ def _file_for(chunk_id):
156
+ if chunk_id.startswith("enf-fr-"):
157
+ return "enf-fr.json"
158
+ if chunk_id.startswith("enf-"):
159
+ return "enf.json"
160
+ if chunk_id.startswith("irb-ls-"):
161
+ return "irb_papers.json"
162
+ if chunk_id.startswith("irb-"):
163
+ return "irb_guidelines.json"
164
+ if chunk_id.startswith("pdi-"):
165
+ return "pdi.json"
166
+ if chunk_id.startswith("commentary-"):
167
+ return "commentary.json"
168
+ if chunk_id.startswith("policy-"):
169
+ return "policy.json"
170
+ return "caselaw.json"
171
+
172
+
173
+ def _wanted_ids(registry):
174
+ ids = set()
175
+ for row in (registry or {}).get("authorities", {}).values():
176
+ ids.update(p["chunk"] for p in row.get("pinned") or [])
177
+ ids.update(s["chunk"] for s in row.get("secondary") or [])
178
+ return frozenset(ids)
179
+
180
+
181
+ @functools.lru_cache(maxsize=8)
182
+ def _corpus_file(name, wanted):
183
+ """(decision keys, citations, {id: chunk}) of one processed file, keeping
184
+ only the chunks the registry names (caselaw.json is 20 MB)."""
185
+ keys, citations, chunks = set(), set(), {}
186
+ try:
187
+ data = json.loads((PROCESSED_DIR / name).read_text(encoding="utf-8"))
188
+ except (OSError, ValueError):
189
+ return None
190
+ for c in data:
191
+ if c.get("doc_type") == "caselaw":
192
+ keys.add((c.get("act_short"), c.get("act_code")))
193
+ citations.add(c.get("citation") or "")
194
+ if c.get("id") in wanted:
195
+ chunks[c["id"]] = c
196
+ return keys, citations, chunks
197
+
198
+
199
+ def _corpus(name, registry=None):
200
+ reg = registry if registry is not None else load()
201
+ return _corpus_file(name, _wanted_ids(reg))
202
+
203
+
204
+ def chunk(chunk_id, registry=None):
205
+ """A chunk the registry names, from the processed corpus, or None."""
206
+ got = _corpus(_file_for(chunk_id), registry)
207
+ return (got[2].get(chunk_id) if got else None)
208
+
209
+
210
+ def decision_present(decision_key, index_chunks=None):
211
+ """True / False when the corpus does / does not hold the decision the key
212
+ names (the loaded index's chunks when given, else data/processed);
213
+ None when neither is readable."""
214
+ if not decision_key:
215
+ return False
216
+ want = (decision_key.get("act_short"), decision_key.get("act_code"))
217
+ if index_chunks is not None:
218
+ return want in _decision_keys(index_chunks)
219
+ got = _corpus("caselaw.json")
220
+ if got is None:
221
+ return None
222
+ return want in got[0]
223
+
224
+
225
+ _KEYS_SEEN = {}
226
+
227
+
228
+ def _decision_keys(index_chunks):
229
+ """{(act_short, act_code)} of the caselaw chunks of a loaded index,
230
+ computed once per chunk list (a tool answer asks for every authority)."""
231
+ got = _KEYS_SEEN.get(id(index_chunks))
232
+ if got is None or got[0] != len(index_chunks):
233
+ keys = {(c.get("act_short"), c.get("act_code")) for c in index_chunks
234
+ if c.get("doc_type") == "caselaw"}
235
+ _KEYS_SEEN.clear()
236
+ _KEYS_SEEN[id(index_chunks)] = got = (len(index_chunks), keys)
237
+ return got[1]
238
+
239
+
240
+ def excerpt(chunk_id, phrase, registry=None, width=280):
241
+ """The sentence of the chunk that contains the phrase (the phrase itself
242
+ when the chunk is not readable here)."""
243
+ c = chunk(chunk_id, registry)
244
+ if c is None:
245
+ return None
246
+ return _sentence(c.get("text") or "", phrase, width)
247
+
248
+
249
+ # A period that ends one of these does not end a sentence ('Mr. Drake',
250
+ # 'para. 24', 'J. dissenting', 's. 36', 'No. 363').
251
+ _ABBREV = re.compile(r"(?:\b(?:Mr|Mrs|Ms|Dr|Hon|J|JJ|JA|No|Nos|v|vs|para|paras|s|ss|subs|p|pp|"
252
+ r"art|arts|c|ch|cf|e\.g|i\.e|R\.S\.C|S\.C|F\.C|D\.L\.R|S\.C\.R|F\.T\.R|"
253
+ r"Imm\. L\.R|N\.R|F\.C\.J)|\b[A-Z])$")
254
+ _BOUNDARY = re.compile(r"[.;:](?=\s+[A-Z(\[\"]|\s*$)")
255
+
256
+
257
+ def _boundaries(t):
258
+ """Offsets just after each sentence end in t (abbreviations skipped)."""
259
+ out = []
260
+ for m in _BOUNDARY.finditer(t):
261
+ if m.group(0) == "." and _ABBREV.search(t[:m.start()]):
262
+ continue
263
+ out.append(m.end())
264
+ return out
265
+
266
+
267
+ # The general limb of a punishment clause: where a maximum phrase occurs more
268
+ # than once in a section ('(a) if a restricted firearm ... is used ..., to
269
+ # imprisonment for life ...; and (b) in any other case, to imprisonment for
270
+ # life', ss. 273 and 344), the occurrence after this formula is the one that
271
+ # states the maximum for every case (wave-3 verifier, 2026-09-15).
272
+ _GENERAL_CLAUSE = re.compile(r"in any other case,?\s+(?:to\s+|by\s+)?$", re.I)
273
+
274
+
275
+ _CLAUSE_MARK = re.compile(r"\([a-z](?:\.\d+)?\)\s*in any other case", re.I)
276
+
277
+
278
+ def _general_clause_quote(t, first, i, n):
279
+ """'<the punishment sentence's lead-in> ... (b) in any other case, to
280
+ <maximum>': the words that make the clause a maximum, and the clause,
281
+ without the special cases between them (each part verbatim)."""
282
+ ends = _boundaries(t)
283
+ s0 = max([e for e in ends if e <= first], default=0)
284
+ mark = re.search(r"\(a\)", t[s0:first])
285
+ clause = list(_CLAUSE_MARK.finditer(t, max(0, i - 80), i))
286
+ if not mark or not clause:
287
+ return None
288
+ end = next((e for e in ends if e >= i + n), len(t))
289
+ return f"{t[s0:s0 + mark.start()].strip()} ... {t[clause[-1].start():end].strip()}"
290
+
291
+
292
+ def _sentence(text, phrase, width=280, general=False):
293
+ t = _norm(text)
294
+ p = _norm(phrase)
295
+ low, pl = t.lower(), p.lower()
296
+ hits = [m.start() for m in re.finditer(re.escape(pl), low)] if pl else []
297
+ if not hits:
298
+ return None
299
+ i = hits[0]
300
+ if general:
301
+ i = next((h for h in hits if _GENERAL_CLAUSE.search(t[max(0, h - 40):h])), i)
302
+ if i != hits[0]:
303
+ lead = _general_clause_quote(t, hits[0], i, len(p))
304
+ if lead:
305
+ return lead
306
+ ends = _boundaries(t)
307
+ before = [e for e in ends if e <= i]
308
+ start = before[-1] if before else 0
309
+ after = [e for e in ends if e >= i + len(p)]
310
+ end = after[0] if after else len(t)
311
+ s = t[start:end].strip()
312
+ if len(s) > 2 * width + len(p):
313
+ a, b = max(start, i - width), min(end, i + len(p) + width)
314
+ s = ("..." if a > start else "") + t[a:b].strip() + ("..." if b < end else "")
315
+ return s
316
+
317
+
318
+ # ---- resolve --------------------------------------------------------------------
319
+
320
+ def resolve(cite, registry=None, index_chunks=None, with_excerpts=True):
321
+ """What the registry knows about a cite: key, name, status, whether this
322
+ corpus holds the decision, the pinned chunks with their excerpts, the
323
+ ordered secondary chunks, the subsequent history. None when the registry
324
+ is absent; {'unlinked': True} when the cite is not in it."""
325
+ reg = registry if registry is not None else load()
326
+ if reg is None:
327
+ return None
328
+ key, row = lookup(cite, reg)
329
+ if row is None:
330
+ return {"cite": cite, "unlinked": True}
331
+ present = (decision_present(row.get("decision_key"), index_chunks)
332
+ if row.get("status") == "in-corpus" else False)
333
+ pinned = []
334
+ for p in row.get("pinned") or []:
335
+ e = dict(p)
336
+ if with_excerpts:
337
+ c = chunk(p["chunk"], reg)
338
+ e["excerpt"] = _sentence(c.get("text") or "", p["contains"]) if c else None
339
+ e["marginal_note"] = (c or {}).get("marginal_note")
340
+ pinned.append(e)
341
+ return {"cite": cite, "key": key, "name": row.get("name"), "status": row.get("status"),
342
+ "present": present, "decision_key": row.get("decision_key"),
343
+ "citation": row.get("citation"), "court": row.get("court"),
344
+ "decided": row.get("decided"), "pinned": pinned,
345
+ "secondary": list(row.get("secondary") or []),
346
+ "subsequent_history": row.get("subsequent_history"),
347
+ "unavailable_reason": row.get("unavailable_reason"),
348
+ "parallel_cites": list(row.get("parallel_cites") or []),
349
+ "notes": list(row.get("notes") or [])}
350
+
351
+
352
+ def _where(res, limit=4):
353
+ secs = res.get("secondary") or []
354
+ if not secs:
355
+ return NO_SECONDARY
356
+ parts = []
357
+ for s in secs[:limit]:
358
+ dated = f", {s['current_to']}" if s.get("current_to") else ""
359
+ parts.append(f"{s['label']} [{s['chunk']}; {s['status']}{dated}]")
360
+ more = len(secs) - limit
361
+ return "; ".join(parts) + (f"; and {more} more" if more > 0 else "")
362
+
363
+
364
+ def _officer_pin(p):
365
+ """A pin's label as an officer may read it, or None. Reviewer notes live
366
+ in a pin's 'review_note' (never printed); a label that still talks about
367
+ the curated data ('the curated (para 11) is wrong' -- the approved
368
+ registry's style) is a reviewer note too, and is not printed."""
369
+ label = (p or {}).get("pin") or ""
370
+ return None if (not label or re.search(r"curated|->", label)) else label
371
+
372
+
373
+ def _pick_pin(res, pin):
374
+ """The registry pin that matches the curated pin's first number, else the
375
+ first pinned chunk that is not a heading-only pin."""
376
+ pins = res.get("pinned") or []
377
+ if not pins:
378
+ return None
379
+ nums = re.findall(r"\d+", pin or "")
380
+ if nums:
381
+ for p in pins:
382
+ if re.search(rf"\b{nums[0]}\b", p.get("pin") or "") and not p.get("note"):
383
+ return p
384
+ return next((p for p in pins if not p.get("note")), pins[0])
385
+
386
+
387
+ def pointer(res, pin=None, brief=False):
388
+ """The officer-facing line under a cited authority (resolve()'s result)."""
389
+ if res is None:
390
+ return HELD_NOTE
391
+ if res.get("unlinked"):
392
+ return UNLINKED
393
+ short = (res.get("decision_key") or {}).get("act_short")
394
+ status = res["status"]
395
+ if status == "in-corpus" and res.get("present") is not False:
396
+ line = STATUS_TEXT["in-corpus"].format(short=short)
397
+ if res.get("present") is None:
398
+ line += " (presence in this deployment not checked)"
399
+ p = _pick_pin(res, pin)
400
+ if p:
401
+ label = _officer_pin(p)
402
+ line += (f"; pinned {label}, chunk {p['chunk']}" if label
403
+ else f"; pinned chunk {p['chunk']}")
404
+ if not brief and p.get("excerpt"):
405
+ line += f': "{p["excerpt"]}"'
406
+ if brief:
407
+ return line
408
+ if res.get("subsequent_history") and res["subsequent_history"] != "None found.":
409
+ line += f"\n subsequent history: {res['subsequent_history']}"
410
+ return line
411
+ where = _where(res)
412
+ if status == "in-corpus":
413
+ line = STATUS_TEXT["absent"].format(short=short, where=where)
414
+ elif status == "unavailable":
415
+ why = res.get("unavailable_reason") or "unavailable"
416
+ line = (STATUS_TEXT["unavailable"].format(why=why, where=where)
417
+ if res.get("secondary") else f"not in the CanLex corpus ({why}); {NO_SECONDARY}")
418
+ else:
419
+ line = STATUS_TEXT["secondary-only"].format(where=where)
420
+ if not brief and res.get("subsequent_history") and res["subsequent_history"] != "None found.":
421
+ line += f"\n subsequent history: {res['subsequent_history']}"
422
+ return line
423
+
424
+
425
+ def authority_lines(auths, registry=None, brief=False, index_chunks=None):
426
+ """The 'Authorities:' bullets of an entry or pairing, each followed by its
427
+ registry pointer (or HELD_NOTE once when the registry is absent; the bare
428
+ bullets when the registry is not rendered -- see renderable())."""
429
+ reg = registry if registry is not None else load()
430
+ lines = []
431
+ show = reg is not None and renderable(reg)
432
+ for a in auths or []:
433
+ pin = f", {a['pin']}" if a.get("pin") else ""
434
+ lines.append(f"- {a['cite']} ({a.get('court', '')}{pin}): {a.get('holding', '')}")
435
+ if not show:
436
+ continue
437
+ res = resolve(a["cite"], reg, index_chunks=index_chunks, with_excerpts=not brief)
438
+ text = pointer(res, a.get("pin"), brief=brief)
439
+ lines += [" " + t for t in text.split("\n")]
440
+ if auths and reg is None:
441
+ lines.append(f" ({HELD_NOTE})" if brief else f" {HELD_NOTE}")
442
+ return lines
443
+
444
+
445
+ # ---- subsequent history: the reversed decisions guidance still cites -------------
446
+
447
+ def superseded_entries(registry=None):
448
+ """SUPERSEDED_CITES-shaped entries from the registry rows that carry a
449
+ 'superseded' block (the reversed trial decision in Saini), or [] when the
450
+ registry is absent or not rendered -- canlex/server.py then uses its own
451
+ constant."""
452
+ reg = registry if registry is not None else load()
453
+ if not renderable(reg):
454
+ return []
455
+ out = []
456
+ for key, row in sorted((reg or {}).get("authorities", {}).items()):
457
+ s = row.get("superseded")
458
+ if not s:
459
+ continue
460
+ try:
461
+ pattern = re.compile(s["pattern"], re.S)
462
+ except (re.error, KeyError):
463
+ continue
464
+ out.append({"id": key, "pattern": pattern, "note": s.get("note", ""),
465
+ "appellate_citation": s.get("appellate_citation", ""),
466
+ "read_present": s.get("read_present", ""),
467
+ "read_absent": s.get("read_absent", ""),
468
+ "read_unknown": s.get("read_unknown", "")})
469
+ return out
470
+
471
+
472
+ # ---- the statute sentence behind each verified maximum --------------------------
473
+
474
+ @functools.lru_cache(maxsize=16)
475
+ def _act_sections(act):
476
+ """{section: [chunk]} of an Act's English chunks, or None when the
477
+ processed file is absent here."""
478
+ try:
479
+ data = json.loads((PROCESSED_DIR / f"{act}.json").read_text(encoding="utf-8"))
480
+ except (OSError, ValueError):
481
+ return None
482
+ out = {}
483
+ for c in data:
484
+ if c.get("lang", "en") != "en":
485
+ continue
486
+ out.setdefault(str(c.get("section")), []).append(c)
487
+ return out
488
+
489
+
490
+ @functools.lru_cache(maxsize=512)
491
+ def statute_sentence(act, section, phrase):
492
+ """{act, section, current_to, chunk, sentence} for the corpus sentence
493
+ that contains a verify phrase; {'missing': True} when the file is absent;
494
+ {'not_found': True, 'current_to': ...} when the phrase is gone (a corpus
495
+ refresh changed the text)."""
496
+ sections = _act_sections(act)
497
+ if sections is None:
498
+ return {"act": act, "section": section, "phrase": phrase, "missing": True}
499
+ chunks = sections.get(str(section)) or []
500
+ for c in chunks:
501
+ s = _sentence(c.get("text") or "", phrase, general=True)
502
+ if s:
503
+ return {"act": act, "section": section, "phrase": phrase,
504
+ "current_to": c.get("current_to") or "", "chunk": c.get("id"),
505
+ "sentence": s}
506
+ return {"act": act, "section": section, "phrase": phrase, "not_found": True,
507
+ "current_to": (chunks[0].get("current_to") if chunks else "") or ""}
508
+
509
+
510
+ def verify_rows(pairing, limb=None):
511
+ """The pairing's verify rows plus the selected limb's, without repeats."""
512
+ rows, seen = [], set()
513
+ for v in list(pairing.get("verify") or []) + (
514
+ [v for l in pairing.get("limbs") or [] if l.get("key") == limb
515
+ for v in l.get("verify") or []] if limb else []):
516
+ key = (v.get("act"), v.get("section"), v.get("contains"))
517
+ if key in seen or not all(key):
518
+ continue
519
+ seen.add(key)
520
+ rows.append(v)
521
+ return rows
522
+
523
+
524
+ def verify_lines(pairing, limb=None):
525
+ """The VERIFIED TEXT lines printed under a pairing's maximum: for each
526
+ verify phrase, the statute sentence that contains it, with the Act, the
527
+ section, the corpus 'current to' date and the chunk id."""
528
+ lines = []
529
+ for v in verify_rows(pairing, limb):
530
+ r = statute_sentence(v["act"], str(v["section"]), v["contains"])
531
+ if r.get("missing"):
532
+ lines.append(f"{VERIFY_LABEL}: " + VERIFY_MISSING.format(
533
+ act=v["act"], section=v["section"], phrase=v["contains"]))
534
+ elif r.get("not_found"):
535
+ when = f" (current to {r['current_to']})" if r.get("current_to") else ""
536
+ lines.append(f"{VERIFY_LABEL}: " + VERIFY_NOT_FOUND.format(
537
+ act=v["act"], section=v["section"], when=when, phrase=v["contains"]))
538
+ else:
539
+ lines.append(f"{VERIFY_LABEL}: {r['act']} s. {r['section']} (current to "
540
+ f"{r['current_to'] or 'n/a'}, chunk {r['chunk']}): \"{r['sentence']}\"")
541
+ return lines
canlex/benefits.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the member booklets of the federal public-service benefit plans.
2
+
3
+ Four plans cover federal employees (and a Border Services Officer over a
4
+ career), by population:
5
+
6
+ - PSHCP -- Public Service Health Care Plan (health; active + pensioners)
7
+ - PSDCP -- Public Service Dental Care Plan (dental; active employees)
8
+ - PDSP -- Pensioners' Dental Services Plan (dental; pensioners)
9
+ - PSMIP -- Public Service Management Insurance Plan (life / AD&D / disability
10
+ insurance; management-category and excluded employees -- so EX and
11
+ excluded CBSA staff rather than the represented FB group, who carry
12
+ the separate Disability Insurance Plan)
13
+
14
+ Each plan's *member booklet* is the plain-language guide the plan administrator
15
+ or Treasury Board publishes -- what is covered, who is eligible, how to claim,
16
+ the appeals process. The booklets are guidance: the governing terms live in the
17
+ PSHCP Directive (already ingested as doc_type='directive') and the respective
18
+ plan rules, which prevail in any discrepancy. They are tagged doc_type=
19
+ 'benefits' and labelled as such in results.
20
+
21
+ Sources: the Canada Life PDFs (welcome.canadalife.com / canadalife.com), which
22
+ -- unlike canada.ca -- serve Python's urllib fine; and the PSMIP booklet, an
23
+ HTML page on canada.ca (which blocks urllib at the TLS layer, so it is fetched
24
+ via PowerShell, as agreement.py does). A PDF booklet carries a table of
25
+ contents whose headings are located in the body to segment it; the HTML booklet
26
+ is segmented at its h2/h3 headings. Either way a retrieved chunk is a coherent
27
+ benefit topic.
28
+
29
+ py -m canlex.benefits
30
+ """
31
+ import io
32
+ import re
33
+ import sys
34
+
35
+ from bs4 import BeautifulSoup
36
+ from pypdf import PdfReader
37
+
38
+ from ._common import (dehyphenate, document_words, fetch_cached, norm_ws,
39
+ preserve_dropped, split_lines, stored_chunks,
40
+ write_corpus)
41
+
42
+ from .config import PROCESSED_DIR, RAW_DIR
43
+
44
+ RAW = RAW_DIR / "benefits"
45
+ OUT = PROCESSED_DIR / "benefits.json"
46
+
47
+ _CHUNK_CHARS = 1800 # target characters per chunk
48
+
49
+ # Each plan: its booklet, and how to read it. PDF booklets give the page
50
+ # indices of the table of contents and the first body page (everything before
51
+ # it is cover + TOC). The HTML booklet is parsed at its h2/h3 headings.
52
+ # 'edition' is the booklet's own date (for HTML it is read from the page's
53
+ # "Date modified"); bump a PDF edition and re-run when a new booklet is posted.
54
+ SOURCES = {
55
+ "PSHCP": {
56
+ "code": "PSHCP", "format": "pdf",
57
+ "name": "Public Service Health Care Plan",
58
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
59
+ "welcome-sites/pshcp/PSHCP-member-booklet.pdf"),
60
+ "label": "Member Booklet",
61
+ "toc_pages": [2, 3, 4],
62
+ "body_start": 5,
63
+ "edition": "June 2024",
64
+ },
65
+ "PSDCP": {
66
+ "code": "PSDCP", "format": "pdf",
67
+ "name": "Public Service Dental Care Plan",
68
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
69
+ "welcome-sites/psdcp/psdcp-member-booklet.pdf"),
70
+ "label": "Member Booklet",
71
+ "toc_pages": [2, 3],
72
+ "body_start": 4,
73
+ "edition": "May 2026",
74
+ },
75
+ "PDSP": {
76
+ "code": "PDSP", "format": "pdf",
77
+ "name": "Pensioners' Dental Services Plan",
78
+ "url": ("https://www.canadalife.com/content/dam/rfp/welcome-sites/"
79
+ "pdsp/pdsp-member-booklet.pdf"),
80
+ "label": "Member Booklet",
81
+ "toc_pages": [2, 3],
82
+ "body_start": 4,
83
+ "edition": "November 2024",
84
+ },
85
+ "PSMIP": {
86
+ "code": "PSMIP", "format": "html",
87
+ "name": "Public Service Management Insurance Plan",
88
+ "url": ("https://www.canada.ca/en/treasury-board-secretariat/services/"
89
+ "benefit-plans/management-insurance-plan/main-plan-booklet.html"),
90
+ "label": "Main Plan Booklet",
91
+ # The canada.ca page exposes no machine-readable date (its <time> tags are
92
+ # content dates, not a page revision). Rather than render currency as
93
+ # 'n/a', state plainly that the source is undated so a reader knows to
94
+ # verify rather than assume staleness.
95
+ "edition": "undated (TBS web edition)",
96
+ },
97
+ }
98
+
99
+ # canada.ca section labels that are navigation/footer, not plan content.
100
+ _SKIP_HEADINGS = {"on this page", "page details", "from:", "table of contents"}
101
+
102
+ # A table-of-contents line: optional dot-leaders, the heading text, then the
103
+ # printed page number. The heading must end in a letter or ')' so a bare page
104
+ # number or a dot run alone is not mistaken for a heading.
105
+ _TOC_LINE = re.compile(r"^[\s.]*(.*?[A-Za-z)])\s*\.*\s*(\d{1,3})\s*$")
106
+
107
+
108
+ def _fetch(url, dest, force=False):
109
+ return fetch_cached(url, dest, timeout=90, pause=0, force=force)
110
+
111
+
112
+ def _clean(text):
113
+ """Tidy extracted PDF text: turn bullets into dashes, collapse runs of
114
+ spaces, and trim excess blank lines. Apostrophes, accents and en-dashes
115
+ extract as proper Unicode, so no transliteration is needed."""
116
+ text = text.replace("•", "\n- ").replace("\xa0", " ")
117
+ text = re.sub(r"[ \t]+", " ", text)
118
+ text = re.sub(r"\n[ \t]+", "\n", text)
119
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
120
+
121
+
122
+ def _page_texts(pdf_bytes):
123
+ reader = PdfReader(io.BytesIO(pdf_bytes))
124
+ return [p.extract_text() or "" for p in reader.pages]
125
+
126
+
127
+ def parse_toc(page_texts, toc_pages):
128
+ """Return the ordered, de-duplicated list of section headings from the
129
+ table of contents."""
130
+ seen, headings = set(), []
131
+ for i in toc_pages:
132
+ if i >= len(page_texts):
133
+ continue
134
+ for line in page_texts[i].split("\n"):
135
+ line = line.strip()
136
+ if not line or "Table of Contents" in line:
137
+ continue
138
+ m = _TOC_LINE.match(line)
139
+ if not m:
140
+ continue
141
+ title = re.sub(r"\s+", " ", m.group(1)).strip(" .")
142
+ if len(title) > 2 and title.lower() not in seen:
143
+ seen.add(title.lower())
144
+ headings.append(title)
145
+ return headings
146
+
147
+
148
+ def segment(body, headings):
149
+ """Split the body text at each heading, in order, returning a list of
150
+ (heading, section_text). A heading the extractor split across lines is
151
+ matched with flexible whitespace; a heading not found in the body is
152
+ skipped (its text folds into the preceding section)."""
153
+ cuts = []
154
+ pos = 0
155
+ for h in headings:
156
+ pattern = re.compile(r"\s*".join(re.escape(w) for w in h.split()), re.I)
157
+ m = pattern.search(body, pos)
158
+ if m:
159
+ cuts.append((h, m.start()))
160
+ pos = m.end()
161
+ sections = []
162
+ for j, (h, start) in enumerate(cuts):
163
+ end = cuts[j + 1][1] if j + 1 < len(cuts) else len(body)
164
+ sections.append((h, body[start:end].strip()))
165
+ return sections
166
+
167
+
168
+ def _split(text, target=_CHUNK_CHARS):
169
+ """Split a long section into ~target-sized pieces at line boundaries,
170
+ folding small leading/trailing remnants into a neighbour so a section
171
+ never yields a stub chunk."""
172
+ return split_lines(text, target, fold_stubs=True)
173
+
174
+
175
+ def _slug(heading, used):
176
+ """A short, unique, citable section locator from a heading."""
177
+ base = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "section"
178
+ slug, n = base, 2
179
+ while slug in used:
180
+ slug = f"{base}-{n}"
181
+ n += 1
182
+ used.add(slug)
183
+ return slug
184
+
185
+
186
+ def _emit(sections, src):
187
+ """Build CanLex chunk dicts from a list of (heading, section_text), shared
188
+ by the PDF and HTML paths. Skips pure section-divider headings and
189
+ sub-splits any section over the chunk budget."""
190
+ chunks = []
191
+ used_slugs = set()
192
+ label = src["label"]
193
+ for heading, text in sections:
194
+ # Skip pure section dividers -- a heading whose only content is the
195
+ # heading itself (its substance lives in the child sections below it).
196
+ # Compare with whitespace collapsed, since a PDF extractor often breaks
197
+ # a heading across lines ("...Plan \n(PSDCP)").
198
+ norm_text = norm_ws(text)
199
+ norm_head = norm_ws(heading)
200
+ remainder = (norm_text[len(norm_head):]
201
+ if norm_text.lower().startswith(norm_head.lower())
202
+ else norm_text)
203
+ if len(remainder.strip()) < 40:
204
+ continue
205
+ slug = _slug(heading, used_slugs)
206
+ pieces = _split(text)
207
+ for k, piece in enumerate(pieces, start=1):
208
+ section = slug if len(pieces) == 1 else f"{slug}-{k}"
209
+ note = heading if len(pieces) == 1 else f"{heading} (part {k})"
210
+ chunks.append({
211
+ "id": f"benefits-{src['code']}-{section}",
212
+ "doc_type": "benefits",
213
+ "act_code": src["code"],
214
+ "act_short": src["code"],
215
+ "act_name": f"{src['name']} — {label}",
216
+ "section": section,
217
+ "marginal_note": note,
218
+ "part": f"{src['name']} {label}",
219
+ "division": "",
220
+ "heading": "",
221
+ "text": piece,
222
+ "history": "",
223
+ "last_amended": "",
224
+ "current_to": src["edition"],
225
+ "citation": (f"{src['code']} {label}"
226
+ + (f" ({src['edition']})" if src["edition"] else "")
227
+ + f", “{heading}”"),
228
+ "source_url": src["url"],
229
+ })
230
+ return chunks
231
+
232
+
233
+ def parse_pdf_booklet(pdf_bytes, src):
234
+ """Segment a PDF booklet by its table-of-contents headings."""
235
+ pages = _page_texts(pdf_bytes)
236
+ headings = parse_toc(pages, src["toc_pages"])
237
+ body = _clean("\n".join(pages[src["body_start"]:]))
238
+ # line-break hyphens closed up (keeping the hyphen unless the booklet
239
+ # itself uses the joined word; 2026-09-22)
240
+ body = dehyphenate(body, document_words(pages))
241
+ return _emit(segment(body, headings), src)
242
+
243
+
244
+ def _html_block_text(heading):
245
+ """Readable text from an h2/h3 up to the next heading of the same or higher
246
+ level; lists become dash items."""
247
+ lines = []
248
+ for sib in heading.find_next_siblings():
249
+ if sib.name in ("h2", "h3"):
250
+ break
251
+ if sib.name in ("ul", "ol"):
252
+ for li in sib.find_all("li", recursive=False):
253
+ item = norm_ws(li.get_text(" ", strip=True))
254
+ if item:
255
+ lines.append(f"- {item}")
256
+ elif sib.name in ("p", "div", "table", "section", "h4", "h5"):
257
+ text = norm_ws(sib.get_text(" ", strip=True))
258
+ if text:
259
+ lines.append(text)
260
+ return "\n".join(lines)
261
+
262
+
263
+ def parse_html_booklet(html, src):
264
+ """Segment the canada.ca HTML booklet at its h2/h3 headings."""
265
+ soup = BeautifulSoup(html, "html.parser")
266
+ main = soup.find("main") or soup
267
+ # Read the page's "Date modified" so the booklet's currency is recorded.
268
+ if not src["edition"]:
269
+ t = main.find("time", property="dateModified") or soup.find(
270
+ "time", property="dateModified")
271
+ if t:
272
+ src = {**src, "edition": t.get_text(strip=True)}
273
+ sections = []
274
+ for h in main.find_all(["h2", "h3"]):
275
+ title = norm_ws(h.get_text(" ", strip=True))
276
+ if not title or title.lower() in _SKIP_HEADINGS:
277
+ continue
278
+ body = _html_block_text(h)
279
+ if body:
280
+ sections.append((title, f"{title}\n{body}"))
281
+ return _emit(sections, src)
282
+
283
+
284
+ def _fetch_html(url, dest, force=False):
285
+ """Fetch a canada.ca page via PowerShell -- the site blocks Python's HTTP
286
+ client at the TLS layer, but accepts PowerShell's .NET stack."""
287
+ return fetch_cached(url, dest, powershell=True, force=force)
288
+
289
+
290
+ def preserve_failed(chunks, failures, stored):
291
+ """Re-attach the last-good chunks of plans this run could not rebuild.
292
+
293
+ Ingestion is a full rebuild of all four booklets into one file, so one plan
294
+ failing -- Canada Life moves a PDF, canada.ca times out, a re-typeset
295
+ booklet no longer segments -- silently deletes that plan's coverage while
296
+ the other three look healthy. A plan's chunks all carry its code in
297
+ act_code, so the stored copy can be restored intact. Restricted to plans
298
+ that actually failed, so a plan removed from SOURCES on purpose (a wound-up
299
+ plan) is not resurrected. Returns (chunks, preserved plan codes).
300
+ """
301
+ failed = {code for code, _why in failures}
302
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
303
+ only=failed)
304
+
305
+
306
+ def build(allow_shrink=False, force=False):
307
+ all_chunks, failures = [], []
308
+ for src in SOURCES.values():
309
+ print(f"Ingesting {src['code']} {src['label'].lower()} ...")
310
+ try:
311
+ if src["format"] == "pdf":
312
+ pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
313
+ force=force)
314
+ chunks = parse_pdf_booklet(pdf, src)
315
+ else:
316
+ html = _fetch_html(src["url"], RAW / f"{src['code']}.html",
317
+ force=force).decode("utf-8", "replace")
318
+ chunks = parse_html_booklet(html, src)
319
+ except Exception as exc:
320
+ print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
321
+ failures.append((src["code"], f"{type(exc).__name__}: {exc}"))
322
+ continue
323
+ if not chunks:
324
+ # A booklet that downloads fine but segments to nothing has failed
325
+ # just as surely as one that 404s -- that is how the CBSA index
326
+ # loss looked -- so it counts as a failure rather than a plan that
327
+ # legitimately has no content.
328
+ print(f" !! {src['code']}: no sections parsed")
329
+ failures.append((src["code"], "no sections parsed"))
330
+ continue
331
+ all_chunks.extend(chunks)
332
+ print(f" {len(chunks)} chunks")
333
+
334
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
335
+ stored_chunks(OUT))
336
+ if preserved:
337
+ print(f" kept the last-good copy of {len(preserved)} booklet(s): "
338
+ f"{', '.join(preserved)}")
339
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
340
+ return False
341
+ print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
342
+ f"booklet(s) -> {OUT}")
343
+ return True
344
+
345
+
346
+ def main():
347
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
348
+ force="--force" in sys.argv) else 1)
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
canlex/bm25.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BM25 postings in array (CSR) form, and their on-disk cache.
2
+
3
+ Audit follow-up 2026-09-21 (Optimization 5, 6 and 8). The postings were a
4
+ dict of Python lists of (chunk, tf) tuples -- 7.04 M tuples over 62,016
5
+ terms, about +522 MB of RSS -- rebuilt from 137,894 tokenize calls on every
6
+ start (8.5-18 s of LegislationIndex()'s 12-31 s), and scored one tuple at a
7
+ time in Python. Here each term's postings are two slices of two arrays
8
+ (chunk indices ascending, term frequencies), so the scorer in
9
+ canlex/index.py adds a whole term at once with numpy, and the arrays -- with
10
+ the corpus statistics and the typo corrector's word counts -- are cached
11
+ under data/cache/ (gitignored).
12
+
13
+ The cache key is the processed files' (name, size, mtime_ns) AND a
14
+ fingerprint of the code that produced the arrays (code_fingerprint): keyed
15
+ by data alone it would keep serving stale postings after a tokenizer or
16
+ stemmer change. A cache that cannot be read or written is rebuilt in
17
+ process -- retrieval never depends on it.
18
+ """
19
+ import hashlib
20
+ import inspect
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from array import array
26
+ from collections import Counter
27
+ from pathlib import Path
28
+ from typing import NamedTuple
29
+
30
+ import numpy as np
31
+
32
+ from .config import DATA_DIR
33
+
34
+ # Bump when the cached layout or the meaning of a cached array changes in a
35
+ # way the code fingerprint cannot see (a dtype, a field's semantics).
36
+ CACHE_VERSION = 1
37
+ CACHE_DIR = Path(os.environ.get("CANLEX_CACHE_DIR") or DATA_DIR / "cache")
38
+ _CACHE_PREFIX = "bm25-"
39
+ # A writer's bm25-<key>.<pid>.tmp older than this is an orphan of a killed
40
+ # process and save() removes it; a live writer's file is seconds old.
41
+ _TMP_STALE_SECONDS = 600
42
+ _SCOPES = ("en", "all", "fr") # the corrector vocabularies (Bm25Data.words)
43
+
44
+
45
+ class Postings:
46
+ """term -> (chunk indices ascending, term frequencies) in CSR form: the
47
+ postings of the i-th term are docs[offsets[i]:offsets[i + 1]] and
48
+ tfs[...] alike. Terms keep their first-seen corpus order, the order the
49
+ dict of lists had, so every vocabulary built by iterating them is
50
+ unchanged."""
51
+
52
+ __slots__ = ("terms", "offsets", "docs", "tfs", "_ids")
53
+
54
+ def __init__(self, terms, offsets, docs, tfs):
55
+ self.terms = terms
56
+ self.offsets, self.docs, self.tfs = offsets, docs, tfs
57
+ self._ids = {t: i for i, t in enumerate(terms)}
58
+
59
+ def span(self, term):
60
+ """(chunk indices, term frequencies) array views, or None."""
61
+ i = self._ids.get(term)
62
+ if i is None:
63
+ return None
64
+ a, b = int(self.offsets[i]), int(self.offsets[i + 1])
65
+ return self.docs[a:b], self.tfs[a:b]
66
+
67
+ def get(self, term, default=None):
68
+ """The chunk indices holding `term` -- len() is its document
69
+ frequency -- or `default`: the dict.get the dict of lists offered."""
70
+ hit = self.span(term)
71
+ return default if hit is None else hit[0]
72
+
73
+ def __contains__(self, term):
74
+ return term in self._ids
75
+
76
+ def __len__(self):
77
+ return len(self.terms)
78
+
79
+ def __iter__(self):
80
+ return iter(self.terms)
81
+
82
+
83
+ class Bm25Data(NamedTuple):
84
+ """What build() derives from the chunks (and the cache stores): the
85
+ postings, each chunk's length in tokens, per-term document frequencies
86
+ over the whole corpus and over the English chunks, the English scope's
87
+ size and total length, and the typo corrector's word counts by scope
88
+ ('en': chunks whose lang is not 'fr'; 'all': every chunk; 'fr': the
89
+ French chunks' words with their accents)."""
90
+ postings: Postings
91
+ doc_len: np.ndarray
92
+ df: np.ndarray
93
+ df_en: np.ndarray
94
+ en_docs: int
95
+ en_total_len: int
96
+ words: dict
97
+
98
+
99
+ def build(chunks, blob, tokenize, words, fr_words):
100
+ """Bm25Data for `chunks`. `blob(chunk)` is the text BM25 indexes,
101
+ `tokenize` turns it into terms, `words(text)` lists the corrector's
102
+ vocabulary words of a chunk's text and `fr_words(text)` a French
103
+ chunk's accented ones. A chunk is English unless its lang is 'fr' (the
104
+ scope of the English statistics and of the 'en' words)."""
105
+ ids = {} # term -> id, in first-seen order
106
+ term_id = ids.setdefault
107
+ col_term, col_doc, col_tf = array("i"), array("i"), array("i")
108
+ doc_len = array("q")
109
+ words_all, words_en, words_fr = Counter(), Counter(), Counter()
110
+ english = np.array([c.get("lang", "en") != "fr" for c in chunks], dtype=bool)
111
+ for idx, c in enumerate(chunks):
112
+ tokens = tokenize(blob(c))
113
+ counts = Counter(tokens)
114
+ doc_len.append(len(tokens))
115
+ col_term.extend([term_id(t, len(ids)) for t in counts])
116
+ col_tf.extend(counts.values())
117
+ col_doc.extend([idx] * len(counts))
118
+ found = words(c["text"])
119
+ words_all.update(found)
120
+ if english[idx]:
121
+ words_en.update(found)
122
+ else:
123
+ words_fr.update(fr_words(c["text"]))
124
+ postings, df, df_en = _csr(list(ids), col_term, col_doc, col_tf, english)
125
+ lengths = np.frombuffer(doc_len, dtype=np.int64).copy()
126
+ return Bm25Data(postings, lengths, df, df_en, int(english.sum()),
127
+ int(lengths[english].sum()),
128
+ {"en": words_en, "all": words_all, "fr": words_fr})
129
+
130
+
131
+ def _csr(terms, col_term, col_doc, col_tf, english):
132
+ """(Postings, df, df_en) from the (term, chunk, tf) columns, which run
133
+ in chunk order: a stable sort by term keeps each term's chunks
134
+ ascending, as the dict of lists appended them."""
135
+ term = np.frombuffer(col_term, dtype=np.intc)
136
+ doc = np.frombuffer(col_doc, dtype=np.intc)
137
+ order = np.argsort(term, kind="stable")
138
+ df = np.bincount(term, minlength=len(terms)).astype(np.int64)
139
+ df_en = np.bincount(term[english[doc]], minlength=len(terms)).astype(np.int64)
140
+ offsets = np.zeros(len(terms) + 1, dtype=np.int64)
141
+ np.cumsum(df, out=offsets[1:])
142
+ tfs = np.frombuffer(col_tf, dtype=np.intc)[order]
143
+ if tfs.size and int(tfs.max()) < 2 ** 16:
144
+ tfs = tfs.astype(np.uint16)
145
+ docs = doc[order].astype(np.int32)
146
+ return Postings(terms, offsets, docs, tfs), df, df_en
147
+
148
+
149
+ def first_seen(parts):
150
+ """The distinct values of the int arrays `parts`, in order of first
151
+ appearance -- the insertion order a dict filled term by term had."""
152
+ if not parts:
153
+ return np.zeros(0, dtype=np.int64)
154
+ flat = np.concatenate(parts)
155
+ uniq, first = np.unique(flat, return_index=True)
156
+ return uniq[np.argsort(first, kind="stable")]
157
+
158
+
159
+ # ---- the cache ---------------------------------------------------------------
160
+
161
+ def _source(obj):
162
+ """What of `obj` the fingerprint hashes: a function's source (its code
163
+ object when no source is available), anything else's repr."""
164
+ if callable(obj):
165
+ try:
166
+ return inspect.getsource(obj)
167
+ except (OSError, TypeError):
168
+ code = getattr(obj, "__code__", None)
169
+ return repr((code.co_code, code.co_consts)) if code else repr(obj)
170
+ return repr(obj)
171
+
172
+
173
+ def code_fingerprint(*parts):
174
+ """sha256 over CACHE_VERSION, build()'s own source and every part (the
175
+ tokenizer, the stemmer and its table, the blob and word functions, the
176
+ stemmer's version -- whatever shapes the cached arrays)."""
177
+ h = hashlib.sha256()
178
+ for part in (CACHE_VERSION, build, _csr, *parts):
179
+ h.update(_source(part).encode("utf-8"))
180
+ h.update(b"\x00")
181
+ return h.hexdigest()
182
+
183
+
184
+ def file_stamps(paths):
185
+ """[(name, size, mtime_ns)] of the processed files, in the given order."""
186
+ out = []
187
+ for p in paths:
188
+ st = Path(p).stat()
189
+ out.append((Path(p).name, st.st_size, st.st_mtime_ns))
190
+ return out
191
+
192
+
193
+ def cache_key(stamps, code):
194
+ """The cache key: the data stamps and the code fingerprint together."""
195
+ return json.dumps({"version": CACHE_VERSION, "code": code, "files": stamps},
196
+ separators=(",", ":"))
197
+
198
+
199
+ def cache_path(key, cache_dir=None):
200
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
201
+ return Path(cache_dir or CACHE_DIR) / f"{_CACHE_PREFIX}{digest}.npz"
202
+
203
+
204
+ def _joined(strings):
205
+ return np.frombuffer("\n".join(strings).encode("utf-8"), dtype=np.uint8)
206
+
207
+
208
+ def _split(arr):
209
+ text = arr.tobytes().decode("utf-8")
210
+ return text.split("\n") if text else []
211
+
212
+
213
+ def _drop_stale_tmp(directory):
214
+ """Remove the bm25-*.tmp files in `directory` last written more than
215
+ _TMP_STALE_SECONDS ago: what a writer killed between open() and
216
+ os.replace() leaves. A file that cannot be read or removed is left.
217
+ Verification 2026-09-22 (A-CACHE-TMP): save() swept only *.npz, so each
218
+ killed writer's partial cache stayed on disk for good."""
219
+ now = time.time()
220
+ for tmp in Path(directory).glob(f"{_CACHE_PREFIX}*.tmp"):
221
+ try:
222
+ if now - tmp.stat().st_mtime > _TMP_STALE_SECONDS:
223
+ tmp.unlink()
224
+ except OSError:
225
+ pass # gone already, or still held open by its writer
226
+
227
+
228
+ def save(key, data, cache_dir=None):
229
+ """Write `data` under `key` atomically and drop the directory's other
230
+ BM25 caches and orphaned partial writes (_drop_stale_tmp); False, with
231
+ a note on stderr, when it cannot be written."""
232
+ path = cache_path(key, cache_dir)
233
+ tmp = path.with_name(f"{path.stem}.{os.getpid()}.tmp")
234
+ p = data.postings
235
+ try:
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ _drop_stale_tmp(path.parent)
238
+ with open(tmp, "wb") as fh:
239
+ np.savez(fh, key=np.array(key), terms=_joined(p.terms),
240
+ offsets=p.offsets, docs=p.docs, tfs=p.tfs,
241
+ doc_len=data.doc_len, df=data.df, df_en=data.df_en,
242
+ scope=np.array([data.en_docs, data.en_total_len], dtype=np.int64),
243
+ **{f"words_{s}": _joined(data.words[s]) for s in _SCOPES},
244
+ **{f"counts_{s}": np.fromiter(data.words[s].values(), np.int64,
245
+ len(data.words[s]))
246
+ for s in _SCOPES})
247
+ os.replace(tmp, path)
248
+ except OSError as exc:
249
+ print(f"CanLex index: BM25 cache not written ({exc}); "
250
+ f"built in process.", file=sys.stderr)
251
+ tmp.unlink(missing_ok=True)
252
+ return False
253
+ for old in path.parent.glob(f"{_CACHE_PREFIX}*.npz"):
254
+ if old != path:
255
+ try:
256
+ old.unlink()
257
+ except OSError:
258
+ pass # another process may still be reading it
259
+ return True
260
+
261
+
262
+ def _consistent(n_chunks, n_terms, offsets, docs, tfs, doc_len, df, df_en):
263
+ """Whether cached arrays can index a corpus of `n_chunks` chunks and
264
+ `n_terms` terms: integer arrays; offsets from 0, never decreasing, up to
265
+ len(docs); one tf per posting; every posting's chunk index inside the
266
+ corpus; one length per chunk; one df and one df_en per term.
267
+ Verification 2026-09-22 (SEC-3): a cache whose postings named chunks
268
+ 999999 and -5 was accepted -- an IndexError on every search, or a
269
+ silently wrong ranking (-5 is the fifth-last chunk)."""
270
+ arrays = (offsets, docs, tfs, doc_len, df, df_en)
271
+ if any(a.ndim != 1 or a.dtype.kind not in "iu" for a in arrays):
272
+ return False
273
+ if (len(offsets) != n_terms + 1 or int(offsets[0]) != 0
274
+ or int(offsets[-1]) != len(docs) or np.any(np.diff(offsets) < 0)):
275
+ return False
276
+ if len(docs) != len(tfs) or len(doc_len) != n_chunks:
277
+ return False
278
+ if docs.size and (int(docs.min()) < 0 or int(docs.max()) >= n_chunks):
279
+ return False
280
+ return len(df) == n_terms and len(df_en) == n_terms
281
+
282
+
283
+ def load(key, n_chunks, cache_dir=None):
284
+ """The cached Bm25Data for `key`, or None when there is none, it is
285
+ empty, it was written for another key or corpus size, its arrays do
286
+ not fit together (_consistent), or it cannot be read.
287
+
288
+ A damaged file raises many types inside np.load -- EOFError on zero
289
+ bytes, BadZipFile on a truncated one, NotImplementedError on a flipped
290
+ compression field, KeyError, ValueError -- and every one of them means
291
+ the same: rebuild. Verification 2026-09-22 (A-CACHE-EOF): a zero-byte
292
+ cache raised EOFError, which the except clause did not name, so
293
+ LegislationIndex() and every search tool failed on each start and
294
+ nothing ever rewrote the file."""
295
+ path = cache_path(key, cache_dir)
296
+ if not path.exists():
297
+ return None
298
+ try:
299
+ if path.stat().st_size == 0:
300
+ print("CanLex index: BM25 cache empty (an interrupted write); "
301
+ "rebuilding.", file=sys.stderr)
302
+ return None
303
+ with np.load(path, allow_pickle=False) as z:
304
+ if z["key"].item() != key or len(z["doc_len"]) != n_chunks:
305
+ return None
306
+ terms = _split(z["terms"])
307
+ offsets, docs, tfs = z["offsets"], z["docs"], z["tfs"]
308
+ doc_len, df, df_en = z["doc_len"], z["df"], z["df_en"]
309
+ words = {s: (_split(z[f"words_{s}"]), z[f"counts_{s}"].tolist())
310
+ for s in _SCOPES}
311
+ scope = z["scope"].tolist()
312
+ if (not _consistent(n_chunks, len(terms), offsets, docs, tfs, doc_len,
313
+ df, df_en)
314
+ or len(scope) != 2
315
+ or any(len(w) != len(c) for w, c in words.values())):
316
+ print("CanLex index: BM25 cache inconsistent; rebuilding.",
317
+ file=sys.stderr)
318
+ return None
319
+ en_docs, en_total = scope
320
+ return Bm25Data(Postings(terms, offsets, docs, tfs), doc_len, df, df_en,
321
+ en_docs, en_total,
322
+ {s: Counter(dict(zip(w, c))) for s, (w, c) in words.items()})
323
+ except Exception as exc: # noqa: BLE001 -- any damage means rebuild
324
+ print(f"CanLex index: BM25 cache unreadable ({type(exc).__name__}); "
325
+ f"rebuilding.", file=sys.stderr)
326
+ return None
canlex/calllog.py ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The MCP call log (2026-09-16): one record per tool call, opt-in, best-effort.
2
+
3
+ The maintainer wants to see what people ask CanLex through the MCP tools.
4
+ install() wraps the FastMCP tool manager's call_tool ONCE at startup, so every
5
+ tool call -- whichever tool, whether it returns or raises -- yields one JSON
6
+ record. No tool function is touched (the review gate fingerprints those, see
7
+ canlex/reviewed_code.py); nothing here carries legal wording or routing.
8
+
9
+ A record holds:
10
+ ts UTC, ISO 8601
11
+ tool the tool name the client called
12
+ arguments the arguments exactly as the client sent them; a string over
13
+ MAX_STRING characters is cut and marked, and its path is listed
14
+ in 'truncated'
15
+ duration_ms, ok, output_chars
16
+ error the exception's TYPE NAME only (never its message or traceback)
17
+ error_id for a tool's own unexpected exception: the reference id the
18
+ client was given and the stderr traceback carries (the error
19
+ guard, audit follow-up 2026-09-21)
20
+ n_results, confidence, top_citations
21
+ canlex_search_legislation only (server._log_query -> note())
22
+ result branch / rehab_status / status / verdict / needs, read from a
23
+ tool's trailing CANLEX_RESULT line (the screening tools)
24
+ dropped_before
25
+ how many calls went unrecorded (queue full) before this one
26
+ A record never holds an IP address, a header, a user agent, a session id or
27
+ any auth material: the wrapper sees only the tool name and its arguments, and
28
+ the record is built from nothing else.
29
+
30
+ Sinks, each turned on by the environment (nothing set = no wrapper, no thread,
31
+ no logging, no notice -- the local stdio server is unchanged):
32
+ CANLEX_LOG_DIR local JSONL, appended to CANLEX_LOG_DIR/calls.jsonl
33
+ CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN (BOTH)
34
+ durable: huggingface_hub.CommitScheduler pushes
35
+ logs/YYYY-MM-DD/<uuid>.jsonl to that dataset repo every
36
+ CANLEX_LOG_EVERY_MIN minutes (default 10), and once more
37
+ at shutdown. The dataset must be private: one that is
38
+ not is never pushed to. Half-configured, a token the Hub
39
+ refuses, or a public dataset: one stderr warning, and
40
+ serving continues.
41
+
42
+ The notice: while logging is on, the server tells MCP clients so in its MCP
43
+ `instructions` (NOTICE, a plain statement that gives no advice about what to
44
+ send) and answers GET / on the HTTP transport with the same statement.
45
+
46
+ The error guard (audit follow-up 2026-09-21, missing 8): FastMCP answers a
47
+ tool's own exception with str(exc) ('int' object has no attribute 'lower'), and
48
+ the record keeps only the type, so the maintainer saw neither message nor
49
+ stack. The wrapper now prints the traceback to stderr under a fresh uuid, notes
50
+ the uuid in the record (error_id) and answers the client with one line naming
51
+ the exception's type and the reference. An unknown tool, arguments that failed
52
+ validation (the caller's own words, which it needs to see) and a cancellation
53
+ pass through unchanged.
54
+
55
+ Best-effort throughout: the wrapper only puts a small tuple on a bounded queue
56
+ (put_nowait -- on overflow the call is dropped and counted); a background
57
+ thread builds the records and writes them. A sink that raises or hangs cannot
58
+ slow, change or break a tool response; a batch a sink refuses is retried one
59
+ record at a time, and a sink's failures are reported on stderr once per error
60
+ type and then at most once every WARN_EVERY seconds. A lone UTF-16 surrogate
61
+ in an argument (valid JSON, not UTF-8) is recorded as its backslash escape.
62
+ The token is never printed or logged.
63
+ """
64
+ import atexit
65
+ import contextvars
66
+ import datetime
67
+ import json
68
+ import os
69
+ import queue
70
+ import sys
71
+ import tempfile
72
+ import threading
73
+ import time
74
+ import traceback
75
+ import uuid
76
+ from pathlib import Path
77
+
78
+ LOG_FILE = "calls.jsonl"
79
+ MAX_STRING = 4000 # a longer argument string is cut and marked
80
+ MAX_ITEMS = 1000 # a longer argument list or object is cut and marked
81
+ ARGS_BUDGET = 64000 # characters of argument text one record keeps in all
82
+ # (the code audit of 2026-09-21: MAX_STRING and MAX_ITEMS bound each node, not the
83
+ # record -- a wide extra key of 1,000 4,000-character strings passed validation, since
84
+ # FastMCP ignores unknown top-level keys, and wrote a 4 MB line; past the budget the
85
+ # rest of the arguments is dropped and the path noted in `truncated`)
86
+ QUEUE_SIZE = 1000 # calls waiting for the writer; beyond it, dropped
87
+ PENDING_CAP = 10000 # dataset lines held while the scheduler starts
88
+ DEFAULT_EVERY_MIN = 10.0
89
+ PATH_IN_REPO = "logs"
90
+ RETRY_DELAYS = (30, 120, 600) # seconds between attempts to open the dataset
91
+ RESULT_MARK = "CANLEX_RESULT: "
92
+ RESULT_KEYS = ("branch", "rehab_status", "status", "verdict", "needs",
93
+ # step 1 ids (disposition roadmap T4.4, the review of batch 1): which
94
+ # entry answered, the footing, how the act branch stands, the wording flags
95
+ "disposition_entry", "entry", "footing", "act_branch", "question_flag",
96
+ "disposition_flags", "allegation_paragraphs")
97
+ SEARCH_KEYS = ("n_results", "confidence", "top_citations")
98
+ ERROR_KEYS = ("error_id",)
99
+ NEEDS_CAP = 10
100
+ NEED_CHARS = 500
101
+ WARN_EVERY = 3600.0 # seconds between repeats of one sink's same failure warning
102
+ NOTICE = ("Tool calls to this server, including their arguments, are logged by "
103
+ "the operator.")
104
+
105
+ _STOP = object()
106
+ _extra = contextvars.ContextVar("canlex_call_extra", default=None)
107
+
108
+
109
+ def _warn(message):
110
+ try:
111
+ print(f"CanLex call log: {message}", file=sys.stderr, flush=True)
112
+ except Exception:
113
+ pass
114
+
115
+
116
+ def note(**fields):
117
+ """Attach search fields (n_results, confidence, top_citations) or the
118
+ error guard's error_id to the call being recorded. A no-op when no call
119
+ is being recorded."""
120
+ extra = _extra.get()
121
+ if extra is not None:
122
+ extra.update((k, v) for k, v in fields.items() if k in SEARCH_KEYS + ERROR_KEYS)
123
+
124
+
125
+ def recording():
126
+ """True inside a tool call the call log is recording."""
127
+ return _extra.get() is not None
128
+
129
+
130
+ # ---- building a record (writer thread) --------------------------------------
131
+
132
+ def _iso(ts):
133
+ return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
134
+ .isoformat(timespec="milliseconds").replace("+00:00", "Z"))
135
+
136
+
137
+ def _cut(text, path, cut):
138
+ """A string for the record: cut past MAX_STRING characters (and marked),
139
+ and always encodable as UTF-8 -- a lone UTF-16 surrogate, which a client
140
+ can send as valid JSON (a string cut inside an emoji), becomes its
141
+ backslash escape, so the line it is on can be written."""
142
+ if len(text) > MAX_STRING:
143
+ cut.append(path)
144
+ text = text[:MAX_STRING] + f" [truncated: {len(text)} chars]"
145
+ if not text.isascii():
146
+ try:
147
+ text.encode("utf-8")
148
+ except UnicodeEncodeError:
149
+ text = text.encode("utf-8", "backslashreplace").decode("utf-8")
150
+ return text
151
+
152
+
153
+ def _clean(value, path, cut, depth=0, budget=None):
154
+ """A JSON-serialisable copy of an argument value: strings over MAX_STRING
155
+ characters and containers over MAX_ITEMS items are cut, their paths listed
156
+ in `cut`; anything that is not JSON is recorded as its repr. `budget` is
157
+ the characters the whole record may still keep (ARGS_BUDGET at the top):
158
+ every string, key and scalar is charged to it, and once it is spent the
159
+ rest is dropped -- a container stops where it stands and its path is
160
+ noted in `cut` once."""
161
+ if budget is None:
162
+ budget = [ARGS_BUDGET]
163
+
164
+ def spent(where):
165
+ if budget[0] <= 0:
166
+ if where not in cut:
167
+ cut.append(where)
168
+ return True
169
+ return False
170
+
171
+ def charge(text):
172
+ # the text this record keeps, cut to what the budget still allows
173
+ if len(text) > budget[0]:
174
+ kept = text[:max(budget[0], 0)] + f" [truncated: {len(text)} chars]"
175
+ budget[0] = 0
176
+ if path not in cut:
177
+ cut.append(path)
178
+ return kept
179
+ budget[0] -= len(text)
180
+ return text
181
+
182
+ if isinstance(value, str):
183
+ if spent(path):
184
+ return "[truncated: record budget]"
185
+ return charge(_cut(value, path, cut))
186
+ if value is None or isinstance(value, (bool, int)):
187
+ budget[0] -= len(str(value))
188
+ return value
189
+ if isinstance(value, float):
190
+ budget[0] -= len(repr(value))
191
+ return value if value == value and abs(value) != float("inf") else repr(value)
192
+ if depth >= 32:
193
+ cut.append(path)
194
+ return "[nested too deep]"
195
+ if isinstance(value, dict):
196
+ items = list(value.items())
197
+ if len(items) > MAX_ITEMS:
198
+ cut.append(path)
199
+ items = items[:MAX_ITEMS]
200
+ out = {}
201
+ for k, v in items:
202
+ if spent(path):
203
+ break
204
+ key = charge(_cut(str(k), f"{path}.<key>", cut))
205
+ out[key] = _clean(v, f"{path}.{key[:40]}", cut, depth + 1, budget)
206
+ return out
207
+ if isinstance(value, (list, tuple)):
208
+ items = list(value)
209
+ if len(items) > MAX_ITEMS:
210
+ cut.append(path)
211
+ items = items[:MAX_ITEMS]
212
+ out = []
213
+ for i, v in enumerate(items):
214
+ if spent(path):
215
+ break
216
+ out.append(_clean(v, f"{path}[{i}]", cut, depth + 1, budget))
217
+ return out
218
+ if spent(path):
219
+ return "[truncated: record budget]"
220
+ return charge(_cut(repr(value), path, cut))
221
+
222
+
223
+ def _output_text(result):
224
+ """The text a tool call returned: FastMCP hands back a content list, a
225
+ (content list, structured) tuple, or a CallToolResult."""
226
+ if isinstance(result, tuple) and result:
227
+ result = result[0]
228
+ content = getattr(result, "content", result)
229
+ if isinstance(content, str):
230
+ return content
231
+ if isinstance(content, (list, tuple)):
232
+ return "".join(t for t in (getattr(b, "text", None) for b in content)
233
+ if isinstance(t, str))
234
+ return ""
235
+
236
+
237
+ def _result_line(text):
238
+ """The JSON after the last CANLEX_RESULT marker, unparsed ('' if none)."""
239
+ at = text.rfind(RESULT_MARK)
240
+ if at < 0:
241
+ return ""
242
+ return text[at + len(RESULT_MARK):].split("\n", 1)[0]
243
+
244
+
245
+ def _need(item):
246
+ if isinstance(item, dict):
247
+ item = item.get("question") or item.get("id") or item.get("parameter") or item
248
+ text = item if isinstance(item, str) else json.dumps(item, ensure_ascii=False,
249
+ default=repr)
250
+ return text[:NEED_CHARS]
251
+
252
+
253
+ def _result_fields(line):
254
+ """branch / rehab_status / status / verdict / needs from a CANLEX_RESULT
255
+ line, or None when there is none or it does not parse."""
256
+ if not line:
257
+ return None
258
+ try:
259
+ data = json.loads(line)
260
+ except ValueError:
261
+ return None
262
+ if not isinstance(data, dict):
263
+ return None
264
+ out = {}
265
+ for key in RESULT_KEYS:
266
+ if key not in data:
267
+ continue
268
+ value = data[key]
269
+ if key == "needs":
270
+ value = ([_need(n) for n in value[:NEEDS_CAP]] if isinstance(value, list)
271
+ else None)
272
+ out[key] = _clean(value, key, [])
273
+ return out or None
274
+
275
+
276
+ def _error_name(exc):
277
+ """The raised exception's type name; FastMCP wraps a tool's own exception
278
+ in ToolError, so the wrapped one is named when there is one."""
279
+ cause = exc.__cause__
280
+ if type(exc).__name__ == "ToolError" and cause is not None:
281
+ exc = cause
282
+ return type(exc).__name__
283
+
284
+
285
+ def build_record(started, tool, arguments, duration_ms, error, output_chars,
286
+ result_line, extra):
287
+ """One call's record (see the module docstring for the fields)."""
288
+ cut = []
289
+ record = {
290
+ "ts": _iso(started),
291
+ "tool": _cut(str(tool), "tool", cut),
292
+ "arguments": _clean(arguments if arguments is not None else {}, "arguments", cut),
293
+ "duration_ms": round(duration_ms, 1),
294
+ "ok": error is None,
295
+ }
296
+ if error is not None:
297
+ record["error"] = _cut(str(error), "error", cut)
298
+ record["output_chars"] = output_chars
299
+ for key in SEARCH_KEYS + ERROR_KEYS:
300
+ if extra and key in extra:
301
+ record[key] = _clean(extra[key], key, cut)
302
+ fields = _result_fields(result_line)
303
+ if fields:
304
+ record["result"] = fields
305
+ if cut:
306
+ record["truncated"] = cut
307
+ return record
308
+
309
+
310
+ # ---- the queue and the writer thread ---------------------------------------
311
+
312
+ class CallLog:
313
+ """A bounded queue of tool calls and the background thread that turns
314
+ them into records and hands them to the sinks."""
315
+
316
+ def __init__(self, sinks, maxsize=QUEUE_SIZE):
317
+ self.sinks = list(sinks)
318
+ self.dropped = 0 # calls not recorded because the queue was full
319
+ self.written = 0 # records at least one sink accepted
320
+ self.unwritten = 0 # records built that no sink accepted
321
+ self._unreported = 0 # drops not yet carried on a record
322
+ self._lock = threading.Lock()
323
+ self._warned = {} # (what, error type) -> [last reported, failures since]
324
+ self._closed = False
325
+ self._queue = queue.Queue(maxsize=maxsize)
326
+ self._thread = threading.Thread(target=self._run, name="canlex-call-log",
327
+ daemon=True)
328
+ self._thread.start()
329
+
330
+ def submit(self, *call):
331
+ """Queue one call without ever blocking; False when it was dropped."""
332
+ if self._closed:
333
+ return False
334
+ try:
335
+ self._queue.put_nowait(call)
336
+ return True
337
+ except queue.Full:
338
+ with self._lock:
339
+ self.dropped += 1
340
+ self._unreported += 1
341
+ except Exception:
342
+ pass
343
+ return False
344
+
345
+ def _run(self):
346
+ while True:
347
+ batch, stop = [], False
348
+ item = self._queue.get()
349
+ while True:
350
+ if item is _STOP:
351
+ stop = True
352
+ break
353
+ batch.append(item)
354
+ if len(batch) >= 200:
355
+ break
356
+ try:
357
+ item = self._queue.get_nowait()
358
+ except queue.Empty:
359
+ break
360
+ if batch:
361
+ self._write(batch)
362
+ if stop:
363
+ return
364
+
365
+ def _write(self, batch):
366
+ records, lines = [], []
367
+ for call in batch:
368
+ try:
369
+ record = build_record(*call)
370
+ line = json.dumps(record, ensure_ascii=False)
371
+ except Exception as exc:
372
+ self._report("record", "a record could not be built", exc)
373
+ continue
374
+ with self._lock:
375
+ if self._unreported:
376
+ record["dropped_before"] = self._unreported
377
+ self._unreported = 0
378
+ line = json.dumps(record, ensure_ascii=False)
379
+ lines.append(line)
380
+ records.append(record)
381
+ if not records:
382
+ return
383
+ stored = [False] * len(records)
384
+ for sink in self.sinks:
385
+ try:
386
+ sink.write(records, lines)
387
+ stored = [True] * len(records)
388
+ continue
389
+ except Exception as exc:
390
+ self._report(id(sink), f"the {sink.name} failed to write", exc)
391
+ # one bad record (or a transient fault) must not cost the whole
392
+ # batch, other people's calls included: retry one record at a time
393
+ for i, (record, line) in enumerate(zip(records, lines)):
394
+ try:
395
+ sink.write([record], [line])
396
+ stored[i] = True
397
+ except Exception as exc:
398
+ self._report(id(sink), f"the {sink.name} failed to write", exc)
399
+ kept = sum(stored)
400
+ self.written += kept
401
+ self.unwritten += len(records) - kept
402
+
403
+ def _report(self, what, message, exc):
404
+ """A failure on stderr, by exception type only: the first of each
405
+ type at once, then at most once every WARN_EVERY seconds, with how
406
+ many went unreported in between."""
407
+ key, now = (what, type(exc).__name__), time.monotonic()
408
+ seen = self._warned.get(key)
409
+ if seen is not None and now - seen[0] < WARN_EVERY:
410
+ seen[1] += 1
411
+ return
412
+ since = seen[1] if seen is not None else 0
413
+ self._warned[key] = [now, 0]
414
+ more = f"; {since} more since the last report" if since else ""
415
+ _warn(f"{message} ({type(exc).__name__}){more}; a repeat is reported at most "
416
+ f"every {WARN_EVERY / 60:g} min")
417
+
418
+ def close(self, timeout=5.0):
419
+ """Write what is queued, then close the sinks (the dataset sink's
420
+ final push). Safe to call more than once; run at interpreter exit."""
421
+ if self._closed:
422
+ return
423
+ self._closed = True
424
+ try:
425
+ self._queue.put(_STOP, timeout=timeout)
426
+ self._thread.join(timeout)
427
+ except Exception:
428
+ pass
429
+ for sink in self.sinks:
430
+ try:
431
+ sink.close()
432
+ except Exception:
433
+ pass
434
+
435
+
436
+ # ---- sinks ------------------------------------------------------------------
437
+
438
+ class LocalSink:
439
+ """Appends each record to CANLEX_LOG_DIR/calls.jsonl."""
440
+ name = "local call log"
441
+
442
+ def __init__(self, directory):
443
+ self.path = Path(directory) / LOG_FILE
444
+
445
+ def write(self, records, lines):
446
+ self.path.parent.mkdir(parents=True, exist_ok=True)
447
+ with self.path.open("a", encoding="utf-8") as fh:
448
+ fh.write("".join(line + "\n" for line in lines))
449
+
450
+ def close(self):
451
+ pass
452
+
453
+
454
+ def _status(exc):
455
+ return getattr(getattr(exc, "response", None), "status_code", None)
456
+
457
+
458
+ def _describe(exc):
459
+ """An exception's type name and HTTP status -- never its message, which
460
+ could echo a request."""
461
+ status = _status(exc)
462
+ return type(exc).__name__ + (f", HTTP {status}" if status else "")
463
+
464
+
465
+ class DatasetSink:
466
+ """Pushes the records to a private Hugging Face dataset repo with
467
+ huggingface_hub.CommitScheduler: this process writes
468
+ <folder>/YYYY-MM-DD/<uuid>.jsonl, the scheduler uploads the folder to
469
+ logs/ every `every` minutes. The scheduler is opened on a background
470
+ thread (it reaches the Hub), so startup never waits on the network; lines
471
+ written before it is up are held in memory (up to PENDING_CAP)."""
472
+ name = "dataset call log"
473
+
474
+ def __init__(self, repo_id, token, every=DEFAULT_EVERY_MIN, folder=None):
475
+ self.repo_id = repo_id
476
+ self.every = every
477
+ self._token = token
478
+ self.file_id = uuid.uuid4().hex
479
+ self.folder = (Path(folder) if folder else
480
+ Path(tempfile.gettempdir()) / "canlex-call-log" / self.file_id)
481
+ self.scheduler = None
482
+ self.failed = False
483
+ self.ready = threading.Event() # set once the scheduler is up or has failed
484
+ self._pending = []
485
+ self._lock = threading.Lock()
486
+ self._stop = threading.Event()
487
+ self._closed = False
488
+ self._thread = None
489
+
490
+ def __repr__(self):
491
+ return f"<DatasetSink {self.repo_id}>"
492
+
493
+ def start(self):
494
+ """Open the scheduler on a background thread. Under HF_HUB_OFFLINE the
495
+ Hub client is replaced first, here, before the server takes requests
496
+ (see allow_dataset_requests), so a model load can never be using the
497
+ client that set_client_factory closes."""
498
+ if self._thread is not None:
499
+ return
500
+ try:
501
+ from huggingface_hub import constants
502
+ if constants.is_offline_mode():
503
+ allow_dataset_requests(self.repo_id)
504
+ except Exception:
505
+ pass # the scheduler's first request then fails and says so
506
+ self._thread = threading.Thread(target=self._open, name="canlex-call-log-hub",
507
+ daemon=True)
508
+ self._thread.start()
509
+
510
+ def _open(self):
511
+ last, public = None, False
512
+ for delay in (0,) + tuple(RETRY_DELAYS):
513
+ if delay and self._stop.wait(delay):
514
+ break
515
+ scheduler = None
516
+ try:
517
+ from huggingface_hub import CommitScheduler
518
+ scheduler = CommitScheduler(
519
+ repo_id=self.repo_id, repo_type="dataset", folder_path=self.folder,
520
+ path_in_repo=PATH_IN_REPO, every=self.every, private=True,
521
+ token=self._token, allow_patterns=["*.jsonl"])
522
+ # private=True only applies when the scheduler CREATES the repo;
523
+ # an existing public dataset would publish every record. Checked
524
+ # before any record reaches the scheduler's folder.
525
+ info = scheduler.api.repo_info(self.repo_id, repo_type="dataset")
526
+ public = getattr(info, "private", None) is not True
527
+ except Exception as exc:
528
+ self._halt(scheduler)
529
+ last = exc
530
+ status = _status(exc)
531
+ retry = (not isinstance(exc, (ImportError, ValueError, TypeError))
532
+ and (status is None or status == 429 or status >= 500))
533
+ if not retry:
534
+ break
535
+ continue
536
+ if public:
537
+ self._halt(scheduler)
538
+ break
539
+ with self._lock:
540
+ self.scheduler = scheduler
541
+ pending, self._pending = self._pending, []
542
+ self._append(pending)
543
+ _warn(f"the dataset call log is open: {self.repo_id!r} (private); records "
544
+ f"are pushed every {self.every:g} min")
545
+ self.ready.set()
546
+ return
547
+ self.failed = True
548
+ with self._lock:
549
+ self._pending = []
550
+ if public:
551
+ _warn(f"the durable call log is off: the dataset {self.repo_id!r} is not "
552
+ "private, so nothing is pushed to it -- make it private in its "
553
+ "settings and restart; tool calls are still served")
554
+ else:
555
+ hint = (" -- check CANLEX_LOG_TOKEN (a fine-grained token with read and write "
556
+ "access to that dataset) and CANLEX_LOG_DATASET") if last is not None else ""
557
+ _warn(f"the durable call log is off: could not open the dataset "
558
+ f"{self.repo_id!r} ({_describe(last) if last else 'stopped'}){hint}; "
559
+ "tool calls are still served")
560
+ self.ready.set()
561
+
562
+ @staticmethod
563
+ def _halt(scheduler):
564
+ """Stop a scheduler that must not push (its folder is still empty)."""
565
+ if scheduler is None:
566
+ return
567
+ try:
568
+ scheduler.stop()
569
+ except Exception:
570
+ pass
571
+
572
+ def write(self, records, lines):
573
+ if self.failed or self._closed:
574
+ return
575
+ items = [(r["ts"][:10], line) for r, line in zip(records, lines)]
576
+ with self._lock:
577
+ if self.scheduler is None:
578
+ room = max(PENDING_CAP - len(self._pending), 0)
579
+ self._pending.extend(items[:room])
580
+ return
581
+ self._append(items)
582
+
583
+ def _append(self, items):
584
+ if not items:
585
+ return
586
+ by_day = {}
587
+ for day, line in items:
588
+ by_day.setdefault(day, []).append(line)
589
+ with self.scheduler.lock:
590
+ for day, day_lines in by_day.items():
591
+ path = self.folder / day / f"{self.file_id}.jsonl"
592
+ path.parent.mkdir(parents=True, exist_ok=True)
593
+ with path.open("a", encoding="utf-8") as fh:
594
+ fh.write("".join(line + "\n" for line in day_lines))
595
+
596
+ def close(self):
597
+ """The final push at shutdown (best-effort)."""
598
+ if self._closed:
599
+ return
600
+ self._closed = True
601
+ self._stop.set()
602
+ scheduler = self.scheduler
603
+ if scheduler is None:
604
+ return
605
+ try:
606
+ scheduler.push_to_hub()
607
+ except Exception as exc:
608
+ _warn(f"the final push to {self.repo_id!r} failed ({_describe(exc)})")
609
+ try:
610
+ scheduler.stop()
611
+ except Exception:
612
+ pass
613
+
614
+
615
+ def allow_dataset_requests(repo_id):
616
+ """Let the call log reach its dataset under HF_HUB_OFFLINE=1.
617
+
618
+ The Space's Dockerfile sets HF_HUB_OFFLINE=1 so the embedder and reranker
619
+ are only ever served from the model cache baked into the image. That flag
620
+ blocks EVERY huggingface_hub request, the scheduler's pushes included, in
621
+ the library's request hook. This installs a client (huggingface_hub's
622
+ public set_client_factory; otherwise the library default) whose hook
623
+ still refuses every Hub request EXCEPT repo creation and requests for
624
+ this dataset repo. Requests to other hosts pass: they are storage URLs a
625
+ Hub response handed out, and a model download cannot get one, because its
626
+ first request (to the Hub's /resolve/ path) is still refused."""
627
+ import httpx
628
+ from huggingface_hub import constants, set_client_factory
629
+ from huggingface_hub.errors import OfflineModeIsEnabled
630
+
631
+ hub_host = httpx.URL(constants.ENDPOINT).host
632
+ repo = repo_id.strip("/").lower()
633
+ exact = {f"/api/datasets/{repo}", "/api/repos/create"}
634
+ prefixes = (f"/api/datasets/{repo}/", f"/datasets/{repo}/", f"/datasets/{repo}.git/")
635
+
636
+ def allowed(url):
637
+ if url.host != hub_host:
638
+ return True
639
+ path = url.path.lower()
640
+ return path in exact or path.startswith(prefixes)
641
+
642
+ def hook(request):
643
+ if constants.is_offline_mode() and not allowed(request.url):
644
+ raise OfflineModeIsEnabled(
645
+ f"Cannot reach {request.url}: offline mode is enabled. To disable it, "
646
+ "please unset the `HF_HUB_OFFLINE` environment variable.")
647
+ if "X-Amzn-Trace-Id" not in request.headers:
648
+ request.headers["X-Amzn-Trace-Id"] = (request.headers.get("x-request-id")
649
+ or str(uuid.uuid4()))
650
+
651
+ def factory():
652
+ return httpx.Client(event_hooks={"request": [hook]}, follow_redirects=True,
653
+ timeout=None)
654
+
655
+ set_client_factory(factory)
656
+ return factory
657
+
658
+
659
+ # ---- configuration and the wrapper -----------------------------------------
660
+
661
+ def _every(env):
662
+ raw = (env.get("CANLEX_LOG_EVERY_MIN") or "").strip()
663
+ if not raw:
664
+ return DEFAULT_EVERY_MIN
665
+ try:
666
+ value = float(raw)
667
+ if value > 0 and value == value and value != float("inf"):
668
+ return value
669
+ except ValueError:
670
+ pass
671
+ _warn(f"CANLEX_LOG_EVERY_MIN={raw!r} is not a positive number of minutes; "
672
+ f"using {DEFAULT_EVERY_MIN:g}")
673
+ return DEFAULT_EVERY_MIN
674
+
675
+
676
+ def configure(environ=None, dataset_folder=None):
677
+ """The sinks the environment turns on ([] = logging off). A dataset sink
678
+ needs BOTH CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN; one without the other
679
+ gets one stderr warning (which never includes the token)."""
680
+ env = os.environ if environ is None else environ
681
+ sinks = []
682
+ log_dir = (env.get("CANLEX_LOG_DIR") or "").strip()
683
+ if log_dir:
684
+ sinks.append(LocalSink(log_dir))
685
+ dataset = (env.get("CANLEX_LOG_DATASET") or "").strip()
686
+ token = (env.get("CANLEX_LOG_TOKEN") or "").strip()
687
+ if dataset and token:
688
+ sinks.append(DatasetSink(dataset, token, _every(env), folder=dataset_folder))
689
+ elif dataset:
690
+ _warn("CANLEX_LOG_DATASET is set but CANLEX_LOG_TOKEN is not: the durable call "
691
+ "log is off; tool calls are still served")
692
+ elif token:
693
+ _warn("CANLEX_LOG_TOKEN is set but CANLEX_LOG_DATASET is not: the durable call "
694
+ "log is off; tool calls are still served")
695
+ return sinks
696
+
697
+
698
+ def _guard(name, exc):
699
+ """The ToolError the client gets instead of `exc`, or None to re-raise
700
+ `exc` unchanged (the error guard, audit follow-up 2026-09-21). Only a
701
+ tool's own exception is guarded -- FastMCP's ToolError whose cause is an
702
+ Exception other than a pydantic ValidationError: its traceback goes to
703
+ stderr under a fresh uuid, the uuid goes into the record (note), and the
704
+ client reads 'Error: CanLex could not complete this call (<type>);
705
+ reference <uuid>'. Never raises itself."""
706
+ try:
707
+ from mcp.server.fastmcp.exceptions import ToolError
708
+ from pydantic import ValidationError
709
+ cause = exc.__cause__
710
+ if (not isinstance(exc, ToolError) or not isinstance(cause, Exception)
711
+ or isinstance(cause, ValidationError)):
712
+ return None
713
+ ref = str(uuid.uuid4())
714
+ stack = "".join(traceback.format_exception(type(cause), cause, cause.__traceback__))
715
+ print(f"CanLex MCP: tool {_cut(str(name), 'tool', [])} failed; reference {ref}\n"
716
+ f"{stack}", file=sys.stderr, flush=True)
717
+ note(error_id=ref)
718
+ return ToolError(f"Error: CanLex could not complete this call "
719
+ f"({type(cause).__name__}); reference {ref}")
720
+ except Exception:
721
+ return None
722
+
723
+
724
+ def wrap(manager, log):
725
+ """Wrap a FastMCP ToolManager's call_tool (mcp 1.27.1: FastMCP.call_tool ->
726
+ ToolManager.call_tool(name, arguments, context=, convert_result=) ->
727
+ Tool.run) so every call is submitted to `log`, and a tool's own exception
728
+ reaches the client as a reference id (_guard). Once per manager."""
729
+ if getattr(manager, "_canlex_call_log", None) is not None:
730
+ return manager
731
+ original = manager.call_tool
732
+
733
+ async def call_tool(name, arguments, context=None, convert_result=False):
734
+ started, clock = time.time(), time.perf_counter()
735
+ extra = {}
736
+ context_token = _extra.set(extra)
737
+ error, result = None, None
738
+ try:
739
+ result = await original(name, arguments, context=context,
740
+ convert_result=convert_result)
741
+ return result
742
+ except Exception as exc:
743
+ error = exc
744
+ guarded = _guard(name, exc)
745
+ if guarded is None:
746
+ raise
747
+ raise guarded from exc
748
+ except BaseException as exc:
749
+ error = exc
750
+ raise
751
+ finally:
752
+ try:
753
+ _extra.reset(context_token)
754
+ except Exception:
755
+ pass
756
+ try:
757
+ duration = (time.perf_counter() - clock) * 1000
758
+ if error is None:
759
+ text = _output_text(result)
760
+ log.submit(started, name, arguments, duration, None, len(text),
761
+ _result_line(text), extra)
762
+ else:
763
+ log.submit(started, name, arguments, duration, _error_name(error), 0,
764
+ "", extra)
765
+ except Exception:
766
+ pass
767
+
768
+ manager.call_tool = call_tool
769
+ manager._canlex_call_log = log
770
+ return manager
771
+
772
+
773
+ def announce(server):
774
+ """Tell the server's users that calls are logged: NOTICE in the MCP
775
+ `instructions` every client receives at initialize (after any existing
776
+ instructions), and a plain-text GET / on the HTTP transport, where the
777
+ Space page's frame otherwise shows a 404. A statement of fact only -- it
778
+ gives no advice about which arguments to send (see RUNBOOK.md, "Call
779
+ log"). Best-effort: a failure is one stderr line."""
780
+ try:
781
+ low = server._mcp_server
782
+ current = low.instructions
783
+ if not current:
784
+ low.instructions = NOTICE
785
+ elif NOTICE not in current:
786
+ low.instructions = f"{current}\n\n{NOTICE}"
787
+ except Exception as exc:
788
+ _warn(f"the notice could not be added to the MCP instructions "
789
+ f"({type(exc).__name__})")
790
+ try:
791
+ if any(getattr(r, "path", None) == "/" for r in server._custom_starlette_routes):
792
+ return
793
+ from starlette.responses import PlainTextResponse
794
+ endpoint = getattr(getattr(server, "settings", None), "streamable_http_path", "/mcp")
795
+ page = (f"CanLex MCP server: connect an MCP client to {endpoint} on this "
796
+ f"host.\n\n{NOTICE}\n")
797
+
798
+ async def root(request):
799
+ return PlainTextResponse(page)
800
+
801
+ server.custom_route("/", methods=["GET"])(root)
802
+ except Exception as exc:
803
+ _warn(f"the notice page at / could not be added ({type(exc).__name__})")
804
+
805
+
806
+ def install(server, environ=None, dataset_folder=None):
807
+ """Turn the call log on for a FastMCP server when the environment asks
808
+ for it: wrap its tool manager, publish the notice (announce), start the
809
+ sinks, and write what is queued (and push) at interpreter exit. Returns
810
+ the CallLog, or None when nothing is configured -- then nothing is
811
+ wrapped, no notice is added and no thread starts."""
812
+ existing = getattr(server._tool_manager, "_canlex_call_log", None)
813
+ if existing is not None:
814
+ return existing
815
+ sinks = configure(environ, dataset_folder=dataset_folder)
816
+ if not sinks:
817
+ return None
818
+ log = CallLog(sinks)
819
+ wrap(server._tool_manager, log)
820
+ announce(server)
821
+ atexit.register(log.close)
822
+ # printed before any sink starts, so it always comes before the dataset
823
+ # sink's own "is open" or "is off" line
824
+ names = ", ".join(s.name + (f" ({s.repo_id}, every {s.every:g} min; opening in "
825
+ "the background)"
826
+ if isinstance(s, DatasetSink) else f" ({s.path})")
827
+ for s in sinks)
828
+ _warn(f"recording every tool call to: {names}")
829
+ for sink in sinks:
830
+ start = getattr(sink, "start", None)
831
+ if start is not None:
832
+ start()
833
+ return log
canlex/caselaw.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/charter.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the Charter and border-relevant Constitution Act, 1867 provisions.
2
+
3
+ The Constitution has no Justice Laws XML, so the corpus long had a hole a
4
+ large fraction of its own case law turns on: Simmons, Canfield, Pike and the
5
+ detention line all apply Charter ss. 7-10 and 24, yet "what does s. 10(b)
6
+ actually say?" returned nothing. Justice Laws publishes the Constitution Acts
7
+ as stable public HTML (laws-lois.justice.gc.ca/eng/const/FullText.html) with
8
+ a regular MarginalNote/Section markup, parsed here into legislation-shaped
9
+ chunks. Scope: the whole Charter (1982 ss. 1-34), Aboriginal rights s. 35,
10
+ the supremacy clause s. 52, and the 1867 heads-of-power ss. 91-92.
11
+
12
+ python -m canlex.charter
13
+ """
14
+ import re
15
+ import sys
16
+
17
+ from ._common import fetch_cached, norm_ws as _norm, write_corpus
18
+ from .config import PROCESSED_DIR, RAW_DIR
19
+
20
+ URL = "https://laws-lois.justice.gc.ca/eng/const/FullText.html"
21
+ RAW = RAW_DIR / "charter"
22
+ OUT_FILE = PROCESSED_DIR / "charter.json"
23
+
24
+ # (act filter, sections wanted). The 1982 Act's Charter is ss. 1-34; s. 35 is
25
+ # Part II (Aboriginal rights) and s. 52 the supremacy clause -- both cited
26
+ # constantly alongside Charter arguments. From 1867, only the ss. 91-92
27
+ # division of powers earns its place in a border-law corpus.
28
+ _WANT_1982 = {str(n) for n in range(1, 36)} | {"52"}
29
+ _WANT_1867 = {"91", "92"}
30
+
31
+ _MN = re.compile(
32
+ r'<p class="MarginalNote">(?:<span[^>]*>.*?</span>)?(.*?)</p>', re.S)
33
+ _SEC_ID = re.compile(r'<span class="sectionLabel">([0-9.]+)</span>')
34
+
35
+
36
+ def _strip(html):
37
+ """Readable text from a fragment: block ends become newlines, tags go."""
38
+ html = re.sub(r"</(?:p|li|h\d)>", "\n", html)
39
+ html = re.sub(r"<[^>]+>", " ", html)
40
+ return "\n".join(p for p in (_norm(line) for line in html.split("\n")) if p)
41
+
42
+
43
+ def _sections(segment_html):
44
+ """Yield (number, marginal_note, text) for each section in a segment."""
45
+ notes = list(_MN.finditer(segment_html))
46
+ for i, m in enumerate(notes):
47
+ end = notes[i + 1].start() if i + 1 < len(notes) else len(segment_html)
48
+ body_html = segment_html[m.end():end]
49
+ num_m = _SEC_ID.search(body_html)
50
+ if not num_m:
51
+ continue
52
+ yield num_m.group(1), _norm(_strip(m.group(1))), _strip(body_html)
53
+
54
+
55
+ def parse(html):
56
+ """Chunks for every in-scope section of both Acts. Pure, for tests.
57
+
58
+ Justice Laws could re-shape this page the way CBSA re-shaped its D-memo
59
+ index, in which case the MarginalNote/Section markup stops matching and
60
+ this returns []; the write guard in build() is what keeps that from
61
+ emptying the corpus.
62
+ """
63
+ # The page is both Acts in sequence; split at the 1982 Act's title so
64
+ # section numbers (which restart) land in the right instrument.
65
+ cut = html.find("CONSTITUTION ACT, 1982")
66
+ if cut < 0:
67
+ raise ValueError("could not locate the Constitution Act, 1982 heading")
68
+ parts = [
69
+ ("CONST-1867", "Constitution Act, 1867", "Constitution Act, 1867",
70
+ html[:cut], _WANT_1867),
71
+ ("CONST-1982", "Charter", "Constitution Act, 1982 (incl. the Canadian "
72
+ "Charter of Rights and Freedoms)", html[cut:], _WANT_1982),
73
+ ]
74
+ chunks = []
75
+ for code, short, name, segment, want in parts:
76
+ for num, note, text in _sections(segment):
77
+ if num not in want or not text:
78
+ continue
79
+ label = f"s. {num}"
80
+ chunks.append({
81
+ "id": f"{code}-s{num}",
82
+ "doc_type": "legislation",
83
+ "act_code": code,
84
+ "act_short": short,
85
+ "act_name": name,
86
+ "section": num,
87
+ "marginal_note": note,
88
+ "part": "",
89
+ "division": "",
90
+ "heading": "",
91
+ "text": text,
92
+ "history": "",
93
+ "last_amended": "",
94
+ "in_force": "",
95
+ "status": "in force",
96
+ "current_to": "",
97
+ "citation": f"{short}, {label}",
98
+ "source_url": URL,
99
+ })
100
+ return chunks
101
+
102
+
103
+ def build(allow_shrink=False, force=False):
104
+ html = fetch_cached(URL, RAW / "FullText.html",
105
+ force=force).decode("utf-8", "replace")
106
+ chunks = parse(html)
107
+ # indent=2 is what charter.json already carries -- changing it would
108
+ # rewrite every line of the file for nothing.
109
+ if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
110
+ return False
111
+ print(f"charter: {len(chunks)} sections -> {OUT_FILE.name}")
112
+ return True
113
+
114
+
115
+ def main():
116
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
117
+ force="--force" in sys.argv) else 1)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
canlex/citator.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CanLII case citator -- live lookup of a Canadian case's citation graph.
2
+
3
+ Uses the CanLII API (key in canlii_key.txt). The API has no name/topic search,
4
+ so a case is identified by its full canlii.org URL. Responses are cached on disk
5
+ and calls are throttled, because the API rate-limits aggressively.
6
+
7
+ Build-time only (roadmap T5.5): `python -m canlex.citator --snapshot-authorities`
8
+ records, for each curated authority (data/curated/authorities.json), how many
9
+ decisions cite it on CanLII and the three most recent FC/FCA/SCC ones. It
10
+ reads the key only when the maintainer runs it, and writes
11
+ data/citator_authorities.json, which .gitignore keeps out of the repo and the
12
+ Space. See snapshot_authorities.
13
+ """
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from datetime import date
22
+ from pathlib import Path
23
+
24
+ from .config import ROOT, DATA_DIR
25
+
26
+ API = "https://api.canlii.org/v1"
27
+ KEY_FILE = ROOT / "canlii_key.txt"
28
+ _PLACEHOLDER = "PASTE-YOUR-CANLII-API-KEY-ON-THIS-LINE"
29
+ _DBMAP_FILE = DATA_DIR / "citator_dbmap.json"
30
+ _CACHE_FILE = DATA_DIR / "citator_cache.json"
31
+ _THROTTLE = 3.0 # seconds between CanLII API calls (the API rate-limits hard)
32
+ _MAX_LIST = 20 # items shown per citator list (lists can run to thousands)
33
+ _CASE_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/doc/\d+/([a-z0-9-]+)")
34
+ _DB_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/?$")
35
+
36
+ # A neutral citation, e.g. "2019 SCC 65", and the CanLII URL segment per
37
+ # court/tribunal. The tribunal entries cover the boards whose decisions the
38
+ # corpus itself carries, so the natural loop (search result -> canlex_case)
39
+ # works for every citation a result can display.
40
+ # 'FCT' is the Federal Court's neutral citation for 2001-2003 ('2002 FCT
41
+ # 123'); CanLII files those decisions under 'fct' with that document id.
42
+ _NEUTRAL = re.compile(
43
+ r"\b(\d{4})\s+(SCC|FCA|FCT|FC|FPSLREB|CIRB|ONCA|ABCA)\s+(\d+)\b",
44
+ re.IGNORECASE)
45
+ _CANLII_SEG = {"scc": "scc", "fca": "fca", "fc": "fct", "fct": "fct",
46
+ "fpslreb": "pslreb", "cirb": "cirb",
47
+ "onca": "onca", "abca": "abca"}
48
+ _PROVINCE_SEG = {"onca": "on", "abca": "ab"} # others are federal ('ca')
49
+
50
+
51
+ def canlii_url_from_citation(text):
52
+ """Build a canlii.org case URL from a neutral citation, or '' if none found.
53
+
54
+ Works for the Supreme Court, Federal Court of Appeal, Federal Court, the
55
+ FPSLREB and CIRB labour boards, and the Ontario and Alberta Courts of
56
+ Appeal -- e.g. "2019 SCC 65" -> .../en/ca/scc/doc/2019/2019scc65/...
57
+ """
58
+ m = _NEUTRAL.search(text)
59
+ if not m:
60
+ return ""
61
+ year, court, num = m.group(1), m.group(2).lower(), m.group(3)
62
+ doc = f"{year}{court}{num}"
63
+ juris = _PROVINCE_SEG.get(court, "ca")
64
+ return (f"https://www.canlii.org/en/{juris}/{_CANLII_SEG[court]}/doc/"
65
+ f"{year}/{doc}/{doc}.html")
66
+
67
+
68
+ def api_key():
69
+ """Return the configured CanLII API key, or '' if not set.
70
+
71
+ The CANLII_API_KEY environment variable is checked first -- the remote
72
+ deployment injects the key as a secret rather than shipping the file.
73
+ A local run falls back to canlii_key.txt.
74
+ """
75
+ env_key = os.environ.get("CANLII_API_KEY", "").strip()
76
+ if env_key and env_key != _PLACEHOLDER:
77
+ return env_key
78
+ if not KEY_FILE.exists():
79
+ return ""
80
+ key = KEY_FILE.read_text(encoding="utf-8").strip()
81
+ return "" if not key or key == _PLACEHOLDER else key
82
+
83
+
84
+ def _load_json(path):
85
+ try:
86
+ return json.loads(path.read_text(encoding="utf-8"))
87
+ except Exception:
88
+ return {}
89
+
90
+
91
+ class Citator:
92
+ """Live CanLII citator with on-disk caching and rate-limit throttling."""
93
+
94
+ def __init__(self):
95
+ self.key = api_key()
96
+ if not self.key:
97
+ raise RuntimeError(f"No CanLII API key -- put your key in {KEY_FILE}.")
98
+ self._dbmap = _load_json(_DBMAP_FILE) # URL court segment -> databaseId
99
+ self._cache = _load_json(_CACHE_FILE) # case URL -> report
100
+
101
+ def _get(self, path):
102
+ """Throttled GET against the CanLII API, retrying on HTTP 429."""
103
+ for attempt in range(3):
104
+ time.sleep(_THROTTLE if attempt == 0 else 15.0)
105
+ sep = "&" if "?" in path else "?"
106
+ url = f"{API}/{path}{sep}api_key={self.key}"
107
+ try:
108
+ with urllib.request.urlopen(url, timeout=45) as resp:
109
+ return json.loads(resp.read().decode("utf-8"))
110
+ except urllib.error.HTTPError as exc:
111
+ if exc.code == 429 and attempt < 2:
112
+ continue
113
+ raise
114
+ raise RuntimeError("CanLII API rate limit reached; retry shortly.")
115
+
116
+ def _ensure_dbmap(self):
117
+ if self._dbmap:
118
+ return
119
+ data = self._get("caseBrowse/en/")
120
+ dbmap = {}
121
+ for db in data.get("caseDatabases", []):
122
+ match = _DB_URL.search(db.get("url") or "")
123
+ if match and db.get("databaseId"):
124
+ dbmap[match.group(1)] = db["databaseId"]
125
+ self._dbmap = dbmap
126
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
127
+ _DBMAP_FILE.write_text(json.dumps(dbmap), encoding="utf-8")
128
+
129
+ def _citator(self, db, case_id, kind):
130
+ data = self._get(f"caseCitator/en/{db}/{case_id}/{kind}")
131
+ items = data.get(kind, [])
132
+ return {"total": len(items), "items": items[:_MAX_LIST]}
133
+
134
+ def case_report(self, case_url):
135
+ """Return a citation-graph report for a case.
136
+
137
+ Accepts a full canlii.org case URL, or a neutral citation (e.g.
138
+ "2019 SCC 65") for a Supreme Court / Federal Court of Appeal / Federal
139
+ Court decision.
140
+ """
141
+ if not _CASE_URL.search(case_url):
142
+ case_url = canlii_url_from_citation(case_url) or case_url
143
+ if case_url in self._cache:
144
+ return self._cache[case_url]
145
+ match = _CASE_URL.search(case_url)
146
+ if not match:
147
+ return {"error": "Provide a full canlii.org case URL, or a neutral "
148
+ "citation such as '2019 SCC 65' (Supreme Court, Federal "
149
+ "Court of Appeal, or Federal Court)."}
150
+ self._ensure_dbmap()
151
+ segment, case_id = match.group(1), match.group(2)
152
+ db = self._dbmap.get(segment)
153
+ if not db:
154
+ return {"error": f"Unrecognized CanLII court segment '{segment}'."}
155
+ report = {
156
+ "meta": self._get(f"caseBrowse/en/{db}/{case_id}/"),
157
+ "citingCases": self._citator(db, case_id, "citingCases"),
158
+ "citedCases": self._citator(db, case_id, "citedCases"),
159
+ "citedLegislations": self._citator(db, case_id, "citedLegislations"),
160
+ }
161
+ self._cache[case_url] = report
162
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
163
+ _CACHE_FILE.write_text(json.dumps(self._cache, ensure_ascii=False),
164
+ encoding="utf-8")
165
+ return report
166
+
167
+
168
+ def citing_cases(self, case_url):
169
+ """Every CanLII citingCases item for a canlii.org case URL, uncapped
170
+ (case_report keeps _MAX_LIST), or None when the URL is not a case
171
+ URL or its court is not in the database map. Not cached."""
172
+ match = _CASE_URL.search(case_url or "")
173
+ if not match:
174
+ return None
175
+ self._ensure_dbmap()
176
+ db = self._dbmap.get(match.group(1))
177
+ if not db:
178
+ return None
179
+ data = self._get(f"caseCitator/en/{db}/{match.group(2)}/citingCases")
180
+ return data.get("citingCases", [])
181
+
182
+
183
+ # --- Build-time authority snapshot (roadmap T5.5) ----------------------------
184
+ # canlex_case needs a key the Space may not have. So the maintainer runs,
185
+ # locally and with his own key,
186
+ # python -m canlex.citator --snapshot-authorities
187
+ # which records, for every curated authority with a CanLII case URL, how
188
+ # many decisions cite it on CanLII and the three most recent FC/FCA/SCC
189
+ # ones. The file is data/citator_authorities.json, which .gitignore keeps
190
+ # out of the repo and the Space: whether derived CanLII API data may ship
191
+ # is the maintainer's open question. A citation count is not a treatment
192
+ # check -- it cannot say whether a later court followed, distinguished or
193
+ # reversed the authority, and it would not have caught the reversal of the
194
+ # trial-level Saini decision -- so the registry's manual subsequent_history
195
+ # stays the only 'reversed' signal.
196
+ SNAPSHOT_FILE = DATA_DIR / "citator_authorities.json"
197
+ SNAPSHOT_AGING_DAYS = 180
198
+ _APEX_COURTS = ("SCC", "FCA", "FC", "FCT")
199
+ _REGISTRY_FILE = DATA_DIR / "curated" / "authorities.json"
200
+
201
+
202
+ def authority_case_url(row):
203
+ """The CanLII case URL for an authority-registry row: its source_url
204
+ when that is a canlii.org case URL, else one built from a neutral
205
+ citation in its citation, decision key or cites; '' when it has none
206
+ (pre-2001 reports held on Lexum, secondary-only rows)."""
207
+ url = row.get("source_url") or ""
208
+ if _CASE_URL.search(url):
209
+ return url
210
+ texts = [row.get("citation"), (row.get("decision_key") or {}).get("act_code")]
211
+ for text in texts + list(row.get("cites") or []):
212
+ built = canlii_url_from_citation(text or "")
213
+ if built:
214
+ return built
215
+ return ""
216
+
217
+
218
+ def _neutral_order(citation):
219
+ m = _NEUTRAL.search(citation or "")
220
+ return (int(m.group(1)), int(m.group(3))) if m else (0, 0)
221
+
222
+
223
+ def apex_citing(items):
224
+ """The FC, FCA and SCC decisions among CanLII citingCases items, by
225
+ their neutral citation, most recent first (year, then number)."""
226
+ out = []
227
+ for item in items or []:
228
+ cite = item.get("citation") or ""
229
+ m = _NEUTRAL.search(cite)
230
+ if m and m.group(2).upper() in _APEX_COURTS:
231
+ out.append({"title": item.get("title", ""), "citation": cite})
232
+ out.sort(key=lambda r: _neutral_order(r["citation"]), reverse=True)
233
+ return out
234
+
235
+
236
+ def snapshot_authorities(citer, registry=None, today=None, path=SNAPSHOT_FILE,
237
+ write=True):
238
+ """Build the authority snapshot with `citer` (a Citator, or anything with
239
+ citing_cases(url)) and write it to `path` unless write=False. Each row
240
+ that resolves: {url, as_of, citing_total, citing_apex, recent_apex (the
241
+ three most recent FC/FCA/SCC citing decisions)}. A row with no CanLII
242
+ URL, or whose lookup fails, lands in 'skipped' with the reason -- an
243
+ error by its type and HTTP code only, never its text, because the
244
+ request URL carries the API key. A run in which no row resolved (every
245
+ lookup failed: a rate limit, the network) is not written, so it never
246
+ overwrites a good earlier snapshot with an all-skipped one."""
247
+ if registry is None:
248
+ registry = json.loads(_REGISTRY_FILE.read_text(encoding="utf-8"))
249
+ today = today or date.today().isoformat()
250
+ out = {"as_of": today,
251
+ "source": "CanLII API caseCitator citingCases (python -m canlex.citator "
252
+ "--snapshot-authorities)",
253
+ "note": "a citation count, not a treatment check; the registry's "
254
+ "subsequent_history is the only 'reversed' signal",
255
+ "authorities": {}, "skipped": {}}
256
+ for key, row in sorted((registry.get("authorities") or {}).items()):
257
+ url = authority_case_url(row)
258
+ if not url:
259
+ out["skipped"][key] = ("no CanLII case URL (no canlii.org source_url "
260
+ "and no neutral citation)")
261
+ continue
262
+ try:
263
+ items = citer.citing_cases(url)
264
+ except Exception as exc: # network or API failure: keep going
265
+ code = getattr(exc, "code", "")
266
+ out["skipped"][key] = f"lookup failed: {type(exc).__name__} {code}".strip()
267
+ continue
268
+ if items is None:
269
+ out["skipped"][key] = "court not in the CanLII database map"
270
+ continue
271
+ apex = apex_citing(items)
272
+ out["authorities"][key] = {"url": url, "as_of": today,
273
+ "citing_total": len(items),
274
+ "citing_apex": len(apex),
275
+ "recent_apex": apex[:3]}
276
+ if write and out["authorities"]:
277
+ Path(path).write_text(json.dumps(out, indent=1, ensure_ascii=False),
278
+ encoding="utf-8")
279
+ return out
280
+
281
+
282
+ def load_snapshot(path=SNAPSHOT_FILE):
283
+ """The snapshot, or None where it was never built (every deployment:
284
+ the file is gitignored)."""
285
+ try:
286
+ return json.loads(Path(path).read_text(encoding="utf-8"))
287
+ except (OSError, ValueError):
288
+ return None
289
+
290
+
291
+ def snapshot_line(entry, today=None):
292
+ """One authority's snapshot as the officer-facing line:
293
+ 'Cited by N later FC/FCA/SCC decisions on CanLII as of <date>; this is
294
+ not a treatment check', the most recent of them, and AGING once the
295
+ snapshot is older than SNAPSHOT_AGING_DAYS."""
296
+ line = (f"Cited by {entry['citing_apex']} later FC/FCA/SCC decisions on "
297
+ f"CanLII as of {entry['as_of']}; this is not a treatment check")
298
+ recent = "; ".join(r["citation"] for r in entry.get("recent_apex") or [])
299
+ if recent:
300
+ line += f" (most recent: {recent})"
301
+ age = (date.fromisoformat(today or date.today().isoformat())
302
+ - date.fromisoformat(entry["as_of"])).days
303
+ if age > SNAPSHOT_AGING_DAYS:
304
+ line += (f" -- AGING: the snapshot is {age} days old; re-run "
305
+ f"python -m canlex.citator --snapshot-authorities")
306
+ return line + "."
307
+
308
+
309
+ def main():
310
+ if sys.argv[1:2] == ["--snapshot-authorities"]:
311
+ snap = snapshot_authorities(Citator())
312
+ for key, entry in snap["authorities"].items():
313
+ print(f"{key}: {snapshot_line(entry)}")
314
+ for key, why in snap["skipped"].items():
315
+ print(f"{key}: skipped -- {why}")
316
+ if snap["authorities"]:
317
+ print(f"-> {SNAPSHOT_FILE} (gitignored; not shipped)")
318
+ else:
319
+ print(f"no authority resolved -- nothing written; {SNAPSHOT_FILE} "
320
+ f"(if any) is kept")
321
+ return
322
+ if len(sys.argv) < 2:
323
+ print('usage: python -m canlex.citator "<canlii-case-url>"\n'
324
+ ' python -m canlex.citator --snapshot-authorities')
325
+ return
326
+ report = Citator().case_report(sys.argv[1])
327
+ if "error" in report:
328
+ print("ERROR:", report["error"])
329
+ return
330
+ meta = report["meta"]
331
+ print(f"{meta.get('title')} -- {meta.get('citation')} ({meta.get('decisionDate')})")
332
+ print(f" cited by: {report['citingCases']['total']}")
333
+ print(f" cites: {report['citedCases']['total']}")
334
+ print(f" legislation cited: {report['citedLegislations']['total']}")
335
+ for item in report["citedLegislations"]["items"][:6]:
336
+ print(f" - {item.get('title')} ({item.get('citation')})")
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
canlex/ciu.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest CIU and PSAC union documents (doc_type='union').
2
+
3
+ The Customs and Immigration Union is the PSAC component representing the FB
4
+ bargaining unit; its governance instruments and published reference material
5
+ are the union-side counterpart to the FB collective agreement already in the
6
+ corpus. Ingested here, all as public documents from ciu-sdi.ca / psacunion.ca:
7
+
8
+ - the CIU National By-Laws (EN + FR), CIU Regulations, Branch Rules and the
9
+ Local Modifications to Branch Rules -- the union's internal law;
10
+ - the PSAC Constitution and Regulations (the parent instrument the by-laws
11
+ operate under: dues, discipline and appeals bottom out there);
12
+ - the Branch Elections Kit and the members' guides (grievance handling,
13
+ employment equity, workers in transition);
14
+ - every site-specific Variable Shift Schedule Agreement (VSSA) the union
15
+ publishes -- the locally negotiated schedule grids for ports of entry.
16
+
17
+ None of this is employer policy or part of the collective agreement itself;
18
+ _format_section banners doc_type='union' accordingly.
19
+
20
+ python -m canlex.ciu [--force] [--allow-shrink]
21
+
22
+ --force re-downloads every cached PDF instead of reusing data/raw/ciu.
23
+ The union reposts amended documents at the same URLs, so a plain run can
24
+ never notice an upstream revision -- see canlex.refresh.check_union.
25
+ --allow-shrink lets a run that yields fewer than 80% of the stored chunks
26
+ write (the corpus-write guard, _common.write_corpus); a document or VSSA
27
+ that fails to fetch keeps its last-good chunks (_common.preserve_dropped).
28
+ """
29
+ import re
30
+ import sys
31
+ import urllib.request
32
+
33
+ from ._common import (dehyphenate, document_words, fetch_cached,
34
+ mark_soft_breaks, norm_ws as _norm, preserve_dropped,
35
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
36
+ from .config import PROCESSED_DIR, RAW_DIR
37
+
38
+ RAW = RAW_DIR / "ciu"
39
+ VSSA_PAGE = "https://ciu-sdi.ca/en/vssa/"
40
+ _DOCS_BASE = "https://www.ciu-sdi.ca/wp-content/uploads/documents/"
41
+
42
+ # (code, act_short, act_name, url, lang)
43
+ DOCS = [
44
+ ("ciu-bylaws", "CIU By-Laws",
45
+ "CIU National By-Laws (adopted by CIU National Conventions)",
46
+ _DOCS_BASE + "bylaws_en.pdf", "en"),
47
+ ("ciu-bylaws", "CIU By-Laws",
48
+ "Règlements nationaux du SDI (adoptés par les congrès)",
49
+ _DOCS_BASE + "bylaws_fr.pdf", "fr"),
50
+ ("ciu-regs", "CIU Regulations",
51
+ "CIU Regulations (adopted by the CIU National Board of Directors)",
52
+ _DOCS_BASE + "ciu-regs-en.pdf", "en"),
53
+ ("ciu-branch-rules", "CIU Branch Rules",
54
+ "CIU Branch Rules (adopted by the CIU National Board of Directors)",
55
+ _DOCS_BASE + "branch-rules-en.pdf", "en"),
56
+ ("ciu-local-mods", "CIU Local Modifications",
57
+ "Local Modifications to CIU Branch Rules",
58
+ _DOCS_BASE + "local_mod_branch_rules_en.pdf", "en"),
59
+ ("ciu-elections-kit", "CIU Elections Kit",
60
+ "CIU Branch Elections Kit",
61
+ "https://www.ciu-sdi.ca/wp-content/uploads/elections/2023/EN/"
62
+ "elections-kit.pdf", "en"),
63
+ ("psac-constitution", "PSAC Constitution",
64
+ "PSAC Constitution and Regulations (November 2024 edition)",
65
+ "https://psacunion.ca/sites/psac/files/"
66
+ "psac.constitution.november_2024_edition.eng_v002.pdf", "en"),
67
+ ("ciu-guide-grievance", "CIU Grievance Guide",
68
+ "CIU Guide to Grievance Handling",
69
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
70
+ "guide_to_grievance_handling-e.pdf", "en"),
71
+ ("ciu-guide-ee", "CIU EE Toolkit",
72
+ "CIU Employment Equity Toolkit",
73
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
74
+ "EMPLOYMENT-EQUITY-Tool-Kit-e.pdf", "en"),
75
+ ("ciu-guide-transition", "CIU Transition Guide",
76
+ "Workers in Transition: a practical guide about gender transition",
77
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
78
+ "workers_in_transition_guide_-_en_-_web.pdf", "en"),
79
+ ]
80
+
81
+ # Governance headings: "BY-LAW 5", "REGULATION 12", "RULE 3", "ARTICLE 7",
82
+ # "SECTION 25", "PART B" -- at line start, optionally followed by a title.
83
+ _HEADING = re.compile(
84
+ r"^(BY-?LAW|R[EÈ]GLEMENT|REGULATION|RULE|ARTICLE|SECTION|PART|ANNEX(?:E)?"
85
+ r"|APPENDIX)\s+([0-9]+[0-9A-Za-z.\-]*|[A-Z])\b\s*[-–—:]?\s*(.{0,80})",
86
+ re.IGNORECASE)
87
+ _DATE = re.compile(r"\b(20\d{2}|19\d{2})\b")
88
+
89
+
90
+ def _pdf_pages(path):
91
+ from pypdf import PdfReader
92
+ pages = []
93
+ for page in PdfReader(str(path)).pages:
94
+ try:
95
+ pages.append(page.extract_text() or "")
96
+ except Exception:
97
+ pages.append("")
98
+ # a line-final soft hyphen is a broken word's printed hyphen: kept as
99
+ # '-' so it survives norm_ws for dehyphenate (2026-09-22)
100
+ return [mark_soft_breaks(page) for page in pages]
101
+
102
+
103
+ def _currency(pages):
104
+ """Newest adoption/amendment year on the cover pages."""
105
+ years = _DATE.findall("\n".join(pages[:3]))
106
+ return max(years) if years else ""
107
+
108
+
109
+ def _doc_chunks(code, short, name, url, lang, pages):
110
+ text = "\n".join(pages)
111
+ lines = text.split("\n")
112
+ currency = _currency(pages)
113
+ marks = []
114
+ for i, l in enumerate(lines):
115
+ m = _HEADING.match(l.strip())
116
+ if m:
117
+ marks.append((i, f"{m.group(1).title()} {m.group(2)}",
118
+ _norm(m.group(3)) or ""))
119
+ segments = []
120
+ if len(marks) >= 6:
121
+ for j, (i, label, title) in enumerate(marks):
122
+ end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
123
+ body = "\n".join(p for p in (_norm(x) for x in lines[i:end]) if p)
124
+ if len(body) > 120:
125
+ segments.append((label, title, body))
126
+ if not segments:
127
+ for k, piece in enumerate(split_lines(text, 1800), start=1):
128
+ body = "\n".join(p for p in (_norm(x) for x in piece.split("\n")) if p)
129
+ if len(body) > 120:
130
+ segments.append((f"part {k}", "", body))
131
+ chunks = []
132
+ fr = "-fr" if lang == "fr" else ""
133
+ words = document_words(lines)
134
+ for n, (label, title, body) in enumerate(segments, start=1):
135
+ body = dehyphenate(body, words) # line-break hyphens (2026-09-22)
136
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
137
+ total = len(pieces)
138
+ for k, piece in enumerate(pieces, start=1):
139
+ suffix = "" if total == 1 else f"-p{k}"
140
+ note = "" if total == 1 else f" (part {k} of {total})"
141
+ chunk = {
142
+ "id": f"{code}{fr}-{n}{suffix}",
143
+ "doc_type": "union",
144
+ "act_code": code.upper(),
145
+ "act_short": short,
146
+ "act_name": name,
147
+ "section": label,
148
+ "marginal_note": title,
149
+ "part": short,
150
+ "division": "",
151
+ "heading": "",
152
+ "text": piece,
153
+ "history": "",
154
+ "last_amended": currency,
155
+ "in_force": "",
156
+ "status": "in force",
157
+ "current_to": currency,
158
+ "citation": f"{short}, {label}"
159
+ + (f" ({title})" if title else "") + note,
160
+ "source_url": url,
161
+ }
162
+ if lang == "fr":
163
+ chunk["lang"] = "fr"
164
+ chunks.append(chunk)
165
+ return chunks
166
+
167
+
168
+ def _vssa_links():
169
+ req = urllib.request.Request(VSSA_PAGE, headers={"User-Agent": "Mozilla/5.0"})
170
+ with urllib.request.urlopen(req, timeout=60) as r:
171
+ html = r.read().decode("utf-8", "replace")
172
+ links = sorted(set(re.findall(
173
+ r'href="(https?://[^"]*?/uploads/vssa/([^"/]+)\.pdf)"', html)))
174
+ return links
175
+
176
+
177
+ def _vssa_chunks(url, stem, pages):
178
+ site = _norm(stem.replace("-", " "))
179
+ text = "\n".join(p for p in (_norm(x) for x in "\n".join(pages).split("\n")) if p)
180
+ text = dehyphenate(text, document_words(pages)) # line-break hyphens
181
+ if len(text) < 60:
182
+ return []
183
+ out = []
184
+ pieces = split_lines(text, 1800)
185
+ total = len(pieces)
186
+ for k, piece in enumerate(pieces, start=1):
187
+ suffix = "" if total == 1 else f"-p{k}"
188
+ note = "" if total == 1 else f" (part {k} of {total})"
189
+ out.append({
190
+ "id": f"vssa-{stem.lower()}{suffix}",
191
+ "doc_type": "union",
192
+ "act_code": "VSSA",
193
+ "act_short": "VSSA",
194
+ "act_name": "Variable Shift Schedule Agreements (by work site)",
195
+ "section": site,
196
+ "marginal_note": f"VSSA — {site}",
197
+ "part": "Variable Shift Schedule Agreements",
198
+ "division": "",
199
+ "heading": "",
200
+ "text": piece,
201
+ "history": "",
202
+ "last_amended": "",
203
+ "in_force": "",
204
+ "status": "in force",
205
+ "current_to": "",
206
+ "citation": f"VSSA — {site}{note}",
207
+ "source_url": url,
208
+ })
209
+ return out
210
+
211
+
212
+ def _identity(chunk):
213
+ """The document a chunk belongs to, for preserve-on-failure: a governance
214
+ document is (ACT_CODE, lang, ''); a VSSA is ('VSSA', 'en', site), since
215
+ every site shares the VSSA act_code and is told apart by its section."""
216
+ code = chunk["act_code"]
217
+ site = chunk["section"] if code == "VSSA" else ""
218
+ return (code, chunk.get("lang", "en"), site)
219
+
220
+
221
+ def build(force=False, allow_shrink=False):
222
+ """Rebuild ciu.json. force=True re-downloads each PDF; without it the
223
+ cached copy in data/raw/ciu wins and upstream revisions are invisible.
224
+ Returns the chunks, or False when the corpus-write guard refused.
225
+
226
+ A document or VSSA that fails to fetch or parse keeps its last-good
227
+ chunks (restricted to the ones that actually failed, so a VSSA the union
228
+ takes down stays gone once the index no longer lists it). Until
229
+ 2026-09-21 this ingester wrote ciu.json directly: a run with the union
230
+ site down wrote an empty list over 635 stored chunks and exited 0."""
231
+ RAW.mkdir(parents=True, exist_ok=True)
232
+ out = PROCESSED_DIR / "ciu.json"
233
+ stored = stored_chunks(out)
234
+ all_chunks, failed = [], set()
235
+ for code, short, name, url, lang in DOCS:
236
+ fr = "-fr" if lang == "fr" else ""
237
+ dest = RAW / f"{code}{fr}.pdf"
238
+ try:
239
+ fetch_cached(url, dest, pause=0.8, force=force)
240
+ chunks = _doc_chunks(code, short, name, url, lang,
241
+ _pdf_pages(dest))
242
+ except Exception as exc:
243
+ print(f" !! {short} ({lang}): {type(exc).__name__}: {exc}")
244
+ failed.add((code.upper(), lang, ""))
245
+ continue
246
+ print(f" {short:24} ({lang}): {len(chunks)} chunks")
247
+ all_chunks.extend(chunks)
248
+ try:
249
+ links = _vssa_links()
250
+ except Exception as exc:
251
+ links = []
252
+ print(f" !! VSSA index: {type(exc).__name__}: {exc}")
253
+ # No index at all says nothing about any one site: keep every stored VSSA.
254
+ failed.update(_identity(c) for c in stored if c.get("act_code") == "VSSA")
255
+ n_vssa = 0
256
+ for url, stem in links:
257
+ dest = RAW / f"vssa-{stem.lower()}.pdf"
258
+ try:
259
+ fetch_cached(url, dest, pause=0.6, force=force)
260
+ chunks = _vssa_chunks(url, stem, _pdf_pages(dest))
261
+ except Exception as exc:
262
+ print(f" !! VSSA {stem}: {type(exc).__name__}: {exc}")
263
+ failed.add(("VSSA", "en", _norm(stem.replace("-", " "))))
264
+ continue
265
+ all_chunks.extend(chunks)
266
+ n_vssa += 1
267
+ print(f" VSSA sites: {n_vssa}")
268
+ all_chunks, preserved = preserve_dropped(all_chunks, stored, key=_identity,
269
+ only=failed)
270
+ if preserved:
271
+ names = ", ".join(f"{code}{' (' + lang + ')' if lang != 'en' else ''}"
272
+ f"{' ' + site if site else ''}"
273
+ for code, lang, site in preserved)
274
+ print(f" kept the last-good copy of {len(preserved)} unfetchable "
275
+ f"document(s): {names}")
276
+ uniquify_ids(all_chunks)
277
+ if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
278
+ return False
279
+ print(f"ciu: {len(all_chunks)} chunks -> {out.name}")
280
+ return all_chunks
281
+
282
+
283
+ def main():
284
+ # `is not False` rather than truthiness: a first-ever run that writes an
285
+ # empty corpus succeeded, a refused write did not.
286
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
287
+ force="--force" in sys.argv) is not False
288
+ sys.exit(0 if ok else 1)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
canlex/commentary.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest CanLex's curated commentary datasets into searchable chunks.
2
+
3
+ Commentary is the one doc_type CanLex AUTHORS rather than mirrors: structured
4
+ legal-analysis datasets (currently the US-dispositions helper -- whether each
5
+ kind of US state criminal disposition is a "conviction" for IRPA s. 36
6
+ purposes). Every chunk is banner-labelled as commentary, every proposition
7
+ carries its authorities, and entries with no authority say so explicitly and
8
+ flag their reasoning as interpretation. The source of truth is
9
+ data/curated/us_dispositions.json, which is reviewed by the user before it
10
+ ships; this module just renders it into corpus chunks.
11
+
12
+ py -m canlex.commentary [--allow-shrink]
13
+ """
14
+ import json
15
+ import re
16
+ import sys
17
+
18
+ from ._common import write_corpus
19
+ from .config import DATA_DIR, PROCESSED_DIR
20
+
21
+ CURATED = DATA_DIR / "curated" / "us_dispositions.json"
22
+ EQUIV = DATA_DIR / "curated" / "us_equivalency.json"
23
+ OUT = PROCESSED_DIR / "commentary.json"
24
+
25
+ ACT_CODE = "US-DISP"
26
+ ACT_SHORT = "US Dispositions Helper"
27
+ ACT_NAME = ("US criminal dispositions and the IRPA 'conviction' concept "
28
+ "(curated CanLex commentary)")
29
+
30
+ BANNER = ("CURATED ANALYSIS -- commentary compiled for CanLex, not a source "
31
+ "of law. Verify against the cited authorities before relying on it.")
32
+
33
+ # The heading over an entry's guidance references (roadmap T1.10). It used to
34
+ # say 'IRCC guidance (cited by reference; not reproduced here)', but the
35
+ # references are mostly IRB Legal Services papers, and the guidance CanLex
36
+ # holds is now in the corpus. canlex/server.py's _entry_brief shares it.
37
+ GUIDANCE_HEADING = ("Guidance cited (IRB / IRCC; where CanLex holds the text, "
38
+ "search it with canlex_search_legislation, doc_type "
39
+ "'memorandum'):")
40
+
41
+ _STATUS_LABEL = {
42
+ "settled": "Settled by binding authority",
43
+ "judicially-considered": "Judicially considered (persuasive authority)",
44
+ "guidance-only": "IRCC guidance only -- no judicial authority located",
45
+ "no-authority": "NO AUTHORITY LOCATED -- reasoned interpretation only",
46
+ }
47
+
48
+ # Verdicts assess the COMPLETED disposition; the methodology chunk explains
49
+ # the rule each one derives from. 'depends' is the legacy spelling of
50
+ # fact-specific, kept for backwards compatibility.
51
+ _VERDICT_LABEL = {
52
+ "yes": "YES", "likely-yes": "LIKELY YES", "likely-no": "LIKELY NO",
53
+ "no": "NO", "fact-specific": "FACT-SPECIFIC",
54
+ "depends": "FACT-SPECIFIC",
55
+ }
56
+
57
+
58
+ def _vlabel(v):
59
+ return _VERDICT_LABEL.get(v, str(v).upper())
60
+
61
+
62
+ # The survey's own caution, rendered with every entry that carries state
63
+ # rows: most rows are secondary-source extractions and the dataset says so
64
+ # in its `_state_survey` key -- which nothing used to read.
65
+ SURVEY_NOTE = ("State rows: 51-jurisdiction survey (July 2026); verdicts applied "
66
+ "by rule from each row's mechanism facts. Every row shows its "
67
+ "provenance: a row marked 'verified <date> against <source>' was "
68
+ "checked against the official text it names (verification rounds "
69
+ "2026-07-12 to 2026-09-12); a row marked 'NOT VERIFIED' says why; "
70
+ "every other row is a secondary-source extraction -- confirm the "
71
+ "statute before relying on it to clear a record.")
72
+
73
+ # Compiler-side notes the survey left in row text ("[EXTRACTION FLAG: ...
74
+ # verify]", "PROFILE THIN -- VERIFY"): rendered as explicit open questions,
75
+ # never as analysis.
76
+ _FLAG = re.compile(r"\s*\[(?:EXTRACTION FLAG|Corrected|Normalized)[^\]]*\]")
77
+ # A sentence boundary: a period followed by whitespace and a capital -- so
78
+ # 'art. 42A.701' and 'Stat. Ann. § 431.073' stay whole.
79
+ _SENTENCE = re.compile(r"(?<=\.)\s+(?=[A-Z])")
80
+ _VERIFY = re.compile(r"\bVERIFY\b|\bPROFILE (?:THIN|SILENT)\b")
81
+
82
+
83
+ def _split_verify(note):
84
+ """(note without its compiler notes, [the notes]): whole sentences that
85
+ carry a VERIFY / PROFILE THIN marker are lifted out of the row text."""
86
+ keep, lifted = [], []
87
+ for sentence in _SENTENCE.split(note):
88
+ (lifted if _VERIFY.search(sentence) else keep).append(sentence.strip())
89
+ return " ".join(s for s in keep if s), lifted
90
+
91
+
92
+ def _sources(v):
93
+ """A state row's 'source' (roadmap T3.5, held): one object or a list."""
94
+ s = v.get("source")
95
+ if isinstance(s, dict):
96
+ return [s]
97
+ return [x for x in (s or []) if isinstance(x, dict)]
98
+
99
+
100
+ def provenance_label(v):
101
+ """The provenance tier printed with EVERY state row (roadmap T3.5): where
102
+ its statement of state law comes from. A verified row names the official
103
+ text it was checked against; a row the verification could not reach says
104
+ why; any other row is a secondary-source extraction."""
105
+ src = _sources(v)
106
+ if v.get("verified"):
107
+ if src:
108
+ s = src[0]
109
+ grade = f", {s['grade']}" if s.get("grade") else ""
110
+ return f"[verified {v['verified']} against {s['cite']}{grade}: {s['url']}]"
111
+ return f"[verified against statute text {v['verified'][:7]}]"
112
+ if v.get("unverifiable"):
113
+ read = f"; read: {src[0]['cite']}: {src[0]['url']}" if src else ""
114
+ return f"[NOT VERIFIED: {v['unverifiable']}{read}]"
115
+ return "[secondary-source extraction, unverified]"
116
+
117
+
118
+ def row_tier(v):
119
+ """The same tier in a few words, for the screening memo's step 1."""
120
+ src = _sources(v)
121
+ if v.get("verified"):
122
+ return (f"verified {v['verified']} against {src[0]['cite']}" if src
123
+ else "verified against statute text")
124
+ if v.get("unverifiable"):
125
+ return f"NOT VERIFIED -- {v['unverifiable']}"
126
+ return "secondary-source extraction -- unverified"
127
+
128
+
129
+ def row_tag(v):
130
+ """The tier as a short tag for a one-line index of rows (wave-3 verifier,
131
+ 2026-09-15: the 'all' index listed every row with its verdict only)."""
132
+ if v.get("verified"):
133
+ return f"[verified {v['verified']}]"
134
+ if v.get("unverifiable"):
135
+ return "[NOT VERIFIED]"
136
+ return "[unverified]"
137
+
138
+
139
+ def row_notes(v):
140
+ """The held review lines a state row carries (roadmap T3.5): a CanLex
141
+ characterisation of the state's law, and what decides a split row."""
142
+ out = []
143
+ if v.get("characterisation"):
144
+ out.append("CANLEX CHARACTERISATION (held for review): " + v["characterisation"])
145
+ if v.get("open_question"):
146
+ out.append("WHAT DECIDES IT (held for review): " + v["open_question"])
147
+ return out
148
+
149
+
150
+ def row_provenance(v):
151
+ """CANLEX_RESULT 'row_provenance' for a state row; None without a row."""
152
+ if not v:
153
+ return None
154
+ s = (_sources(v) or [{}])[0]
155
+ return {"state": v.get("state"),
156
+ "tier": ("verified" if v.get("verified") else
157
+ "not-verified" if v.get("unverifiable") else "secondary-unverified"),
158
+ "verified": v.get("verified"), "verified_by": v.get("verified_by"),
159
+ "source_cite": s.get("cite"), "source_url": s.get("url"),
160
+ "unverifiable": v.get("unverifiable"),
161
+ "characterisation": bool(v.get("characterisation")),
162
+ "open_question": bool(v.get("open_question"))}
163
+
164
+
165
+ def _row_lines(v, verdict_label, full=False):
166
+ """The bullet for one state row with its provenance tier (every row,
167
+ roadmap T3.5), the held review lines, any open-question line, and --
168
+ full=True, the requested state in a tool answer -- each source quote."""
169
+ note = v["note"]
170
+ flags = [m.group(0).strip(" []") for m in _FLAG.finditer(note)]
171
+ note = _FLAG.sub("", note)
172
+ note, verify = _split_verify(note)
173
+ lines = [f"- {v['state']}{verdict_label}: {note} {provenance_label(v)}"]
174
+ lines += [" " + x for x in row_notes(v)]
175
+ if full:
176
+ for s in _sources(v):
177
+ if s.get("quote"):
178
+ lines.append(f" SOURCE: {s['cite']}: \"{s['quote']}\" ({s['url']}, "
179
+ f"retrieved {s.get('retrieved', '?')})")
180
+ open_q = [f for f in flags if not f.startswith("Normalized")] + verify
181
+ if open_q:
182
+ lines.append(" OPEN QUESTION (unverified extraction note): "
183
+ + " | ".join(open_q))
184
+ return lines
185
+
186
+
187
+ # A disposition entry may carry 'policy_caveats' (roadmap T3.6, 2026-09-15 --
188
+ # HELD FOR REVIEW; optional, the approved baseline has none): official guidance
189
+ # that pulls against the entry's bottom line, quoted verbatim (each quote pinned
190
+ # in the corpus by tests/test_read_next.py), with both readings -- principle 5:
191
+ # CanLex does not choose. First use: ENF 2 s. 3's list of scenarios in which the
192
+ # act branch 'would generally not be applied' against the entries whose bottom
193
+ # lines keep it available.
194
+ POLICY_CAVEAT_HEAD = "POLICY CAVEAT -- NOT SETTLED (both readings are given; neither is chosen):"
195
+ # Disposition roadmap T1.5 / T1.10 and the critic's Rodriguez Anzola item
196
+ # (2026-09-16, HELD): what a caveat bears on, keyed by its curated id -- never
197
+ # by its text. 'act': the act branch, whose allegation is still drafted with
198
+ # the caveat beside it (ENF 2 s. 3's scenarios); 'act-not-settled': the act
199
+ # branch after an acquittal, where no act-branch allegation, s. 44(1) skeleton
200
+ # or card report is drafted (ENF 6 s. 23.1 against the statute and Magtibay);
201
+ # 'act-if-acquitted': a pending charge, whose readings apply only if it ends
202
+ # in an acquittal; 'conviction': the conviction branch itself (a conviction
203
+ # that may be a nullity for Canadian purposes). An id not listed is 'act'.
204
+ CAVEAT_CLASSES = {
205
+ "enf-2-s3-act-branch": "act",
206
+ "enf-6-s23-1-acquittal": "act-not-settled",
207
+ "enf-6-s23-1-if-acquitted": "act-if-acquitted",
208
+ "rodriguez-anzola-duress-nullity": "conviction",
209
+ }
210
+ AUTHORITY_CAVEAT_HEAD = ("AUTHORITY CAVEAT -- NOT SETTLED (both readings are given; neither is "
211
+ "chosen):")
212
+
213
+
214
+ def caveat_class(cv):
215
+ """The class CAVEAT_CLASSES gives a curated caveat item ('act' by default)."""
216
+ return CAVEAT_CLASSES.get((cv or {}).get("id") if isinstance(cv, dict) else None, "act")
217
+
218
+
219
+ def caveat_in_play(cv, footing):
220
+ """True when a caveat bears on a step-1 footing ('conviction', 'act' or
221
+ 'both'; None: every caveat, as the entry prints without a record)."""
222
+ if footing is None or footing == "both":
223
+ return True
224
+ return (caveat_class(cv) == "conviction") == (footing == "conviction")
225
+
226
+
227
+ def policy_caveat_lines(e, footing=None, skip=()):
228
+ """The POLICY CAVEAT lines of an entry: the caveat text with its source and
229
+ chunk, then each reading ([] when the entry carries none). footing: only
230
+ the caveats that bear on it (caveat_in_play); skip: caveat ids the caller
231
+ prints another way."""
232
+ out = []
233
+ for cv in (e.get("policy_caveats") or []) if isinstance(e, dict) else []:
234
+ if not (isinstance(cv, dict) and isinstance(cv.get("text"), str) and cv["text"].strip()):
235
+ continue
236
+ if cv.get("id") in skip or not caveat_in_play(cv, footing):
237
+ continue
238
+ head = AUTHORITY_CAVEAT_HEAD if caveat_class(cv) == "conviction" else POLICY_CAVEAT_HEAD
239
+ where = "; ".join(x for x in (cv.get("source"), cv.get("chunk")) if x)
240
+ out.append(f"{head} {cv['text']}" + (f" [{where}]" if where else ""))
241
+ out += [f"- {r}" for r in cv.get("readings") or [] if isinstance(r, str) and r.strip()]
242
+ return out
243
+
244
+
245
+ def _entry_text(e, max_states=None):
246
+ """Render one disposition entry as a readable, retrieval-friendly block.
247
+
248
+ With the 51-jurisdiction survey an entry can carry dozens of state rows;
249
+ max_states caps how many render inline (the corpus keeps full per-state
250
+ detail in separate per-state chunks so retrieval still reaches every
251
+ row). None renders everything -- the tool uses that for direct lookups."""
252
+ lines = [BANNER, ""]
253
+ lines.append(f"Disposition: {'; '.join(e['names'])}")
254
+ lines.append(f"Is the COMPLETED disposition a conviction for IRPA s. 36: "
255
+ f"{_vlabel(e['is_conviction'])}")
256
+ lines.append(f"Authority status: {_STATUS_LABEL[e['status']]}")
257
+ if e.get("bottom_line"):
258
+ lines.append("")
259
+ lines.append(f"BOTTOM LINE: {e['bottom_line']}")
260
+ caveat = policy_caveat_lines(e) # held for review; absent on the baseline
261
+ if caveat:
262
+ lines.append("")
263
+ lines += caveat
264
+ lines.append("")
265
+ lines.append(e["analysis"])
266
+ if e.get("state_variations"):
267
+ named = [v for v in e["state_variations"]
268
+ if v["state"].lower() != "general"]
269
+ shown = named if max_states is None else named[:max_states]
270
+ lines.append("")
271
+ if max_states is not None and len(named) > len(shown):
272
+ from collections import Counter
273
+ counts = Counter(v.get("is_conviction", "depends") for v in named)
274
+ lines.append(f"State-by-state coverage: {len(named)} jurisdictions "
275
+ f"({', '.join(f'{k}: {n}' for k, n in counts.most_common())}) "
276
+ f"-- full per-state detail in the per-state entries.")
277
+ else:
278
+ lines.append("State variations:")
279
+ for v in shown:
280
+ flag = (f" (conviction: {_vlabel(v['is_conviction'])})"
281
+ if v.get("is_conviction") else "")
282
+ lines += _row_lines(v, flag)
283
+ for v in e["state_variations"]:
284
+ if v["state"].lower() == "general":
285
+ lines.append(f"- General: {v['note']}")
286
+ lines.append("")
287
+ lines.append(SURVEY_NOTE)
288
+ # The Saini checklist (roadmap T2.8), only for an entry that carries the
289
+ # curated 'pardon_test' (held for review; the approved baseline has none).
290
+ from canlex.screening import saini_checklist
291
+ checklist = saini_checklist(e)
292
+ if checklist:
293
+ lines.append("")
294
+ lines += checklist
295
+ if e.get("authorities"):
296
+ # each followed by the authority registry's brief pointer (roadmap
297
+ # T3.2; the bare bullets when the registry is not rendered)
298
+ from canlex.authorities import authority_lines
299
+ lines.append("")
300
+ lines.append("Authorities:")
301
+ lines += authority_lines(e["authorities"], brief=True)
302
+ if e.get("guidance"):
303
+ lines.append("")
304
+ lines.append(GUIDANCE_HEADING)
305
+ for g in e["guidance"]:
306
+ lines.append(f"- {g['ref']}: {g['note']}")
307
+ if e.get("interpretation"):
308
+ lines.append("")
309
+ lines.append("INTERPRETATION (no direct authority -- this is CanLex's "
310
+ "reasoned view from the governing principles; treat it as "
311
+ "a starting point, not an answer): "
312
+ + e["interpretation"])
313
+ return "\n".join(lines)
314
+
315
+
316
+ def build(allow_shrink=False):
317
+ data = json.loads(CURATED.read_text(encoding="utf-8"))
318
+ chunks = []
319
+ for e in data.get("methodology", []):
320
+ chunks.append({
321
+ "id": f"commentary-method-{e['id']}",
322
+ "doc_type": "commentary",
323
+ "act_code": ACT_CODE,
324
+ "act_short": ACT_SHORT,
325
+ "act_name": ACT_NAME,
326
+ "section": e["id"],
327
+ "marginal_note": e["title"],
328
+ "part": "Methodology",
329
+ "division": "",
330
+ "heading": e["title"],
331
+ "text": BANNER + "\n\n" + e["text"],
332
+ "history": "",
333
+ "last_amended": "",
334
+ "current_to": data.get("reviewed", ""),
335
+ "citation": f"{ACT_SHORT} — {e['title']}",
336
+ "source_url": "",
337
+ })
338
+ for e in data.get("dispositions", []):
339
+ chunks.append({
340
+ "id": f"commentary-disp-{e['id']}",
341
+ "doc_type": "commentary",
342
+ "act_code": ACT_CODE,
343
+ "act_short": ACT_SHORT,
344
+ "act_name": ACT_NAME,
345
+ "section": e["id"],
346
+ "marginal_note": e["names"][0],
347
+ "part": "US dispositions",
348
+ "division": "",
349
+ "heading": (f"Is a US {e['names'][0]} a conviction for IRPA "
350
+ f"s. 36? ({_vlabel(e['is_conviction'])})"),
351
+ "text": _entry_text(e, max_states=6),
352
+ "history": "",
353
+ "last_amended": "",
354
+ "current_to": data.get("reviewed", ""),
355
+ "citation": f"{ACT_SHORT} — {e['names'][0]}",
356
+ "source_url": "",
357
+ })
358
+
359
+ # One chunk per jurisdiction: every disposition row for that state, so a
360
+ # query naming a state ("Georgia first offender act", "Missouri SIS")
361
+ # retrieves that state's page directly.
362
+ by_state = {}
363
+ for e in data.get("dispositions", []):
364
+ for v in e.get("state_variations", []):
365
+ st = v["state"]
366
+ if st.lower() == "general":
367
+ continue
368
+ flag = _vlabel(v.get("is_conviction") or e["is_conviction"])
369
+ row = dict(v, state=e["names"][0])
370
+ by_state.setdefault(st, []).extend(
371
+ _row_lines(row, f" (conviction: {flag})"))
372
+ for st in sorted(by_state):
373
+ body = (BANNER + "\n\n"
374
+ + f"US dispositions — {st}: whether each disposition type is a "
375
+ f"conviction for IRPA s. 36, under {st} law.\n\n"
376
+ + "\n".join(by_state[st])
377
+ + "\n\n" + SURVEY_NOTE
378
+ + "\n\nThe act branch (IRPA s. 36(1)(c)/(2)(c)) can apply even "
379
+ "where a disposition is not a conviction. See the "
380
+ "per-disposition entries for the governing analysis and "
381
+ "authorities.")
382
+ slug = st.lower().replace(" ", "-")
383
+ chunks.append({
384
+ "id": f"commentary-state-{slug}",
385
+ "doc_type": "commentary",
386
+ "act_code": ACT_CODE,
387
+ "act_short": ACT_SHORT,
388
+ "act_name": ACT_NAME,
389
+ "section": f"state-{slug}",
390
+ "marginal_note": f"US dispositions — {st}",
391
+ "part": "US dispositions by state",
392
+ "division": "",
393
+ "heading": (f"{st}: criminal dispositions vs the IRPA "
394
+ f"'conviction' concept"),
395
+ "text": body,
396
+ "history": "",
397
+ "last_amended": "",
398
+ "current_to": data.get("reviewed", ""),
399
+ "citation": f"{ACT_SHORT} — {st}",
400
+ "source_url": "",
401
+ })
402
+ # --- equivalency pairings (step 2), same banner discipline
403
+ n_pairings = 0
404
+ if EQUIV.exists():
405
+ eq = json.loads(EQUIV.read_text(encoding="utf-8"))
406
+ for m in eq.get("methodology", []):
407
+ chunks.append({
408
+ "id": f"commentary-method-{m['id']}",
409
+ "doc_type": "commentary",
410
+ "act_code": ACT_CODE,
411
+ "act_short": ACT_SHORT,
412
+ "act_name": ACT_NAME,
413
+ "section": m["id"],
414
+ "marginal_note": m["title"],
415
+ "part": "Methodology",
416
+ "division": "",
417
+ "heading": m["title"],
418
+ "text": BANNER + "\n\n" + m["text"],
419
+ "history": "",
420
+ "last_amended": "",
421
+ "current_to": eq.get("reviewed", ""),
422
+ "citation": f"{ACT_SHORT} — {m['title']}",
423
+ "source_url": "",
424
+ })
425
+ for p in eq.get("pairings", []):
426
+ n_pairings += 1
427
+ lines = [BANNER, "",
428
+ f"US offence: {'; '.join(p['us_terms'][:5])}",
429
+ f"Canadian equivalent: {p['canadian_offence']}",
430
+ f"Maximum penalty (verified against the corpus text): "
431
+ f"{p['penalty']}",
432
+ f"Inadmissibility branch: {p['branch']}",
433
+ f"Authority status: {_STATUS_LABEL[p['status']]}", "",
434
+ p["analysis"]]
435
+ if p.get("caveats"):
436
+ lines.append("")
437
+ lines.append("Caveats:")
438
+ lines += [f"- {c}" for c in p["caveats"]]
439
+ if p.get("authorities"):
440
+ from canlex.authorities import authority_lines
441
+ lines.append("")
442
+ lines.append("Authorities:")
443
+ lines += authority_lines(p["authorities"], brief=True)
444
+ chunks.append({
445
+ "id": f"commentary-equiv-{p['id']}",
446
+ "doc_type": "commentary",
447
+ "act_code": ACT_CODE,
448
+ "act_short": ACT_SHORT,
449
+ "act_name": ACT_NAME,
450
+ "section": f"equiv-{p['id']}",
451
+ "marginal_note": f"Equivalency: {p['us_terms'][0]}",
452
+ "part": "US offence equivalency",
453
+ "division": "",
454
+ "heading": (f"What does a US {p['us_terms'][0]} conviction "
455
+ f"equate to in Canada?"),
456
+ "text": "\n".join(lines),
457
+ "history": "",
458
+ "last_amended": "",
459
+ "current_to": eq.get("reviewed", ""),
460
+ "citation": f"{ACT_SHORT} — equivalency: {p['us_terms'][0]}",
461
+ "source_url": "",
462
+ })
463
+
464
+ # A full re-render of the curated files, so a truncated or half-edited
465
+ # us_dispositions.json (or an equivalency file that moved) collapses the
466
+ # chunk count and the write would replace good analysis with the remnant.
467
+ # indent=1 is the stored file's format -- changing it rewrites every line.
468
+ if not write_corpus(OUT, chunks, indent=1, allow_shrink=allow_shrink):
469
+ return False
470
+ print(f"{len(chunks)} commentary chunks "
471
+ f"({len(data.get('dispositions', []))} dispositions, "
472
+ f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
473
+ f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
474
+ return True
475
+
476
+
477
+ def main():
478
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
479
+
480
+
481
+ if __name__ == "__main__":
482
+ main()
canlex/conduct.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest public policy pages: employer policy instruments and a few
2
+ operational-policy and treaty texts (one chunk per heading).
3
+
4
+ Nearly every CBSA discipline letter and FPSLREB discipline decision in the
5
+ corpus is framed as a breach of a numbered section of the CBSA Code of
6
+ Conduct, and the Code itself operates under the Treasury Board's Values and
7
+ Ethics Code for the Public Sector -- yet neither text was in the corpus, so
8
+ a steward asking what the Code says about off-duty conduct or outside
9
+ employment got tribunal paraphrases of older editions. The Treasury Board
10
+ Directive on Terms and Conditions of Employment joins them: the FB agreement
11
+ defers to it for pay administration. All are single public HTML pages; each
12
+ is parsed into one chunk per heading.
13
+
14
+ These are employer policy instruments: binding on employees as a condition
15
+ of employment (the CBSA Code requires an annual attestation), grievable
16
+ when applied, but neither legislation nor part of the collective agreement.
17
+ server._format_section banners doc_type='policy' accordingly.
18
+
19
+ Each DOCS entry is one instrument: `url` (or `urls`, for a document
20
+ published as several pages), optional `doc_type` ('policy' by default;
21
+ 'memorandum' for CBSA operational policy, 'legislation' for the Safe Third
22
+ Country Agreement), optional `lang` ('fr' twins carry a '-fr-' id infix and
23
+ lang='fr' like every other French chunk), and `powershell` for canada.ca
24
+ hosts that reject Python's TLS client.
25
+
26
+ python -m canlex.conduct [--force] [--allow-shrink]
27
+ """
28
+ import re
29
+ import sys
30
+
31
+ from bs4 import BeautifulSoup
32
+
33
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
34
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
35
+ from .config import PROCESSED_DIR, RAW_DIR
36
+
37
+ RAW = RAW_DIR / "policy"
38
+ OUT_FILE = PROCESSED_DIR / "policy.json"
39
+
40
+ _TBS = "https://www.tbs-sct.canada.ca/pol/doc-{lang}.aspx?id={id}"
41
+ _CBSA_DET = "https://www.cbsa-asfc.gc.ca/security-securite/detent/"
42
+ _STCA = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
43
+ "mandate/policies-operational-instructions-agreements/agreements/"
44
+ "safe-third-country-agreement/")
45
+
46
+
47
+ def _tbs(code, short, name, id, lang="en"):
48
+ """A Treasury Board policy-suite instrument, English or French."""
49
+ return {"code": code, "short": short, "name": name,
50
+ "url": _TBS.format(lang="eng" if lang == "en" else "fra", id=id),
51
+ "raw": f"tbs-{id}-{lang}.html", "lang": lang}
52
+
53
+
54
+ DOCS = [
55
+ {"code": "CBSA-CODE",
56
+ "short": "CBSA Code of Conduct",
57
+ "name": "Canada Border Services Agency Code of Conduct",
58
+ "url": "https://www.cbsa-asfc.gc.ca/agency-agence/reports-rapports/"
59
+ "acc-resp/code-eng.html",
60
+ "raw": "cbsa-code-eng.html"},
61
+ {"code": "VE-CODE",
62
+ "short": "Values and Ethics Code",
63
+ "name": "Values and Ethics Code for the Public Sector (Treasury Board)",
64
+ "url": "https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=25049",
65
+ "raw": "values-ethics-code-eng.html"},
66
+ # The FB agreement defers to this Directive for pay on promotion and
67
+ # acting, increments, hours-of-work defaults and overpayment recovery.
68
+ {"code": "TB-TCE",
69
+ "short": "Terms and Conditions Directive",
70
+ "name": "Directive on Terms and Conditions of Employment (Treasury Board)",
71
+ "url": "https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=15772",
72
+ "raw": "tb-terms-conditions-eng.html"},
73
+ # --- the people-management instruments the FPSLREB decisions apply ---
74
+ _tbs("TB-DISC", "Guidelines for Discipline",
75
+ "Guidelines for Discipline (Treasury Board)", 22370),
76
+ _tbs("TB-DTA", "Duty to Accommodate Directive",
77
+ "Directive on the Duty to Accommodate (Treasury Board)", 32634),
78
+ _tbs("TB-PERF", "Performance Management Directive",
79
+ "Directive on Performance Management (Treasury Board)", 27146),
80
+ _tbs("TB-WPHV", "Harassment and Violence Directive",
81
+ "Directive on the Prevention and Resolution of Workplace Harassment "
82
+ "and Violence (Treasury Board)", 32671),
83
+ _tbs("TB-LEAVE", "Leave and Special Working Arrangements Directive",
84
+ "Directive on Leave and Special Working Arrangements (Treasury "
85
+ "Board)", 15774),
86
+ _tbs("TB-PGS", "Policy on Government Security",
87
+ "Policy on Government Security (Treasury Board)", 16578),
88
+ _tbs("TB-SEC", "Security Screening Directive",
89
+ "Directive on Security Screening (Treasury Board, in effect "
90
+ "2025-01-06; replaces the Standard on Security Screening)", 32805),
91
+ # --- French twins: the same instruments as their francophone readers
92
+ # get them (lang='fr'; an English query never sees them) ---
93
+ {"code": "CBSA-CODE", "lang": "fr",
94
+ "short": "CBSA Code of Conduct",
95
+ "name": "Code de conduite de l'Agence des services frontaliers du Canada",
96
+ "url": "https://www.cbsa-asfc.gc.ca/agency-agence/reports-rapports/"
97
+ "acc-resp/code-fra.html",
98
+ "raw": "cbsa-code-fra.html"},
99
+ _tbs("VE-CODE", "Values and Ethics Code",
100
+ "Code de valeurs et d'éthique du secteur public", 25049, "fr"),
101
+ _tbs("TB-TCE", "Terms and Conditions Directive",
102
+ "Directive sur les conditions d'emploi", 15772, "fr"),
103
+ _tbs("TB-DISC", "Guidelines for Discipline",
104
+ "Lignes directrices concernant la discipline", 22370, "fr"),
105
+ _tbs("TB-DTA", "Duty to Accommodate Directive",
106
+ "Directive sur l'obligation de prendre des mesures d'adaptation",
107
+ 32634, "fr"),
108
+ _tbs("TB-PERF", "Performance Management Directive",
109
+ "Directive sur la gestion du rendement", 27146, "fr"),
110
+ _tbs("TB-WPHV", "Harassment and Violence Directive",
111
+ "Directive sur la prévention et la résolution du harcèlement et de "
112
+ "la violence dans le lieu de travail", 32671, "fr"),
113
+ _tbs("TB-LEAVE", "Leave and Special Working Arrangements Directive",
114
+ "Directive sur les congés et les modalités de travail spéciales",
115
+ 15774, "fr"),
116
+ _tbs("TB-PGS", "Policy on Government Security",
117
+ "Politique sur la sécurité du gouvernement", 16578, "fr"),
118
+ _tbs("TB-SEC", "Security Screening Directive",
119
+ "Directive sur le filtrage de sécurité", 32805, "fr"),
120
+ # --- CBSA / Public Safety detention policy: operational guidance, so
121
+ # doc_type 'memorandum' (persuasive, not binding) like the ENF chapters ---
122
+ {"code": "CBSA-NIDF", "doc_type": "memorandum",
123
+ "short": "CBSA Detention Framework",
124
+ "name": "CBSA National Immigration Detention Framework",
125
+ "url": _CBSA_DET + "nidf-cndi-eng.html", "raw": "cbsa-nidf-eng.html"},
126
+ {"code": "CBSA-NDDHM", "doc_type": "memorandum",
127
+ "short": "CBSA Minors Detention Directive",
128
+ "name": "CBSA National Directive for the Detention or Housing of Minors",
129
+ "url": _CBSA_DET + "nddhm-dndhm-eng.html", "raw": "cbsa-nddhm-eng.html"},
130
+ {"code": "CBSA-NIDS", "doc_type": "memorandum",
131
+ "short": "CBSA Detention Standards",
132
+ "name": "CBSA National Immigration Detention Standards (chapters 1-6)",
133
+ "urls": [_CBSA_DET + f"standards-normes/ch{n}-eng.html" for n in range(1, 7)],
134
+ "raw": "cbsa-nids-eng"},
135
+ # --- the IRCC applicant guide the screening chain points people to:
136
+ # eligibility, the five-year calculation with worked examples, fees,
137
+ # where to apply. Guidance (doc_type 'memorandum'), like ENF 14 / OP 19.
138
+ {"code": "IMM-5312", "doc_type": "memorandum", "powershell": True,
139
+ "short": "Guide 5312",
140
+ "name": "IRCC Guide 5312 -- Rehabilitation for Persons Who Are "
141
+ "Inadmissible to Canada Because of Past Criminal Activity "
142
+ "(application guide for form IMM 1444)",
143
+ "url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
144
+ "services/application/application-forms-guides/"
145
+ "guide-5312-rehabilitation-persons-inadmissible-canada-past-"
146
+ "criminal-activity.html",
147
+ "raw": "imm5312-guide-eng.html"},
148
+ # --- IRCC's applicant-facing pages on overcoming criminal
149
+ # inadmissibility (added 2026-09-12, roadmap T3.8). The rehabilitation
150
+ # memo names form IMM 1444: its landing page carries the form's
151
+ # supplementary instructions. The form PDF itself is an XFA form whose
152
+ # text layer is only a 'Please wait...' viewer shell, so it is not
153
+ # ingested. Applicant-facing summaries, not officer instructions:
154
+ # doc_type 'memorandum' like Guide 5312; the law is IRPR ss. 17-18.
155
+ {"code": "IMM-1444", "doc_type": "memorandum", "powershell": True,
156
+ "short": "IMM 1444 form page",
157
+ "name": "IRCC Application for Criminal Rehabilitation (IMM 1444) -- "
158
+ "form page with the form's supplementary instructions "
159
+ "(applicant-facing)",
160
+ "url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
161
+ "services/application/application-forms-guides/imm1444.html",
162
+ "raw": "imm1444-page-eng.html"},
163
+ {"code": "IRCC-PUB-OCC", "doc_type": "memorandum", "powershell": True,
164
+ "short": "IRCC: Overcome criminal convictions",
165
+ "name": "IRCC public page: Overcome criminal convictions "
166
+ "(applicant-facing)",
167
+ "url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
168
+ "services/admissibility-enforcement/"
169
+ "overcome-criminal-convictions.html",
170
+ "raw": "ircc-overcome-criminal-convictions-eng.html"},
171
+ {"code": "IRCC-PUB-DEEMED", "doc_type": "memorandum", "powershell": True,
172
+ "short": "IRCC: Deemed rehabilitation",
173
+ "name": "IRCC public page: Deemed rehabilitation (applicant-facing)",
174
+ "url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
175
+ "services/admissibility-enforcement/"
176
+ "overcome-criminal-convictions/deemed-rehabilitation.html",
177
+ "raw": "ircc-deemed-rehabilitation-eng.html"},
178
+ {"code": "PS-MD-MINORS", "doc_type": "memorandum",
179
+ "short": "Ministerial Direction on Minors in Detention",
180
+ "name": "Ministerial Direction to the CBSA: Minors in Canada's "
181
+ "Immigration Detention System (Public Safety, November 2017)",
182
+ "url": "https://www.publicsafety.gc.ca/cnt/trnsprnc/ns-trnsprnc/"
183
+ "mnstrl-drctn-cbsa-en.aspx",
184
+ "raw": "ps-md-minors-eng.html"},
185
+ # --- the Canada-US Safe Third Country Agreement: a treaty implemented by
186
+ # IRPR ss. 159.1-159.7, so it renders as law, named as a treaty ---
187
+ {"code": "STCA", "doc_type": "legislation", "powershell": True,
188
+ "short": "STCA",
189
+ "name": "Safe Third Country Agreement (Canada-United States treaty, in "
190
+ "force 2004-12-29; implemented by IRPR ss. 159.1-159.7)",
191
+ "url": _STCA + "final-text.html", "raw": "stca-final-text-eng.html",
192
+ "current_to": "2004-12-29"},
193
+ {"code": "STCA-PROTOCOL", "doc_type": "legislation", "powershell": True,
194
+ "short": "STCA Additional Protocol",
195
+ "name": "Additional Protocol to the Safe Third Country Agreement "
196
+ "(in force 2023-03-25: claims between ports of entry)",
197
+ "url": _STCA + "additional-protocol.html",
198
+ "raw": "stca-additional-protocol-eng.html", "current_to": "2023-03-25"},
199
+ ]
200
+
201
+ # canada.ca stamps the date two ways: a visible <time property="dateModified">
202
+ # on the WET template, and a <meta name="dcterms.modified" content="..."> on
203
+ # the application-guide pages (which have no visible stamp at all).
204
+ _MODIFIED = re.compile(
205
+ r'dateModified"[^>]*>(\d{4}-\d{2}-\d{2})|'
206
+ r'dcterms\.modified"[^>]*content="(\d{4}-\d{2}-\d{2})"')
207
+ _EFFECTIVE = re.compile(
208
+ r"(?:effective|in effect|takes? effect|comes? into (?:effect|force)|"
209
+ r"came into (?:effect|force))\D{0,40}"
210
+ r"((?:January|February|March|April|May|June|July|August|September|"
211
+ r"October|November|December)\s+\d{1,2},\s+\d{4})", re.I)
212
+ # Site chrome and navigation that sit inside <main> on canada.ca pages.
213
+ _SKIP = {"on this page", "page details", "contacts", "footnotes",
214
+ "language selection", "search", "menu", "you are here:",
215
+ "about this site", "government of canada", "date modified:",
216
+ "report a problem or mistake on this page", "share this page",
217
+ "government of canada activities and initiatives",
218
+ "about government", "topics menu", "search and menus",
219
+ "table of contents",
220
+ # canada.ca form pages: how to run Acrobat, not what the form asks
221
+ "how to download, save and open this form",
222
+ "open this form in acrobat reader",
223
+ # TBS policy-suite chrome
224
+ "supporting tools", "hierarchy", "archives", "more information",
225
+ # the same chrome in French
226
+ "sur cette page", "détails de la page", "table des matières",
227
+ "notes de bas de page", "sélection de la langue", "recherche",
228
+ "vous êtes ici :", "à propos de ce site", "gouvernement du canada",
229
+ "date de modification :", "signaler un problème ou une erreur sur "
230
+ "cette page", "partagez cette page", "activités et initiatives du "
231
+ "gouvernement du canada", "à propos du gouvernement",
232
+ "menu des sujets", "recherche et menus", "outils de soutien",
233
+ "hiérarchie", "renseignements supplémentaires",
234
+ "personnes-ressources", "coordonnées"}
235
+ _HEADINGS = ("h2", "h3", "h4")
236
+ _CONTENT = {"p", "blockquote", "table", "dl", "pre", "figure"}
237
+ _PIECE = 1800
238
+
239
+
240
+ def _own_text(li):
241
+ """A list item's own text, without its nested lists (which are rendered
242
+ as their own bullets): the V&E Code nests '1.1 ...' items under a
243
+ '<strong>Respect For Democracy</strong>' item."""
244
+ return _norm(" ".join(
245
+ child.get_text(" ", strip=True) if getattr(child, "name", None) else str(child)
246
+ for child in li.children
247
+ if getattr(child, "name", None) not in ("ul", "ol")))
248
+
249
+
250
+ def _block_text(heading):
251
+ """Readable text from a heading up to the next heading of any level."""
252
+ lines = []
253
+ for sib in heading.find_next_siblings():
254
+ if sib.name in _HEADINGS or sib.name == "h1":
255
+ break
256
+ if sib.name in ("ul", "ol"):
257
+ for li in sib.find_all("li"):
258
+ item = _own_text(li)
259
+ if item:
260
+ lines.append(f"- {item}")
261
+ elif sib.name == "dl":
262
+ # Definition lists (the Directive's Part 1 is one 9 KB <dl>):
263
+ # one 'term: definition' line each, so the block can split.
264
+ term = ""
265
+ for el in sib.find_all(["dt", "dd"]):
266
+ text = _norm(el.get_text(" ", strip=True))
267
+ if el.name == "dt":
268
+ term = text
269
+ elif text:
270
+ lines.append(f"{term}: {text}" if term else text)
271
+ elif sib.name in _CONTENT:
272
+ text = _norm(sib.get_text(" ", strip=True))
273
+ if text:
274
+ lines.append(text)
275
+ return "\n".join(lines)
276
+
277
+
278
+ def parse(html, doc, url=None, start=0):
279
+ """Chunks for one page of a document: one per h2/h3/h4 heading with body
280
+ text, part = the enclosing h2 (chapter). `start` continues the section
281
+ numbering across a multi-page document. Pure, for tests."""
282
+ url = url or doc.get("url") or doc["urls"][0]
283
+ lang = doc.get("lang", "en")
284
+ prefix = f"policy-fr-{doc['code'].lower()}" if lang == "fr" else \
285
+ f"policy-{doc['code'].lower()}"
286
+ soup = BeautifulSoup(html, "html.parser")
287
+ main = soup.find("main") or soup
288
+ # canada.ca wraps sections in div/section; the TBS policy suite wraps
289
+ # each chapter in <details><summary><h2>..</h2></summary><div>..</div>
290
+ # and appendix headings in <section><header><h3>..</h3></header>.
291
+ # Flatten all of it so each heading and its content become siblings.
292
+ for tag in main.find_all(["section", "div", "article", "details",
293
+ "summary", "header"]):
294
+ tag.unwrap()
295
+ mod = _MODIFIED.search(html)
296
+ # A page without a dateModified stamp (the STCA texts) takes the date
297
+ # the DOCS entry pins -- for a treaty, its in-force date.
298
+ modified = ((mod.group(1) or mod.group(2)) if mod
299
+ else doc.get("current_to", ""))
300
+ eff = _EFFECTIVE.search(main.get_text(" ", strip=True))
301
+ effective = f"Effective {eff.group(1)}" if eff else ""
302
+ chunks, chapter, n = [], "", start
303
+ for h in main.find_all(list(_HEADINGS)):
304
+ title = re.sub(r"\s*\[\d+\]", "", _norm(h.get_text(" ", strip=True)))
305
+ if not title or title.lower() in _SKIP:
306
+ continue
307
+ if h.name == "h2":
308
+ chapter = title
309
+ body = _block_text(h)
310
+ if len(body) < 80:
311
+ continue
312
+ n += 1
313
+ pieces = split_lines(body, _PIECE) if len(body) > 2400 else [body]
314
+ total = len(pieces)
315
+ for k, piece in enumerate(pieces, start=1):
316
+ suffix = "" if total == 1 else f"-p{k}"
317
+ note = "" if total == 1 else f" (part {k} of {total})"
318
+ chunk = {
319
+ "id": f"{prefix}-{n}{suffix}",
320
+ "doc_type": doc.get("doc_type", "policy"),
321
+ "act_code": doc["code"],
322
+ "act_short": doc["short"],
323
+ "act_name": doc["name"],
324
+ "section": title,
325
+ "marginal_note": title,
326
+ "part": chapter if h.name != "h2" else "",
327
+ "division": "",
328
+ "heading": "",
329
+ "text": piece,
330
+ "history": effective,
331
+ "last_amended": modified,
332
+ "in_force": "",
333
+ "status": "in force",
334
+ "current_to": modified,
335
+ "citation": f"{doc['short']}{note}",
336
+ "source_url": url,
337
+ }
338
+ if lang == "fr":
339
+ chunk["lang"] = "fr"
340
+ chunks.append(chunk)
341
+ return chunks
342
+
343
+
344
+ def _fetch_doc(doc, force=False):
345
+ """[(url, html)] for every page of the document."""
346
+ urls = doc.get("urls") or [doc["url"]]
347
+ pages = []
348
+ for i, url in enumerate(urls, start=1):
349
+ raw = doc["raw"] if len(urls) == 1 else f"{doc['raw']}-{i}.html"
350
+ html = fetch_cached(url, RAW / raw, pause=1.0, force=force,
351
+ powershell=doc.get("powershell", False))
352
+ pages.append((url, html.decode("utf-8", "replace")))
353
+ return pages
354
+
355
+
356
+ def build(allow_shrink=False, force=False):
357
+ chunks, failures = [], []
358
+ for doc in DOCS:
359
+ key = (doc["code"], doc.get("lang", "en"))
360
+ try:
361
+ pages = _fetch_doc(doc, force=force)
362
+ except Exception as exc:
363
+ print(f" !! {doc['short']} ({key[1]}): fetch failed "
364
+ f"({type(exc).__name__}: {exc})")
365
+ failures.append(key)
366
+ continue
367
+ got = []
368
+ for url, html in pages:
369
+ got += parse(html, doc, url=url, start=len(got))
370
+ if not got:
371
+ # Fetched, parsed to nothing: the page template moved. Keep the
372
+ # stored copy rather than dropping the instrument.
373
+ print(f" !! {doc['short']} ({key[1]}): no content parsed; "
374
+ f"keeping stored copy")
375
+ failures.append(key)
376
+ continue
377
+ chunks += got
378
+ print(f" {doc['short']} ({key[1]}): {len(got)} chunks, modified "
379
+ f"{got[0]['current_to'] or '?'}")
380
+ chunks, preserved = preserve_dropped(
381
+ chunks, stored_chunks(OUT_FILE),
382
+ key=lambda c: (c["act_code"], c.get("lang", "en")),
383
+ only=set(failures))
384
+ if preserved:
385
+ print(f" kept the last-good copy of: "
386
+ f"{', '.join(f'{c} ({l})' for c, l in preserved)}")
387
+ uniquify_ids(chunks)
388
+ if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
389
+ return False
390
+ print(f"conduct: {len(chunks)} chunks -> {OUT_FILE.name}")
391
+ return chunks
392
+
393
+
394
+ def main():
395
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
396
+ force="--force" in sys.argv) is not False
397
+ sys.exit(0 if ok else 1)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()
canlex/config.py ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paths and legislation sources for CanLex."""
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parent.parent
5
+ DATA_DIR = ROOT / "data"
6
+ RAW_DIR = DATA_DIR / "raw"
7
+ PROCESSED_DIR = DATA_DIR / "processed"
8
+
9
+ # Justice Laws Website XML sources. Each key is the Justice Laws consolidated
10
+ # identifier, used as the XML filename and the local raw/processed filename.
11
+ # To add an instrument, add an entry with a verified xml_url.
12
+ #
13
+ # The Constitution Acts, 1867 to 1982 are intentionally absent: Justice Laws
14
+ # publishes them only as HTML/PDF, with no XML version available.
15
+ SOURCES = {
16
+ # --- Immigration ---
17
+ "I-2.5": {
18
+ "short": "IRPA", "name": "Immigration and Refugee Protection Act", "kind": "act",
19
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-2.5.xml",
20
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-2.5/"},
21
+ "SOR-2002-227": {
22
+ "short": "IRPR", "name": "Immigration and Refugee Protection Regulations",
23
+ "kind": "regulation",
24
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-227.xml",
25
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-227/"},
26
+ "C-29": {
27
+ "short": "Citizenship Act", "name": "Citizenship Act", "kind": "act",
28
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-29.xml",
29
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-29/"},
30
+ # IRB tribunal procedural rules. (SOR-2002-230 was repealed because the
31
+ # Immigration Appeal Division Rules, 2022 -- SOR-2022-277, below --
32
+ # replaced it in January 2023.)
33
+ "SOR-2022-277": {
34
+ "short": "IAD Rules", "name": "Immigration Appeal Division Rules, 2022",
35
+ "kind": "regulation",
36
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2022-277.xml",
37
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2022-277/"},
38
+ "SOR-2002-229": {
39
+ "short": "Immigration Division Rules", "name": "Immigration Division Rules",
40
+ "kind": "regulation",
41
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-229.xml",
42
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-229/"},
43
+ "SOR-2012-256": {
44
+ "short": "RPD Rules", "name": "Refugee Protection Division Rules",
45
+ "kind": "regulation",
46
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-256.xml",
47
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-256/"},
48
+ "SOR-2012-257": {
49
+ "short": "RAD Rules", "name": "Refugee Appeal Division Rules",
50
+ "kind": "regulation",
51
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-257.xml",
52
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-257/"},
53
+
54
+ # --- Customs & trade ---
55
+ "C-52.6": {
56
+ "short": "Customs Act", "name": "Customs Act", "kind": "act",
57
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-52.6.xml",
58
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-52.6/"},
59
+ # skip_schedules: the Customs Tariff's schedule is the entire tariff
60
+ # nomenclature -- tariff_schedule.py serves chapters 98/99 from the CBSA
61
+ # edition instead, so the XML schedule would only duplicate it at scale.
62
+ "C-54.011": {
63
+ "short": "Customs Tariff", "name": "Customs Tariff", "kind": "act",
64
+ "skip_schedules": True,
65
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-54.011.xml",
66
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-54.011/"},
67
+ # Traveller/trade instruments under the Customs Act and EIPA. The two
68
+ # control lists are almost entirely Schedules, so they depend on the
69
+ # schedule parser; Presentation of Persons is the legal basis for NEXUS
70
+ # and every other alternative-presentation program.
71
+ "E-14.1": {
72
+ "short": "Excise Act, 2001", "name": "Excise Act, 2001", "kind": "act",
73
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-14.1.xml",
74
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-14.1/"},
75
+ # The Income Tax Act: added 2026-09-10 so the equivalency table's
76
+ # tax-evasion pairing (US 26 USC 7201 / 7206 -> ITA s. 239) rests on
77
+ # corpus-verified text like every other maximum. The largest instrument
78
+ # in the corpus (~14 MB of XML per language); its vocabulary ('resident',
79
+ # 'employee', 'benefit', 'duty', 'trust') overlaps the border and labour
80
+ # families, so it is measured question by question at every ingest.
81
+ "I-3.3": {
82
+ "short": "ITA", "name": "Income Tax Act", "kind": "act",
83
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-3.3.xml",
84
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-3.3/"},
85
+ "CRC-604": {
86
+ "short": "Import Control List", "name": "Import Control List",
87
+ "kind": "regulation",
88
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.,_c._604.xml",
89
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.,_c._604/"},
90
+ "SOR-89-202": {
91
+ "short": "Export Control List", "name": "Export Control List",
92
+ "kind": "regulation",
93
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-89-202.xml",
94
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-89-202/"},
95
+ "SOR-2003-323": {
96
+ "short": "Presentation of Persons Regs",
97
+ "name": "Presentation of Persons (2003) Regulations", "kind": "regulation",
98
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-323.xml",
99
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-323/"},
100
+ "SOR-86-1072": {
101
+ "short": "Duty Free Shop Regs", "name": "Duty Free Shop Regulations",
102
+ "kind": "regulation",
103
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1072.xml",
104
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1072/"},
105
+ "SOR-86-1065": {
106
+ "short": "Sufferance Warehouses Regs",
107
+ "name": "Customs Sufferance Warehouses Regulations", "kind": "regulation",
108
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1065.xml",
109
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1065/"},
110
+ "S-8.35": {
111
+ "short": "Luxury Tax Act", "name": "Select Luxury Items Tax Act", "kind": "act",
112
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-8.35.xml",
113
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-8.35/"},
114
+ "C-1.4": {
115
+ "short": "CBSA Act", "name": "Canada Border Services Agency Act", "kind": "act",
116
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-1.4.xml",
117
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"},
118
+ "S-15": {
119
+ "short": "SIMA", "name": "Special Import Measures Act", "kind": "act",
120
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-15.xml",
121
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-15/"},
122
+ "E-19": {
123
+ "short": "EIPA", "name": "Export and Import Permits Act", "kind": "act",
124
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-19.xml",
125
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-19/"},
126
+ "P-19.32": {
127
+ "short": "Preclearance Act", "name": "Preclearance Act, 2016", "kind": "act",
128
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-19.32.xml",
129
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-19.32/"},
130
+ # Customs Act operational regulations -- the procedural detail behind the
131
+ # bare Act (reporting, accounting, exemptions, and the designated-provisions
132
+ # regulation that is the legal hook for every AMPS monetary penalty).
133
+ "SOR-86-873": {
134
+ "short": "Reporting of Imported Goods Regs",
135
+ "name": "Reporting of Imported Goods Regulations", "kind": "regulation",
136
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-873.xml",
137
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-873/"},
138
+ "SOR-86-1062": {
139
+ "short": "Accounting for Imported Goods Regs",
140
+ "name": "Accounting for Imported Goods and Payment of Duties Regulations",
141
+ "kind": "regulation",
142
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1062.xml",
143
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1062/"},
144
+ "SOR-2002-336": {
145
+ "short": "Designated Provisions (Customs) Regs",
146
+ "name": "Designated Provisions (Customs) Regulations", "kind": "regulation",
147
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-336.xml",
148
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-336/"},
149
+ "SOR-96-46": {
150
+ "short": "Customs Bonded Warehouses Regs",
151
+ "name": "Customs Bonded Warehouses Regulations", "kind": "regulation",
152
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-46.xml",
153
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-46/"},
154
+ "SOR-98-58": {
155
+ "short": "Returning Persons Exemption Regs",
156
+ "name": "Returning Persons Exemption Regulations", "kind": "regulation",
157
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-58.xml",
158
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-58/"},
159
+
160
+ # --- Criminal & financial crime ---
161
+ "C-46": {
162
+ "short": "Criminal Code", "name": "Criminal Code", "kind": "act",
163
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-46.xml",
164
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-46/"},
165
+ "F-11.6": {
166
+ "short": "Firearms Act", "name": "Firearms Act", "kind": "act",
167
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.6.xml",
168
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11.6/"},
169
+ # Made under Criminal Code s. 117.15: the model-by-model prohibited/
170
+ # restricted/non-restricted classifications (incl. the May 2020
171
+ # SOR/2020-96 prohibitions). Nearly all substance is in its Schedules --
172
+ # this entry is why the schedule parser exists.
173
+ "SOR-98-462": {
174
+ "short": "Firearms Classification Regs",
175
+ "name": ("Regulations Prescribing Certain Firearms and Other Weapons, "
176
+ "Components and Parts of Weapons, Accessories, Cartridge "
177
+ "Magazines, Ammunition and Projectiles as Prohibited, "
178
+ "Restricted or Non-Restricted"), "kind": "regulation",
179
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-462.xml",
180
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-462/"},
181
+ "P-24.501": {
182
+ "short": "PCMLTFA",
183
+ "name": "Proceeds of Crime (Money Laundering) and Terrorist Financing Act",
184
+ "kind": "act",
185
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-24.501.xml",
186
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-24.501/"},
187
+ "SOR-2002-412": {
188
+ "short": "Cross-border Currency Reporting Regs",
189
+ "name": "Cross-border Currency and Monetary Instruments Reporting Regulations",
190
+ "kind": "regulation",
191
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-412.xml",
192
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-412/"},
193
+ "S-6.9": {
194
+ "short": "SCIDA", "name": "Security of Canada Information Disclosure Act",
195
+ "kind": "act",
196
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.9.xml",
197
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.9/"},
198
+
199
+ # --- Controlled substances & cannabis ---
200
+ "C-38.8": {
201
+ "short": "CDSA", "name": "Controlled Drugs and Substances Act", "kind": "act",
202
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.8.xml",
203
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.8/"},
204
+ "SOR-97-234": {
205
+ "short": "CDSA Police Enforcement Regs",
206
+ "name": "Controlled Drugs and Substances Act (Police Enforcement) Regulations",
207
+ "kind": "regulation",
208
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-234.xml",
209
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-234/"},
210
+ "SOR-2000-217": {
211
+ "short": "Benzodiazepines Regs",
212
+ "name": "Benzodiazepines and Other Targeted Substances Regulations",
213
+ "kind": "regulation",
214
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2000-217.xml",
215
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2000-217/"},
216
+ "C.R.C.,_c._1041": {
217
+ "short": "Narcotic Control Regs", "name": "Narcotic Control Regulations",
218
+ "kind": "regulation",
219
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._1041.xml",
220
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._1041/"},
221
+ "SOR-2012-230": {
222
+ "short": "New Classes of Practitioners Regs",
223
+ "name": "New Classes of Practitioners Regulations", "kind": "regulation",
224
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-230.xml",
225
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-230/"},
226
+ "SOR-2002-359": {
227
+ "short": "Precursor Control Regs", "name": "Precursor Control Regulations",
228
+ "kind": "regulation",
229
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-359.xml",
230
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-359/"},
231
+ "SOR-97-229": {
232
+ "short": "Precursor & Substance Exemption Regs",
233
+ "name": "Regulations Exempting Certain Precursors and Controlled Substances "
234
+ "from the Application of the Controlled Drugs and Substances Act",
235
+ "kind": "regulation",
236
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-229.xml",
237
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-229/"},
238
+ "C-24.5": {
239
+ "short": "Cannabis Act", "name": "Cannabis Act", "kind": "act",
240
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-24.5.xml",
241
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-24.5/"},
242
+ "SOR-2018-144": {
243
+ "short": "Cannabis Regs", "name": "Cannabis Regulations", "kind": "regulation",
244
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-144.xml",
245
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-144/"},
246
+
247
+ # --- Food, health, agriculture ---
248
+ "F-27": {
249
+ "short": "Food and Drugs Act", "name": "Food and Drugs Act", "kind": "act",
250
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-27.xml",
251
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-27/"},
252
+ "C.R.C.,_c._870": {
253
+ "short": "Food and Drug Regs", "name": "Food and Drug Regulations",
254
+ "kind": "regulation",
255
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._870.xml",
256
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._870/"},
257
+ "H-3.3": {
258
+ "short": "Health of Animals Act", "name": "Health of Animals Act", "kind": "act",
259
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-3.3.xml",
260
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-3.3/"},
261
+ "C.R.C.,_c._296": {
262
+ "short": "Health of Animals Regs", "name": "Health of Animals Regulations",
263
+ "kind": "regulation",
264
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._296.xml",
265
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._296/"},
266
+ "P-14.8": {
267
+ "short": "Plant Protection Act", "name": "Plant Protection Act", "kind": "act",
268
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-14.8.xml",
269
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-14.8/"},
270
+ "SOR-95-212": {
271
+ "short": "Plant Protection Regs", "name": "Plant Protection Regulations",
272
+ "kind": "regulation",
273
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-95-212.xml",
274
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-95-212/"},
275
+ "SOR-2018-108": {
276
+ "short": "Safe Food for Canadians Regs",
277
+ "name": "Safe Food for Canadians Regulations", "kind": "regulation",
278
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-108.xml",
279
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-108/"},
280
+ "A-8.8": {
281
+ "short": "AAAMPA",
282
+ "name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
283
+ "kind": "act",
284
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-8.8.xml",
285
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/"},
286
+ "Q-1.1": {
287
+ "short": "Quarantine Act", "name": "Quarantine Act", "kind": "act",
288
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Q-1.1.xml",
289
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/q-1.1/"},
290
+ # CITES enforcement at the border -- the basis for seizures of protected
291
+ # wildlife, ivory and exotic-species goods.
292
+ "W-8.5": {
293
+ "short": "WAPPRIITA",
294
+ "name": "Wild Animal and Plant Protection and Regulation of "
295
+ "International and Interprovincial Trade Act", "kind": "act",
296
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/W-8.5.xml",
297
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/w-8.5/"},
298
+ "SOR-96-263": {
299
+ "short": "WAPTR",
300
+ "name": "Wild Animal and Plant Trade Regulations", "kind": "regulation",
301
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-263.xml",
302
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-263/"},
303
+
304
+ # --- Labour & public service ---
305
+ # CHRA grounds the accommodation/discrimination case law already in the
306
+ # corpus (Renaud, Hydro-Québec, Elk Valley); GECA governs injury-on-duty
307
+ # compensation interlocking with the FB agreement's IOD leave.
308
+ "H-6": {
309
+ "short": "CHRA", "name": "Canadian Human Rights Act", "kind": "act",
310
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-6.xml",
311
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-6/"},
312
+ "G-5": {
313
+ "short": "GECA", "name": "Government Employees Compensation Act",
314
+ "kind": "act",
315
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/G-5.xml",
316
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/g-5/"},
317
+ "L-2": {
318
+ "short": "Canada Labour Code", "name": "Canada Labour Code", "kind": "act",
319
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/L-2.xml",
320
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/l-2/"},
321
+ # The whole federal harassment/violence process (notice of occurrence,
322
+ # designated recipient, resolution, investigation, timelines) lives in
323
+ # these Regulations since 2021, not in CLC Part II itself; Marentette
324
+ # 2024 FC 676 (in the corpus) interprets them.
325
+ "SOR-2020-130": {
326
+ "short": "WPHV Regs",
327
+ "name": "Work Place Harassment and Violence Prevention Regulations",
328
+ "kind": "regulation",
329
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2020-130.xml",
330
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2020-130/"},
331
+ # Part II of the Code binds the federal public administration (s. 123(2));
332
+ # these two Regulations give its duties their content -- first aid,
333
+ # lighting, sanitation, hazardous substances, confined spaces (COHSR) --
334
+ # and constitute the policy/work-place committees a steward sits on.
335
+ "SOR-86-304": {
336
+ "short": "COHSR",
337
+ "name": "Canada Occupational Health and Safety Regulations",
338
+ "kind": "regulation",
339
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-304.xml",
340
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-304/"},
341
+ "SOR-2015-164": {
342
+ "short": "OHS Committees Regs",
343
+ "name": "Policy Committees, Work Place Committees and Health and "
344
+ "Safety Representatives Regulations",
345
+ "kind": "regulation",
346
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2015-164.xml",
347
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2015-164/"},
348
+ "P-33.3": {
349
+ "short": "FPSLRA", "name": "Federal Public Sector Labour Relations Act",
350
+ "kind": "act",
351
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.3.xml",
352
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.3/"},
353
+ "P-33.35": {
354
+ "short": "FPSLREB Act",
355
+ "name": "Federal Public Sector Labour Relations and Employment Board Act",
356
+ "kind": "act",
357
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.35.xml",
358
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.35/"},
359
+ "P-33.01": {
360
+ "short": "PSEA", "name": "Public Service Employment Act", "kind": "act",
361
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.01.xml",
362
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.01/"},
363
+ "P-36": {
364
+ "short": "PSSA", "name": "Public Service Superannuation Act", "kind": "act",
365
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-36.xml",
366
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-36/"},
367
+
368
+ # --- Privacy ---
369
+ # --- Public-service labour relations, discipline and disclosure ---
370
+ # The FPSLRA hands grievance time limits and adjudication procedure to
371
+ # its Regulations; FAA s. 12 is the legal authority for discipline in a
372
+ # department (and s. 155 the set-off behind overpayment recovery); the
373
+ # PSDPA underlies the CBSA Code's disclosure chapter and the V&E Code.
374
+ "SOR-2005-79": {
375
+ "short": "FPSLR Regulations",
376
+ "name": "Federal Public Sector Labour Relations Regulations",
377
+ "kind": "regulation",
378
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-79.xml",
379
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-79/"},
380
+ "SOR-2005-334": {
381
+ "short": "PSER",
382
+ "name": "Public Service Employment Regulations",
383
+ "kind": "regulation",
384
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-334.xml",
385
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-334/"},
386
+ "F-11": {
387
+ "short": "FAA",
388
+ "name": "Financial Administration Act",
389
+ "kind": "act",
390
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.xml",
391
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11/"},
392
+ "P-31.9": {
393
+ "short": "PSDPA",
394
+ "name": "Public Servants Disclosure Protection Act",
395
+ "kind": "act",
396
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-31.9.xml",
397
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-31.9/"},
398
+ "O-3.01": {
399
+ "short": "OLA",
400
+ "name": "Official Languages Act",
401
+ "kind": "act",
402
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/O-3.01.xml",
403
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/o-3.01/"},
404
+ "I-21": {
405
+ "short": "Interpretation Act",
406
+ "name": "Interpretation Act",
407
+ "kind": "act",
408
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-21.xml",
409
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-21/"},
410
+ # --- Criminal records and youth: the other half of IRPA s. 36(3)(b),
411
+ # which lifts inadmissibility only while a record suspension 'has not
412
+ # ceased to have effect or been revoked' under the Criminal Records Act.
413
+ "C-47": {
414
+ "short": "Criminal Records Act",
415
+ "name": "Criminal Records Act",
416
+ "kind": "act",
417
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-47.xml",
418
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-47/",
419
+ "skip_schedules": True}, # its offence-list schedules are keyword magnets
420
+ "Y-1.5": {
421
+ "short": "YCJA",
422
+ "name": "Youth Criminal Justice Act",
423
+ "kind": "act",
424
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Y-1.5.xml",
425
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/y-1.5/"},
426
+ # --- Contraventions: IRPA s. 36(3)(e)(i) bars basing s. 36 inadmissibility
427
+ # on an offence 'designated as a contravention under the Contraventions
428
+ # Act'. The designations are the Regulations' schedules (skip_schedules
429
+ # stays off), so both are needed to check that caveat against corpus
430
+ # text. Added 2026-09-12 (roadmap T3.8).
431
+ "C-38.7": {
432
+ "short": "Contraventions Act",
433
+ "name": "Contraventions Act",
434
+ "kind": "act",
435
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.7.xml",
436
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.7/"},
437
+ "SOR-96-313": {
438
+ "short": "Contraventions Regs",
439
+ "name": "Contraventions Regulations",
440
+ "kind": "regulation",
441
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-313.xml",
442
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-313/"},
443
+ # --- Sanctions: IRPA s. 35(1)(c)-(e) inadmissibility is defined by
444
+ # reference to these Acts, and the CBSA enforces sanctioned goods.
445
+ "S-14.5": {
446
+ "short": "SEMA",
447
+ "name": "Special Economic Measures Act",
448
+ "kind": "act",
449
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-14.5.xml",
450
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-14.5/"},
451
+ "U-2": {
452
+ "short": "United Nations Act",
453
+ "name": "United Nations Act",
454
+ "kind": "act",
455
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/U-2.xml",
456
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/u-2/"},
457
+ "J-2.3": {
458
+ "short": "JVCFOA",
459
+ "name": "Justice for Victims of Corrupt Foreign Officials Act (Sergei Magnitsky Law)",
460
+ "kind": "act",
461
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/J-2.3.xml",
462
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/j-2.3/"},
463
+ # --- Border regulations the D-memos and Preclearance Act point at ---
464
+ "SOR-2019-183": {
465
+ "short": "Preclearance in Canada Regs",
466
+ "name": "Preclearance in Canada Regulations",
467
+ "kind": "regulation",
468
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2019-183.xml",
469
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2019-183/"},
470
+ "SOR-2003-219": {
471
+ "short": "Passenger Information Regs",
472
+ "name": "Passenger Information (Customs) Regulations",
473
+ "kind": "regulation",
474
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-219.xml",
475
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-219/"},
476
+ "SOR-2005-23": {
477
+ "short": "Reporting of Exported Goods Regs",
478
+ "name": "Reporting of Exported Goods Regulations",
479
+ "kind": "regulation",
480
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-23.xml",
481
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-23/"},
482
+ "SOR-98-209": {
483
+ "short": "Firearms Storage & Transport Regs",
484
+ "name": "Storage, Display, Transportation and Handling of Firearms by Individuals Regulations",
485
+ "kind": "regulation",
486
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-209.xml",
487
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-209/"},
488
+ # --- Aviation security: the airport side of an officer's work. The
489
+ # Preclearance Act and its Regulations point at the aerodromes listed in
490
+ # the CASR 2012; the Aeronautics Act is their enabling Act; the Secure Air
491
+ # Travel Act and Regulations are the 'no-fly' list regime. The Identity
492
+ # Screening Regulations (SOR/2007-82) they replaced are wholly repealed
493
+ # (SOR/2015-182, s. 2) and are not added. Added 2026-10-12.
494
+ "A-2": {
495
+ "short": "Aeronautics Act", "name": "Aeronautics Act", "kind": "act",
496
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-2.xml",
497
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-2/"},
498
+ "SOR-2011-318": {
499
+ "short": "CASR 2012",
500
+ "name": "Canadian Aviation Security Regulations, 2012",
501
+ "kind": "regulation",
502
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2011-318.xml",
503
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2011-318/"},
504
+ "S-6.7": {
505
+ "short": "SATA", "name": "Secure Air Travel Act", "kind": "act",
506
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.7.xml",
507
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.7/"},
508
+ "SOR-2015-181": {
509
+ "short": "Secure Air Travel Regs",
510
+ "name": "Secure Air Travel Regulations",
511
+ "kind": "regulation",
512
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2015-181.xml",
513
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2015-181/"},
514
+ "P-21": {
515
+ "short": "Privacy Act", "name": "Privacy Act", "kind": "act",
516
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-21.xml",
517
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-21/"},
518
+ }
canlex/curated_checks.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The curated tables' statute phrases, checked against the corpus (roadmap T4.3).
2
+
3
+ The curated tables pin the statute text they rely on with `verify` rows --
4
+ (act, section, contains): the phrase must appear in that section as
5
+ ingested. This module is the one implementation of that check. It is used by
6
+ tests/test_curated_penalties.py and by `py -m canlex.refresh --check`
7
+ (refresh.check_curated_sections), and is meant for tools/predeploy.py's gate
8
+ (run_checks + gate). The refresh-drift helpers corpus_reader, verify_sections
9
+ and refresh_drift moved here from tools/predeploy.py (wave 2); until predeploy
10
+ and tools/compose_release.py import them from here, predeploy carries its own
11
+ copy, and tests/test_refresh.py checks that the two agree.
12
+
13
+ After a corpus refresh a verify row can show two signals:
14
+
15
+ - broken: the phrase is no longer in its section, or the section is gone.
16
+ The table quotes text the statute no longer has.
17
+ - amended: the section's last_amended (the corpus's own amendment date, read
18
+ with the section's `history` citation) is later than the date the review
19
+ saw. That seen date is the section's last_amended in the corpus of the
20
+ approved commit (data/curated/review_state.json), so `last_amended_seen`
21
+ advances when the maintainer approves a new baseline. It is reported per
22
+ verify row and never written into the curated JSON, which is held for
23
+ review.
24
+
25
+ When an amended section's stated maximum changed in a subsection a verify row
26
+ quotes, draft_history proposes a penalty_history row: the maximum before the
27
+ amendment, until the day before the amendment. Under Tran, an offence
28
+ committed before the amendment keeps the maximum then in force. No row is
29
+ drafted when a row the pairing already carries covers the amendment. The
30
+ drafts go to data/curated/drafts/ for review. Nothing here edits
31
+ data/curated/*.json.
32
+ """
33
+ import datetime
34
+ import json
35
+ import re
36
+ import subprocess
37
+ from pathlib import Path
38
+
39
+ ROOT = Path(__file__).resolve().parent.parent
40
+ CURATED = ROOT / "data" / "curated"
41
+ DRAFTS = CURATED / "drafts"
42
+ REVIEW_STATE = CURATED / "review_state.json"
43
+ EQUIVALENCY = "us_equivalency.json"
44
+ TRANSBORDER = "transborder.json"
45
+ DRAFT_GLOB = "penalty-history-draft-*.json"
46
+
47
+ PASS, WARN, FAIL = "PASS", "WARN", "FAIL" # tools/predeploy.py's statuses
48
+
49
+
50
+ # ---- corpus access (working tree or a git commit) -----------------------------
51
+
52
+ def git_blob(commit, path, root=ROOT):
53
+ """The bytes of `path` at `commit`, or None when git cannot show it."""
54
+ try:
55
+ out = subprocess.run(["git", "-C", str(root), "show", f"{commit}:{path}"],
56
+ capture_output=True, timeout=300)
57
+ except (OSError, subprocess.SubprocessError):
58
+ return None
59
+ return out.stdout if out.returncode == 0 else None
60
+
61
+
62
+ def lfs_content(blob, root=ROOT):
63
+ """The content an LFS pointer names, read from the LOCAL LFS store (never
64
+ fetched), or None when it is not there. A non-pointer blob is returned
65
+ as is (the same rule as tools/predeploy.py lfs_object)."""
66
+ if blob is None or not blob.startswith(b"version https://git-lfs"):
67
+ return blob
68
+ oid = next((l.split(b":", 1)[1].strip().decode() for l in blob.splitlines()
69
+ if l.startswith(b"oid sha256:")), None)
70
+ if not oid:
71
+ return None
72
+ try:
73
+ out = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"],
74
+ capture_output=True, timeout=60)
75
+ except (OSError, subprocess.SubprocessError):
76
+ return None
77
+ common = Path(out.stdout.decode().strip())
78
+ if not common.is_absolute():
79
+ common = Path(root) / common
80
+ path = common / "lfs" / "objects" / oid[:2] / oid[2:4] / oid
81
+ return path.read_bytes() if path.exists() else None
82
+
83
+
84
+ def _chunks(blob, root=ROOT):
85
+ blob = lfs_content(blob, root)
86
+ if blob is None:
87
+ return []
88
+ try:
89
+ return json.loads(blob.decode("utf-8"))
90
+ except ValueError:
91
+ return []
92
+
93
+
94
+ def squash(text):
95
+ return re.sub(r"\s+", " ", text or "")
96
+
97
+
98
+ class Corpus:
99
+ """data/processed read per act: the working tree (commit None), a git
100
+ commit (`git show`, LFS pointers read from the local store), or a dict of
101
+ chunks (tests)."""
102
+
103
+ def __init__(self, commit=None, root=ROOT, chunks=None):
104
+ self.commit, self.root = commit, Path(root)
105
+ self._cache = dict(chunks) if chunks is not None else {}
106
+ self._fixed = chunks is not None
107
+
108
+ def chunks(self, act):
109
+ if act not in self._cache:
110
+ if self._fixed:
111
+ return []
112
+ path = f"data/processed/{act}.json"
113
+ if self.commit is None:
114
+ p = self.root / path
115
+ blob = p.read_bytes() if p.exists() else None
116
+ else:
117
+ blob = git_blob(self.commit, path, self.root)
118
+ self._cache[act] = _chunks(blob, self.root)
119
+ return self._cache[act]
120
+
121
+ def section(self, act, section):
122
+ return [c for c in self.chunks(act) if str(c.get("section")) == str(section)]
123
+
124
+ def raw_text(self, act, section):
125
+ """The section's text with its line breaks (subsections start a line)."""
126
+ return "\n".join(c.get("text", "") for c in self.section(act, section))
127
+
128
+ def text(self, act, section):
129
+ """Whitespace squashed: the text a verify phrase is checked against."""
130
+ return squash(" ".join(c.get("text", "") for c in self.section(act, section)))
131
+
132
+ __call__ = text
133
+
134
+ def last_amended(self, act, section):
135
+ return max((c.get("last_amended") or "" for c in self.section(act, section)),
136
+ default="")
137
+
138
+ def amended_by(self, act, section):
139
+ """The last citation in the section's history line ('2026, c. 19, s. 23')."""
140
+ hist = next((c.get("history") for c in self.section(act, section)
141
+ if c.get("history")), "")
142
+ return hist.split(";")[-1].strip() if hist else ""
143
+
144
+
145
+ def corpus_reader(commit=None, cwd=ROOT):
146
+ """section_text(act, section) over a commit's data/processed (None: the
147
+ working tree at cwd), whitespace squashed: the text a curated verify
148
+ phrase is checked against. Signature kept from tools/predeploy.py."""
149
+ return Corpus(commit, cwd).text
150
+
151
+
152
+ # ---- verify rows ----------------------------------------------------------------
153
+
154
+ def verify_rows(table, file=EQUIVALENCY):
155
+ """Every verify row in a curated table, flattened: a pairing's and its
156
+ limbs' (us_equivalency.json) or a row's (transborder.json). Each is
157
+ {file, owner, limb, act, section, contains}."""
158
+ out = []
159
+
160
+ def add(owner, limb, v):
161
+ out.append({"file": file, "owner": owner, "limb": limb, "act": v["act"],
162
+ "section": str(v["section"]), "contains": v["contains"]})
163
+
164
+ for p in (table or {}).get("pairings") or []:
165
+ for v in p.get("verify") or []:
166
+ add(p.get("id"), None, v)
167
+ for l in p.get("limbs") or []:
168
+ if isinstance(l, dict):
169
+ for v in l.get("verify") or []:
170
+ add(p.get("id"), l.get("key"), v)
171
+ for r in (table or {}).get("rows") or []:
172
+ for v in r.get("verify") or []:
173
+ add(r.get("id"), None, v)
174
+ return out
175
+
176
+
177
+ def phrase_in(phrase, text):
178
+ """The phrase, whitespace squashed, occurs in the text (case-insensitive)."""
179
+ return " ".join((phrase or "").split()).lower() in (text or "").lower()
180
+
181
+
182
+ def where(row):
183
+ return f"{row['owner']}" + (f" limb {row['limb']}" if row.get("limb") else "")
184
+
185
+
186
+ def problems(rows, corpus):
187
+ """The broken rows as messages: '<owner>: <act> s. <section> lacks '<phrase>''
188
+ (or 'not in corpus'). [] when every phrase is in its section."""
189
+ out = []
190
+ for r in rows:
191
+ text = corpus.text(r["act"], r["section"])
192
+ if not text:
193
+ out.append(f"{where(r)}: {r['act']} s. {r['section']} not in corpus")
194
+ elif not phrase_in(r["contains"], text):
195
+ out.append(f"{where(r)}: {r['act']} s. {r['section']} lacks {r['contains']!r}")
196
+ return out
197
+
198
+
199
+ # ---- refresh drift (moved from tools/predeploy.py, wave 2 verifier) -----------
200
+
201
+ def verify_sections(pairing):
202
+ """{(act, section): the maxima the pairing's verify rows (and its limbs')
203
+ quote for that section} -- canlex.screening.maxima_stated on each
204
+ phrase."""
205
+ from .screening import maxima_stated
206
+ out = {}
207
+ rows = list(pairing.get("verify") or []) + [
208
+ v for l in pairing.get("limbs") or [] if isinstance(l, dict) for v in l.get("verify") or []]
209
+ for v in rows:
210
+ out.setdefault((v["act"], v["section"]), set()).update(maxima_stated(v.get("contains")))
211
+ return out
212
+
213
+
214
+ def refresh_drift(equivalency, now_text, then_text):
215
+ """The curated table against a corpus refresh: (drift, changed).
216
+
217
+ drift: [(pairing id, act, section, sorted new maxima)] -- a maximum a
218
+ verify section states NOW that it did not state in the corpus the table
219
+ was approved against, and that the pairing does not quote (an amendment
220
+ the approved text cannot know: 2026, c. 19 raised s. 173(2)(a) to 10
221
+ years). A composed release shipping such a table ships stale law.
222
+ changed: [(pairing id, act, section)] -- every verify section whose text
223
+ changed at all (re-read before relying on the approved prose)."""
224
+ from .screening import maxima_stated
225
+ drift, changed = [], []
226
+ for p in (equivalency or {}).get("pairings") or []:
227
+ for (act, sec), quoted in sorted(verify_sections(p).items()):
228
+ now, then = now_text(act, sec), then_text(act, sec)
229
+ if not now or not then:
230
+ continue
231
+ if now != then:
232
+ changed.append((p["id"], act, sec))
233
+ new = maxima_stated(now) - maxima_stated(then) - quoted
234
+ if new:
235
+ drift.append((p["id"], act, sec, sorted(new)))
236
+ return drift, changed
237
+
238
+
239
+ # ---- amendments and the maximum they changed ------------------------------------
240
+
241
+ _SUBSECTION = re.compile(r"\n\((\d+(?:\.\d+)*)\)\s")
242
+
243
+
244
+ def subsections(raw):
245
+ """{label: text} of a section's subsections as the corpus prints them (a
246
+ line opening '(1) ', '(2.1) '); the text before the first one, or a
247
+ section with none, is labelled ''. A section split over several chunks
248
+ is read as one text, so a chunk continuing a subsection stays in it."""
249
+ marks = list(_SUBSECTION.finditer(raw or ""))
250
+ if not marks:
251
+ return {"": raw or ""}
252
+ out = {}
253
+ head = raw[:marks[0].start()]
254
+ if head.strip():
255
+ out[""] = head
256
+ for i, m in enumerate(marks):
257
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
258
+ out[m.group(1)] = out.get(m.group(1), "") + raw[m.start():end]
259
+ return out
260
+
261
+
262
+ def maxima_seq(text):
263
+ """The maxima a text states, in order (canlex.screening.maxima_stated on
264
+ each stated term), so that a maximum moving between provisions shows."""
265
+ from . import screening as sc
266
+ return [x for m in sc._STATED_MAX.finditer((text or "").lower())
267
+ for x in sorted(sc.maxima_stated(m.group(0)))]
268
+
269
+
270
+ def maximum_change(phrase, then_raw, now_raw):
271
+ """(changed, before, after, added, removed) for the subsections that hold
272
+ `phrase` then or now (every subsection when neither does).
273
+
274
+ changed: labels printed both then and now whose stated maxima differ as
275
+ sets, and only when the section's maxima as a whole differ (a
276
+ subsection split or renumbered around the same maxima is not a change).
277
+ before / after: the maxima those labels stated then / now.
278
+ added / removed: quoted labels only in the new / only in the old text,
279
+ a provision created or repealed, never read as a changed maximum."""
280
+ from collections import Counter
281
+ then_s, now_s = subsections(then_raw), subsections(now_raw)
282
+ labels = sorted({l for l, t in then_s.items() if phrase_in(phrase, squash(t))}
283
+ | {l for l, t in now_s.items() if phrase_in(phrase, squash(t))})
284
+ if not labels:
285
+ labels = sorted(set(then_s) | set(now_s))
286
+ added = [l for l in labels if l not in then_s]
287
+ removed = [l for l in labels if l not in now_s]
288
+ # The unnumbered label is the whole section in a version without
289
+ # subsections, but only the number and head words in one with them: it is
290
+ # compared only when neither version numbers its subsections.
291
+ numbered = bool(set(then_s) - {""}) or bool(set(now_s) - {""})
292
+ changed = []
293
+ if Counter(maxima_seq(then_raw)) != Counter(maxima_seq(now_raw)):
294
+ changed = [l for l in labels if l in then_s and l in now_s
295
+ and not (l == "" and numbered)
296
+ and set(maxima_seq(then_s[l])) != set(maxima_seq(now_s[l]))]
297
+ before = sorted({x for l in changed for x in maxima_seq(then_s[l])})
298
+ after = sorted({x for l in changed for x in maxima_seq(now_s[l])})
299
+ return changed, before, after, added, removed
300
+
301
+
302
+ def check_rows(rows, now, then=None):
303
+ """Each verify row with its report fields:
304
+
305
+ status 'broken' (phrase or section gone) > 'amended' > 'ok'
306
+ last_amended the section's last_amended in the corpus now
307
+ last_amended_seen the same in `then` (the approved corpus); '' when there
308
+ is no baseline or the section was not in it
309
+ amended last_amended is later than last_amended_seen
310
+ amended_by the last citation in the section's history line
311
+ subsections for an amended row: the quoted subsections whose stated
312
+ maximum changed ([] = the amendment left it alone)
313
+ maxima_before / maxima_after what those subsections stated then / now
314
+ provisions_added / provisions_removed quoted subsections that exist
315
+ only now / only then (created or repealed: re-read)
316
+ """
317
+ out = []
318
+ for r in rows:
319
+ act, sec = r["act"], r["section"]
320
+ text = now.text(act, sec)
321
+ rep = dict(r, status="ok", reason="", last_amended=now.last_amended(act, sec),
322
+ last_amended_seen="", amended=False, amended_by="",
323
+ subsections=[], maxima_before=[], maxima_after=[],
324
+ provisions_added=[], provisions_removed=[])
325
+ if not text:
326
+ rep.update(status="broken", reason="section not in corpus")
327
+ elif not phrase_in(r["contains"], text):
328
+ rep.update(status="broken", reason="phrase not in the section")
329
+ if then is not None and then.section(act, sec):
330
+ seen = then.last_amended(act, sec)
331
+ rep["last_amended_seen"] = seen
332
+ if seen and rep["last_amended"] > seen:
333
+ labels, before, after, added, removed = maximum_change(
334
+ r["contains"], then.raw_text(act, sec), now.raw_text(act, sec))
335
+ rep.update(amended=True, amended_by=now.amended_by(act, sec),
336
+ subsections=labels, maxima_before=before, maxima_after=after,
337
+ provisions_added=added, provisions_removed=removed)
338
+ if rep["status"] == "ok":
339
+ rep["status"] = "amended"
340
+ out.append(rep)
341
+ return out
342
+
343
+
344
+ def _day_before(iso):
345
+ return (datetime.date.fromisoformat(iso) - datetime.timedelta(days=1)).isoformat()
346
+
347
+
348
+ def _years(m):
349
+ return "life" if m is not None and m >= 99 else f"{m} years"
350
+
351
+
352
+ def pit_url(act, section, start):
353
+ """The Justice Laws point-in-time page of the version that began on
354
+ `start` (a candidate address, not fetched)."""
355
+ kind = "regulations" if act.upper().startswith(("SOR-", "C.R.C.", "CRC")) else "acts"
356
+ return (f"https://laws-lois.justice.gc.ca/eng/{kind}/{act}/section-{section}-"
357
+ f"{start.replace('-', '')}.html")
358
+
359
+
360
+ def draft_history(report, equivalency):
361
+ """DRAFT penalty_history rows for the amended rows whose quoted maximum
362
+ changed, and which no row the pairing carries already covers. A covering
363
+ row ends between the amendment the review saw and the new one; for a
364
+ pairing-level verify row any limb's row counts. Each report row that a
365
+ draft or an existing row answers gets `draft` or `covered_by`.
366
+
367
+ The proposed row is the maximum before the amendment until the day
368
+ before it, when the quoted subsections stated a single maximum before.
369
+ Otherwise max_years is None and the note says so. It is never applied
370
+ here.
371
+
372
+ Each draft also carries `cautions` for the reviewer: the pairing (or
373
+ limb) has no structured maximum, so a row cannot drive the branch by
374
+ itself; or the same section is quoted at another maximum in a provision
375
+ the amendment left alone, so a row at this level would misstate that
376
+ limb. `prose_names_date` says whether the curated prose already names
377
+ the amendment (only the structured row is missing)."""
378
+ from .screening import maxima_stated
379
+ pairings = {p.get("id"): p for p in (equivalency or {}).get("pairings") or []}
380
+ drafts, made = [], {}
381
+ for r in report:
382
+ if not (r.get("amended") and r.get("subsections") and r["file"] == EQUIVALENCY):
383
+ continue
384
+ p = pairings.get(r["owner"])
385
+ if p is None:
386
+ continue
387
+ until = _day_before(r["last_amended"])
388
+ covering = sorted({h.get("until") for h in p.get("penalty_history") or []
389
+ if isinstance(h, dict)
390
+ and (r["limb"] is None or h.get("limb") in (None, r["limb"]))
391
+ and r["last_amended_seen"] <= (h.get("until") or "") < r["last_amended"]})
392
+ if covering:
393
+ r["covered_by"] = covering
394
+ continue
395
+ key = (r["owner"], r["limb"], until)
396
+ if key in made:
397
+ r["draft"] = made[key]
398
+ continue
399
+ before, after = r["maxima_before"], r["maxima_after"]
400
+ old = before[0] if len(before) == 1 else None
401
+ limb = next((l for l in p.get("limbs") or []
402
+ if isinstance(l, dict) and l.get("key") == r["limb"]), None)
403
+ table_max = limb.get("max_years", p.get("max_years")) if limb else p.get("max_years")
404
+ labels = [l for l in r["subsections"] if l]
405
+ where = f"{r['act']} s. {r['section']}" + (
406
+ f" (subsection{'s' if len(labels) > 1 else ''} "
407
+ f"{', '.join(f'({l})' for l in labels)})" if labels else "")
408
+ if old is None:
409
+ note = (f"DRAFT: {where} as it stood before {r['last_amended']} "
410
+ f"({r['amended_by'] or 'amendment'}); the maximum before it is not "
411
+ f"determined mechanically (stated then: "
412
+ f"{', '.join(_years(m) for m in before) or 'none'}) -- read the "
413
+ f"point-in-time version and set max_years before accepting")
414
+ else:
415
+ note = (f"DRAFT: {where} before {r['last_amended']} "
416
+ f"({r['amended_by'] or 'amendment'}): {_years(old)} "
417
+ f"(now {', '.join(_years(m) for m in after) or 'none stated'}) -- "
418
+ f"confirm against the point-in-time version before accepting")
419
+ row = {"until": until, "max_years": old, "note": note}
420
+ if r["limb"]:
421
+ row["limb"] = r["limb"]
422
+ level = f"limb {r['limb']!r}" if r["limb"] else "pairing"
423
+ # The other maxima the same owner quotes for this section: a
424
+ # provision the amendment left alone, which a row at this level
425
+ # would misstate (s. 286.1(1) stays at 5 years while (2) went 10 -> 14).
426
+ others = sorted({m for x in report
427
+ if x["owner"] == r["owner"] and x["limb"] == r["limb"]
428
+ and (x["act"], x["section"]) == (r["act"], r["section"])
429
+ for m in maxima_stated(x["contains"])} - set(before) - set(after))
430
+ cautions = []
431
+ if table_max is None:
432
+ cautions.append(
433
+ f"the table gives this {level} no structured maximum (max_years null"
434
+ + (f", depends_on {p.get('depends_on')!r}" if p.get("depends_on") else "")
435
+ + "): a history row here cannot drive the branch on its own -- decide "
436
+ "whether the amended subsection needs a limb of its own")
437
+ if others:
438
+ cautions.append(
439
+ f"{r['act']} s. {r['section']} is also quoted at "
440
+ f"{', '.join(_years(m) for m in others)} in a provision the amendment left "
441
+ f"alone: a row on this {level} would apply to that provision too -- give "
442
+ f"the amended subsection{'s' if len(labels) > 1 else ''} "
443
+ f"{', '.join(f'({l})' for l in labels) or where} a limb of its own first")
444
+ prose = squash(" ".join([p.get("penalty") or "", p.get("analysis") or "",
445
+ p.get("branch") or ""] + list(p.get("caveats") or [])))
446
+ made[key] = len(drafts)
447
+ r["draft"] = made[key]
448
+ drafts.append({
449
+ "pairing": r["owner"], "limb": r["limb"], "act": r["act"],
450
+ "section": r["section"], "subsections": [l for l in r["subsections"] if l],
451
+ "amended": r["last_amended"], "amended_by": r["amended_by"],
452
+ "last_amended_seen": r["last_amended_seen"],
453
+ "maxima_before": before, "maxima_after": after,
454
+ "table_max_years": table_max,
455
+ "pit_url_to_read": (pit_url(r["act"], r["section"], r["last_amended_seen"])
456
+ if r["last_amended_seen"] else ""),
457
+ "prose_names_date": bool(prose) and (until in prose or r["last_amended"] in prose),
458
+ "cautions": cautions,
459
+ "status": "DRAFT - for review", "proposed_row": row})
460
+ return drafts
461
+
462
+
463
+ def write_drafts(drafts, report, baseline, today, draft_dir=None):
464
+ """Write the drafts, with the amended and broken report rows, to
465
+ <draft_dir>/penalty-history-draft-<today>.json, and return its path.
466
+ Nothing is written when there are no drafts. When the newest existing
467
+ draft file already holds the same drafts, that file's path is returned
468
+ instead, so a weekly check does not pile up copies."""
469
+ if not drafts:
470
+ return ""
471
+ draft_dir = Path(draft_dir) if draft_dir else DRAFTS
472
+ drafts = json.loads(json.dumps(drafts))
473
+ existing = sorted(draft_dir.glob(DRAFT_GLOB))
474
+ if existing:
475
+ try:
476
+ if json.loads(existing[-1].read_text(encoding="utf-8")).get("drafts") == drafts:
477
+ return str(existing[-1])
478
+ except ValueError:
479
+ pass
480
+ draft_dir.mkdir(parents=True, exist_ok=True)
481
+ path = draft_dir / f"penalty-history-draft-{today}.json"
482
+ payload = {
483
+ "generated": today,
484
+ "tool": "py -m canlex.refresh --check (refresh.check_curated_sections)",
485
+ "status": ("DRAFT research output for review. Nothing in this file is applied "
486
+ "to data/curated/us_equivalency.json; an accepted row is added there "
487
+ "separately after review."),
488
+ "baseline": baseline,
489
+ "method": ("A verify row is amended when its section's last_amended in the corpus "
490
+ "now is later than in the corpus of the approved commit "
491
+ "(last_amended_seen). A row is drafted when the maximum stated in a "
492
+ "subsection the row quotes changed, and no penalty_history row of the "
493
+ "pairing ends between the two amendment dates: the maximum before the "
494
+ "amendment, until the day before it."),
495
+ "drafts": drafts,
496
+ "rows": [r for r in report if r.get("amended") or r["status"] == "broken"],
497
+ }
498
+ path.write_text(json.dumps(payload, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
499
+ return str(path)
500
+
501
+
502
+ # ---- the approved baseline and one entry point ------------------------------------
503
+
504
+ def approved_commit(root=ROOT):
505
+ """The approved commit of data/curated/review_state.json when git can read
506
+ it, else ''."""
507
+ try:
508
+ state = json.loads((Path(root) / "data" / "curated" / "review_state.json")
509
+ .read_text(encoding="utf-8"))
510
+ commit = state["approved"]["commit"]
511
+ ok = subprocess.run(["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
512
+ capture_output=True, timeout=60).returncode == 0
513
+ except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
514
+ return ""
515
+ return commit if ok else ""
516
+
517
+
518
+ def check_sections(equivalency, transborder=None, now=None, then=None):
519
+ """check_rows over every verify row of the equivalency table and the
520
+ IRPR s. 19 table (optional: absent on the approved baseline)."""
521
+ rows = verify_rows(equivalency) + verify_rows(transborder, TRANSBORDER)
522
+ return check_rows(rows, now or Corpus(), then)
523
+
524
+
525
+ def run_checks(curated_dir=None, root=ROOT, baseline=None, today=None,
526
+ draft_dir=None, write=True):
527
+ """The whole T4.3 check on a tree's curated tables and corpus:
528
+ {baseline, rows, drafts, draft_file, drift, error}. baseline None reads
529
+ the approved commit from review_state.json; '' checks broken phrases
530
+ only."""
531
+ today = today or datetime.date.today().isoformat()
532
+ curated_dir = Path(curated_dir) if curated_dir else Path(root) / "data" / "curated"
533
+ try:
534
+ eq = json.loads((curated_dir / EQUIVALENCY).read_text(encoding="utf-8"))
535
+ tb_path = curated_dir / TRANSBORDER
536
+ tb = json.loads(tb_path.read_text(encoding="utf-8")) if tb_path.exists() else None
537
+ except (OSError, ValueError) as exc:
538
+ return {"baseline": "", "rows": [], "drafts": [], "draft_file": "", "drift": [],
539
+ "error": f"{type(exc).__name__}: {exc}"}
540
+ base = approved_commit(root) if baseline is None else baseline
541
+ now = Corpus(None, root)
542
+ then = Corpus(base, root) if base else None
543
+ rows = check_sections(eq, tb, now, then)
544
+ drafts = draft_history(rows, eq)
545
+ drift = [list(d) for d in refresh_drift(eq, now.text, then.text)[0]] if then else []
546
+ path = write_drafts(drafts, rows, base, today, draft_dir) if write else ""
547
+ return {"baseline": base, "rows": rows, "drafts": drafts, "draft_file": path,
548
+ "drift": drift, "error": ""}
549
+
550
+
551
+ def gate(result):
552
+ """(status, lines) for tools/predeploy.py: FAIL on a broken phrase (or a
553
+ table that cannot be read), WARN on an amended section, PASS otherwise."""
554
+ if result.get("error"):
555
+ return FAIL, [f"curated tables unreadable: {result['error']}"]
556
+ rows = result.get("rows") or []
557
+ broken = [r for r in rows if r["status"] == "broken"]
558
+ amended = [r for r in rows if r.get("amended")]
559
+ lines = [f"BROKEN {where(r)}: {r['act']} s. {r['section']} -- {r['reason']}: "
560
+ f"{r['contains']!r}" for r in broken]
561
+ for pid, act, sec, new in result.get("drift") or []:
562
+ lines.append(f"NOT QUOTED {pid}: {act} s. {sec} now states "
563
+ f"{' and '.join(_years(m) for m in new)}")
564
+ for d in result.get("drafts") or []:
565
+ lines.append(f"DRAFT history row {d['pairing']}"
566
+ + (f" limb {d['limb']}" if d["limb"] else "")
567
+ + f": {d['act']} s. {d['section']} until {d['proposed_row']['until']}")
568
+ secs = sorted({(r["act"], r["section"]) for r in amended})
569
+ if secs:
570
+ lines.append(f"amended since the approved corpus: {len(secs)} verify section(s): "
571
+ + ", ".join(f"{a} s. {s}" for a, s in secs))
572
+ if result.get("draft_file"):
573
+ lines.append(f"drafts: {result['draft_file']}")
574
+ if broken:
575
+ return FAIL, lines
576
+ if amended or result.get("drift"):
577
+ return WARN, lines
578
+ return PASS, lines or [f"{len(rows)} verify rows: every phrase is in its section"]
canlex/delegation.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest instruments of delegation and designation under IRPA / IRPR.
2
+
3
+ These instruments record which officer positions the Minister has delegated
4
+ powers to, or designated for functions, under the Immigration and Refugee
5
+ Protection Act and its Regulations. They are administrative instruments -- not
6
+ enacted law, and not guidance -- so every chunk is tagged doc_type="delegation".
7
+
8
+ Sources:
9
+ - the CBSA "Delegation of Authority and Designations of Officers ..."
10
+ instrument (HTML, cbsa-asfc.gc.ca);
11
+ - the IRCC "IL3 -- Instrument of Designation and Delegation" (PDF, canada.ca).
12
+
13
+ py -m canlex.delegation
14
+ """
15
+ import io
16
+ import re
17
+ import sys
18
+
19
+ from bs4 import BeautifulSoup
20
+ from pypdf import PdfReader
21
+
22
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
23
+ stored_chunks, uniquify_ids, write_corpus)
24
+ from .config import PROCESSED_DIR, RAW_DIR
25
+
26
+ RAW = RAW_DIR / "delegation"
27
+ OUT = PROCESSED_DIR / "delegation.json"
28
+
29
+ # The CBSA IRPA delegation has been a moving target: the November 28, 2017
30
+ # instrument was expressly superseded by a full restatement on May 8, 2023
31
+ # (signed by Mendicino), which has itself been amended five times since. CBSA
32
+ # does not publish a consolidated version, so the current effective state is the
33
+ # 2023 restatement read together with its later amendments; we ingest each one
34
+ # as a separate "act" so a user (or the LLM) sees the base item and any
35
+ # amendments that touch it side-by-side in retrieval.
36
+ _CBSA_DELEG_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
37
+ "delegation/")
38
+ _CBSA_DELEG_NAME = ("Delegation of Authority and Designations of Officers by "
39
+ "the Minister of Public Safety and Emergency Preparedness "
40
+ "under the Immigration and Refugee Protection Act and the "
41
+ "Immigration and Refugee Protection Regulations")
42
+
43
+ SOURCES = {
44
+ # The 2023 restatement -- the current base instrument.
45
+ "cbsa-2023-05": {
46
+ "code": "cbsa-2023-05",
47
+ "kind": "html-cbsa",
48
+ "act_code": "CBSA-IRPA-DELEG-2023-05",
49
+ "act_short": "CBSA Deleg 2023-05-08",
50
+ "act_name": _CBSA_DELEG_NAME,
51
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2023-05-08-eng.html",
52
+ "effective": "2023-05-08",
53
+ },
54
+ # Amendments to the 2023 restatement, in chronological order.
55
+ "cbsa-2023-09": {
56
+ "code": "cbsa-2023-09",
57
+ "kind": "html-cbsa",
58
+ "act_code": "CBSA-IRPA-DELEG-AMEND-2023-09-08",
59
+ "act_short": "CBSA Deleg Amend 2023-09-08",
60
+ "act_name": "Amendment to the " + _CBSA_DELEG_NAME,
61
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2023-09-08-eng.html",
62
+ "effective": "2023-09-08",
63
+ },
64
+ "cbsa-2023-11": {
65
+ "code": "cbsa-2023-11",
66
+ "kind": "html-cbsa",
67
+ "act_code": "CBSA-IRPA-DELEG-AMEND-2023-11-17",
68
+ "act_short": "CBSA Deleg Amend 2023-11-17",
69
+ "act_name": "Amendment to the " + _CBSA_DELEG_NAME,
70
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2023-11-17-eng.html",
71
+ "effective": "2023-11-17",
72
+ },
73
+ "cbsa-2024-03-05": {
74
+ "code": "cbsa-2024-03-05",
75
+ "kind": "html-cbsa",
76
+ "act_code": "CBSA-IRPA-DELEG-AMEND-2024-03-05",
77
+ "act_short": "CBSA Deleg Amend 2024-03-05",
78
+ "act_name": "Amendment to the " + _CBSA_DELEG_NAME,
79
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2024-03-05-eng.html",
80
+ "effective": "2024-03-05",
81
+ },
82
+ "cbsa-2024-03-15": {
83
+ "code": "cbsa-2024-03-15",
84
+ "kind": "html-cbsa",
85
+ "act_code": "CBSA-IRPA-DELEG-AMEND-2024-03-15",
86
+ "act_short": "CBSA Deleg Amend 2024-03-15",
87
+ "act_name": "Amendment to the " + _CBSA_DELEG_NAME,
88
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2024-03-15-eng.html",
89
+ "effective": "2024-03-15",
90
+ },
91
+ "cbsa-2025-07": {
92
+ "code": "cbsa-2025-07",
93
+ "kind": "html-cbsa",
94
+ "act_code": "CBSA-IRPA-DELEG-AMEND-2025-07-10",
95
+ "act_short": "CBSA Deleg Amend 2025-07-10",
96
+ "act_name": "Amendment to the " + _CBSA_DELEG_NAME,
97
+ "url": _CBSA_DELEG_URL + "irpa-lipr-2025-07-10-eng.html",
98
+ "effective": "2025-07-10",
99
+ },
100
+ # Separate authority: a peace-officer designation under IRPA s. 138(1).
101
+ # Narrative prose, not a Schedule table -- needs its own parser.
102
+ "cbsa-peaceofficer": {
103
+ "code": "cbsa-peaceofficer",
104
+ "kind": "html-cbsa-narrative",
105
+ "act_code": "CBSA-IRPA-PEACEOFF-2022-08",
106
+ "act_short": "CBSA Peace Officer Auth 2022-08-18",
107
+ "act_name": ("Authorization to have the Authority and Powers of a "
108
+ "Peace Officer under the Immigration and Refugee "
109
+ "Protection Act (subsection 138(1))"),
110
+ "url": _CBSA_DELEG_URL + "desig/po-ag_2022-08-eng.html",
111
+ "effective": "2022-08-18",
112
+ },
113
+ "ircc": {
114
+ "code": "ircc",
115
+ "kind": "pdf-ircc",
116
+ "act_code": "IRCC-IL3-DELEG",
117
+ "act_short": "IRCC IL3",
118
+ "act_name": ("IL3 — Instrument of Designation and Delegation, "
119
+ "Immigration and Refugee Protection Act and Regulations"),
120
+ "url": ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
121
+ "resources/manuals/il/il3-eng.pdf"),
122
+ },
123
+ }
124
+
125
+
126
+ def _normalize_refs(text):
127
+ """Expand the instruments' provision shorthand so the section numbers are
128
+ searchable as tokens: 'A55(1)' -> 'IRPA 55(1)', 'R39' -> 'IRPR 39'. Both
129
+ instruments write 'A' for the Act and 'R' for the Regulations."""
130
+ text = re.sub(r"\bA(?=\d)", "IRPA ", text)
131
+ text = re.sub(r"\bR(?=\d)", "IRPR ", text)
132
+ return _norm(text)
133
+
134
+
135
+ def _fetch(url, dest, powershell=False, force=False):
136
+ """Fetch a page or file, caching the raw bytes under data/raw/delegation.
137
+ canada.ca blocks Python's HTTP client, so its PDF is fetched via PowerShell.
138
+ (refresh.py also calls this for its IL3 version check.)"""
139
+ return fetch_cached(url, dest, powershell=powershell, force=force)
140
+
141
+
142
+ # --- CBSA instrument (HTML) ---------------------------------------------------
143
+
144
+ def _delegates(cell):
145
+ """Flatten a 'Delegates / Designated officials' table cell into readable
146
+ lines. The cell groups officer positions under an organisation header
147
+ (<p class="h4">: CBSA, RCMP, IRCC) and an optional sub-heading
148
+ (<p class="h5">: a region or a headquarters branch), each followed by a
149
+ <ul> of position titles."""
150
+ lines, org, sub, org_used = [], "", "", False
151
+ for el in cell.find_all(["p", "ul"], recursive=False):
152
+ if el.name == "p":
153
+ classes = " ".join(el.get("class") or [])
154
+ text = _norm(el.get_text())
155
+ if not text:
156
+ continue
157
+ if "h4" in classes:
158
+ if org and not org_used: # a previous org with no list of its own
159
+ lines.append(org)
160
+ org, sub, org_used = text.rstrip(": "), "", False
161
+ elif "h5" in classes:
162
+ sub = text
163
+ else:
164
+ lines.append(text) # a free-standing note
165
+ else: # <ul> of position titles
166
+ positions = "; ".join(
167
+ t for t in (_norm(li.get_text())
168
+ for li in el.find_all("li", recursive=False)) if t)
169
+ if positions:
170
+ label = f"{org} — {sub}" if sub else org
171
+ lines.append(f"{label}: {positions}" if label else positions)
172
+ org_used = True
173
+ if org and not org_used: # a trailing org with no list
174
+ lines.append(org)
175
+ return "\n".join(lines)
176
+
177
+
178
+ def parse_cbsa(html, src):
179
+ """Parse the CBSA delegation instrument into one chunk per Schedule item,
180
+ plus one chunk for the preamble."""
181
+ soup = BeautifulSoup(html, "html.parser")
182
+ main = soup.find("main")
183
+ if main is None:
184
+ return []
185
+ for sup in main.find_all("sup"): # drop footnote-reference superscripts
186
+ sup.decompose()
187
+
188
+ # The effective date comes from SOURCES, not the first <time> in <main>:
189
+ # amendment pages quote the base instrument's date ("signed on May 8, 2023")
190
+ # in their preamble, so the first <time> on an amendment page is the base
191
+ # instrument's date, not the amendment's own.
192
+ date = src["effective"]
193
+
194
+ chunks = []
195
+
196
+ # Preamble: the paragraphs between the title and the Schedule, which set out
197
+ # the tiers of delegation and designation and how the columns are read.
198
+ schedule = main.find("h2", id="sch")
199
+ if schedule:
200
+ paras = [_norm(p.get_text())
201
+ for p in reversed(schedule.find_previous_siblings("p"))]
202
+ body = "\n".join(p for p in paras if p)
203
+ if body:
204
+ chunks.append({
205
+ "id": f"delegation-{src['code']}-preamble",
206
+ "doc_type": "delegation",
207
+ "act_code": src["act_code"],
208
+ "act_short": src["act_short"],
209
+ "act_name": src["act_name"],
210
+ "section": "",
211
+ "marginal_note": "Tiers of delegation and designation",
212
+ "part": "",
213
+ "division": "",
214
+ "heading": "Instrument of delegation and designation under IRPA",
215
+ "text": body,
216
+ "history": "",
217
+ "last_amended": "",
218
+ "current_to": date,
219
+ "citation": f"{src['act_short']} — Preamble",
220
+ "source_url": src["url"],
221
+ })
222
+
223
+ # One chunk per Schedule item. Two row shapes are accepted:
224
+ # (a) four <td> cells: Item | Refs | Power | Delegates (the 2023-05
225
+ # restatement and the 2023-09, 2023-11, 2024-03 amendments).
226
+ # (b) one <th> + two or three <td> cells: the <th> carries the item
227
+ # number and the <td>s carry Refs | Power [| Delegates]. The
228
+ # 2025-07-10 amendment uses this layout, and may omit the Delegates
229
+ # column when an amendment changes only references or descriptions.
230
+ # Each topical <h3>, if present, names the schedule section the table belongs to.
231
+ for table in main.find_all("table", class_="table-bordered"):
232
+ h3 = table.find_previous_sibling("h3")
233
+ section_name = _norm(h3.get_text()) if h3 else ""
234
+ for tr in table.find_all("tr"):
235
+ th_cells = tr.find_all("th", recursive=False)
236
+ td_cells = tr.find_all("td", recursive=False)
237
+ if not th_cells and len(td_cells) == 4:
238
+ item_cell, refs_cell, power_cell, deleg_cell = td_cells
239
+ elif len(th_cells) == 1 and len(td_cells) in (2, 3):
240
+ item_cell, refs_cell, power_cell = th_cells[0], td_cells[0], td_cells[1]
241
+ deleg_cell = td_cells[2] if len(td_cells) == 3 else None
242
+ else:
243
+ continue # header row or a stray row
244
+ item_no = _norm(item_cell.get_text()).rstrip(".")
245
+ refs = _normalize_refs(_norm(refs_cell.get_text()))
246
+ power = " ".join(_norm(p.get_text())
247
+ for p in power_cell.find_all("p")) \
248
+ or _norm(power_cell.get_text())
249
+ delegates = _delegates(deleg_cell) if deleg_cell is not None else ""
250
+ if not item_no or not (power or refs):
251
+ continue
252
+ text = power
253
+ if refs:
254
+ text += f"\n\nProvisions (IRPA / IRPR): {refs}."
255
+ if delegates:
256
+ text += "\n\nDelegated / designated to:\n" + delegates
257
+ chunks.append({
258
+ "id": f"delegation-{src['code']}-{item_no}",
259
+ "doc_type": "delegation",
260
+ "act_code": src["act_code"],
261
+ "act_short": src["act_short"],
262
+ "act_name": src["act_name"],
263
+ "section": "",
264
+ "marginal_note": refs or f"Item {item_no}",
265
+ "part": section_name,
266
+ "division": "",
267
+ "heading": section_name,
268
+ "text": text,
269
+ "history": "",
270
+ "last_amended": "",
271
+ "current_to": date,
272
+ "citation": f"{src['act_short']}, Item {item_no}",
273
+ "source_url": src["url"],
274
+ })
275
+ return chunks
276
+
277
+
278
+ # --- CBSA narrative-prose instrument (e.g. the peace-officer designation) -----
279
+
280
+ def parse_cbsa_narrative(html, src):
281
+ """Parse a narrative-prose CBSA designation instrument into a single chunk.
282
+
283
+ Used for the peace-officer authorization under IRPA s. 138(1) -- plain prose
284
+ listing 18 designated officer positions, not a four-column Schedule table,
285
+ so parse_cbsa's table walker would yield nothing. The whole operative text
286
+ is a few hundred words, well within a single chunk."""
287
+ soup = BeautifulSoup(html, "html.parser")
288
+ main = soup.find("main")
289
+ if main is None:
290
+ return []
291
+ for sup in main.find_all("sup"):
292
+ sup.decompose()
293
+
294
+ date = src["effective"]
295
+
296
+ # Skip the breadcrumb/title/footer chrome -- only paragraphs and lists in
297
+ # <main> that carry real content. Lists are rendered as "; "-joined items.
298
+ parts = []
299
+ for el in main.find_all(["p", "ul", "ol"]):
300
+ if el.find_parent(["ul", "ol"]):
301
+ continue # nested lists are picked up by their parent
302
+ if el.name in ("ul", "ol"):
303
+ items = [_norm(li.get_text()) for li in el.find_all("li")]
304
+ joined = "; ".join(t for t in items if t)
305
+ if joined:
306
+ parts.append(joined)
307
+ else:
308
+ text = _norm(el.get_text())
309
+ if text:
310
+ parts.append(text)
311
+ body = "\n".join(parts)
312
+ if not body:
313
+ return []
314
+
315
+ return [{
316
+ "id": f"delegation-{src['code']}",
317
+ "doc_type": "delegation",
318
+ "act_code": src["act_code"],
319
+ "act_short": src["act_short"],
320
+ "act_name": src["act_name"],
321
+ "section": "",
322
+ "marginal_note": "Peace-officer authorization — IRPA s. 138(1)",
323
+ "part": "",
324
+ "division": "",
325
+ "heading": src["act_name"],
326
+ "text": body,
327
+ "history": "",
328
+ "last_amended": "",
329
+ "current_to": date,
330
+ "citation": src["act_short"],
331
+ "source_url": src["url"],
332
+ }]
333
+
334
+
335
+ # --- IRCC IL3 instrument (PDF) ------------------------------------------------
336
+
337
+ # A topical part heading -- a line in full upper case (MINISTERIAL INSTRUCTIONS,
338
+ # MEDICAL, MISCELLANEOUS). Organisation acronyms (CI, CBSA, RCMP) are shorter
339
+ # than the 6-character floor and so are not mistaken for headings.
340
+ _IL3_PART = re.compile(r"[A-Z][A-Z &/,()'.\-]{5,}")
341
+ # An item opens "<n>. <A/R provision>" -- the number must be followed by a
342
+ # provision reference, which rejects ordinary numbered prose.
343
+ _IL3_ITEM = re.compile(r"(\d+)\.\s+(?=[AR]\d)")
344
+ # The word that introduces an item's power description, after the provisions.
345
+ _IL3_POWER = re.compile(r"(?:Delegation|Designation)\s*[-–—]\s*")
346
+
347
+
348
+ def _pdf_pages(pdf_bytes):
349
+ """Extract each page's text, dropping the printed page number that pypdf
350
+ emits as the page's first line (roman in the front matter, arabic later)."""
351
+ reader = PdfReader(io.BytesIO(pdf_bytes))
352
+ pages = []
353
+ for page in reader.pages:
354
+ lines = (page.extract_text() or "").split("\n")
355
+ if lines and re.fullmatch(r"\s*[ivxlcdm\d]+\s*", lines[0] or "", re.I):
356
+ lines = lines[1:]
357
+ pages.append("\n".join(lines))
358
+ return pages
359
+
360
+
361
+ def parse_ircc(pdf_bytes, src):
362
+ """Parse the IRCC IL3 instrument into one chunk per Schedule item, plus a
363
+ preamble chunk. The PDF flattens the four-column table into a linear text
364
+ stream, so each item runs from its numbered marker to the next; the power
365
+ description and the delegated positions are kept together within the item."""
366
+ pages = _pdf_pages(pdf_bytes)
367
+ if not pages:
368
+ return []
369
+ version_match = re.search(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}", pages[0])
370
+ version = version_match.group(0) if version_match else ""
371
+
372
+ chunks = []
373
+ preamble = _norm(pages[1]) if len(pages) > 1 else ""
374
+ if preamble:
375
+ chunks.append({
376
+ "id": f"delegation-{src['code']}-preamble",
377
+ "doc_type": "delegation",
378
+ "act_code": src["act_code"],
379
+ "act_short": src["act_short"],
380
+ "act_name": src["act_name"],
381
+ "section": "",
382
+ "marginal_note": "Preamble — designation and delegation under IRPA s. 6",
383
+ "part": "",
384
+ "division": "",
385
+ "heading": "Instrument of designation and delegation under IRPA",
386
+ "text": preamble,
387
+ "history": "",
388
+ "last_amended": "",
389
+ "current_to": version,
390
+ "citation": f"{src['act_short']} — Preamble",
391
+ "source_url": src["url"],
392
+ })
393
+
394
+ # Walk every line: an all-caps line is a topical part heading; a line that
395
+ # opens "<n>. <A/R provision>" starts a new item. Lines before the first
396
+ # item (the preamble, definitions and table of contents) are skipped.
397
+ items, part = [], ""
398
+ cur_no = cur_part = None
399
+ cur_lines = []
400
+ for line in "\n".join(pages).split("\n"):
401
+ s = _norm(line)
402
+ if not s:
403
+ continue
404
+ if _IL3_PART.fullmatch(s):
405
+ part = s.title()
406
+ continue
407
+ m = _IL3_ITEM.match(s)
408
+ if m:
409
+ if cur_no is not None:
410
+ items.append((cur_part, cur_no, cur_lines))
411
+ cur_no, cur_part, cur_lines = m.group(1), part, [s]
412
+ elif cur_no is not None:
413
+ cur_lines.append(s)
414
+ if cur_no is not None:
415
+ items.append((cur_part, cur_no, cur_lines))
416
+
417
+ for n, (item_part, item_no, lines) in enumerate(items, start=1):
418
+ body = re.sub(r"^\d+\.\s*", "", "\n".join(lines)).strip()
419
+ if not body:
420
+ continue
421
+ power = _IL3_POWER.search(body)
422
+ refs = _normalize_refs(body[:power.start()]) if power else ""
423
+ chunks.append({
424
+ "id": f"delegation-{src['code']}-{n}",
425
+ "doc_type": "delegation",
426
+ "act_code": src["act_code"],
427
+ "act_short": src["act_short"],
428
+ "act_name": src["act_name"],
429
+ "section": "",
430
+ "marginal_note": refs or f"Item {item_no}",
431
+ "part": item_part or "",
432
+ "division": "",
433
+ "heading": item_part or "",
434
+ "text": _normalize_refs(body),
435
+ "history": "",
436
+ "last_amended": "",
437
+ "current_to": version,
438
+ "citation": (f"{src['act_short']} — {item_part}, Item {item_no}"
439
+ if item_part else f"{src['act_short']}, Item {item_no}"),
440
+ "source_url": src["url"],
441
+ })
442
+ return chunks
443
+
444
+
445
+ def preserve_failed(chunks, failures, stored):
446
+ """Re-attach the last-good chunks of instruments this run could not build.
447
+
448
+ Ingestion is a full rebuild, so an instrument that fails its fetch -- or
449
+ that parses to nothing because CBSA changed the page's shape again -- would
450
+ silently vanish from the corpus. An instrument withdrawn on purpose is
451
+ removed from SOURCES instead, and so never lands in `failures`, which is
452
+ why preservation is restricted to the codes that actually failed here.
453
+ act_code is the per-instrument identity every parser stamps on its chunks.
454
+ Returns (chunks, preserved act_codes).
455
+ """
456
+ failed = {act_code for act_code, _why in failures}
457
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
458
+ only=failed)
459
+
460
+
461
+ def build(allow_shrink=False, force=False):
462
+ """Fetch, parse and chunk every delegation instrument into delegation.json."""
463
+ all_chunks, failures = [], []
464
+ for src in SOURCES.values():
465
+ print(f"Ingesting {src['act_short']} ...")
466
+ try:
467
+ if src["kind"] == "html-cbsa":
468
+ html = _fetch(src["url"], RAW / f"{src['code']}.html",
469
+ force=force)
470
+ chunks = parse_cbsa(html, src)
471
+ elif src["kind"] == "html-cbsa-narrative":
472
+ html = _fetch(src["url"], RAW / f"{src['code']}.html",
473
+ force=force)
474
+ chunks = parse_cbsa_narrative(html, src)
475
+ elif src["kind"] == "pdf-ircc":
476
+ pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
477
+ powershell=True, force=force)
478
+ chunks = parse_ircc(pdf, src)
479
+ else:
480
+ chunks = []
481
+ except Exception as exc:
482
+ print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
483
+ failures.append((src["act_code"], f"{type(exc).__name__}: {exc}"))
484
+ continue
485
+ if not chunks:
486
+ failures.append((src["act_code"], "no content parsed"))
487
+ all_chunks.extend(chunks)
488
+ print(f" {len(chunks)} chunks")
489
+ uniquify_ids(all_chunks)
490
+
491
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
492
+ stored_chunks(OUT))
493
+ if preserved:
494
+ print(f" kept the last-good copy of {len(preserved)} instrument(s): "
495
+ f"{', '.join(preserved)}")
496
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
497
+ return False
498
+ print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
499
+ f"instrument(s) -> {OUT}")
500
+ return True
501
+
502
+
503
+ def main():
504
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
505
+ force="--force" in sys.argv) else 1)
506
+
507
+
508
+ if __name__ == "__main__":
509
+ main()
canlex/directive.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest National Joint Council directives (HTML) into section-level chunks.
2
+
3
+ NJC directives are negotiated by employer and bargaining-agent representatives;
4
+ their provisions form part of collective agreements (and the rate tables in
5
+ their appendices apply too). Chunks are tagged doc_type="directive".
6
+ """
7
+ import re
8
+ import sys
9
+
10
+ from bs4 import BeautifulSoup
11
+
12
+ from ._common import (MONTHS, fetch_cached, norm_ws as _norm, preserve_dropped,
13
+ split_lines, stored_chunks, write_corpus)
14
+ from .config import RAW_DIR, PROCESSED_DIR
15
+
16
+ INDEX_URL = "https://www.njc-cnm.gc.ca/directive/en"
17
+ BASE = "https://www.njc-cnm.gc.ca"
18
+ DIRECTIVE_DIR = RAW_DIR / "directives"
19
+ OUT_FILE = PROCESSED_DIR / "directives.json"
20
+ _CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
21
+ _NUMBERED = re.compile(r"^(\d+(?:\.\d+)*)\s+(.+)")
22
+ _CODE = re.compile(r"/directive/([^/]+)/")
23
+
24
+ _PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
25
+ _EFFECTIVE = re.compile(
26
+ r"effective[^.<:]{0,20}?([A-Z][a-z]+)\s+(\d{1,2}),?\s+(\d{4})", re.I)
27
+
28
+
29
+ def _split(text, target=_PIECE_TARGET):
30
+ """Split long text into ~target-sized pieces at line boundaries."""
31
+ return split_lines(text, target)
32
+
33
+
34
+ def _page_effective_date(main):
35
+ """Latest 'effective <Month D, YYYY>' date on a directive page, as ISO.
36
+ Used as a currency fallback when the index gives no date (e.g. the Foreign
37
+ Service Directives, whose chunks otherwise render currency as 'n/a')."""
38
+ best = ""
39
+ for mon, day, year in _EFFECTIVE.findall(main.get_text(" ", strip=True)):
40
+ if mon.lower() in MONTHS:
41
+ iso = f"{int(year):04d}-{MONTHS[mon.lower()]:02d}-{int(day):02d}"
42
+ best = max(best, iso)
43
+ return best
44
+
45
+
46
+ def _fetch(url, dest, force=False):
47
+ """Fetch via PowerShell's (.NET) HTTP stack -- the NJC site blocks
48
+ Python's HTTP client at the TLS layer."""
49
+ return fetch_cached(url, dest, force=force, powershell=True)
50
+
51
+
52
+ def directive_links(force=False):
53
+ """Return [(url, title, date), ...] for the current NJC directives."""
54
+ html = _fetch(INDEX_URL, DIRECTIVE_DIR / "_index.html", force=force)
55
+ soup = BeautifulSoup(html, "html.parser")
56
+ ul = soup.find("ul", class_="directive-list")
57
+ if ul is None:
58
+ return []
59
+ out = []
60
+ for li in ul.find_all("li", recursive=False):
61
+ a = li.find("a", href=True) # the first <a> is the current directive
62
+ if not a:
63
+ continue
64
+ url = a["href"] if a["href"].startswith("http") else BASE + a["href"]
65
+ span = li.find("span", class_="date")
66
+ out.append((url, _norm(a.get_text(" ", strip=True)),
67
+ _norm(span.get_text()) if span else ""))
68
+ return out
69
+
70
+
71
+ def _block_text(heading):
72
+ """Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
73
+ lines = []
74
+ for sib in heading.find_next_siblings():
75
+ if sib.name in ("h2", "h3"):
76
+ break
77
+ if sib.name in ("ul", "ol"):
78
+ for li in sib.find_all("li", recursive=False):
79
+ item = _norm(li.get_text(" ", strip=True))
80
+ if item:
81
+ lines.append(f"- {item}")
82
+ elif sib.name in _CONTENT_TAGS:
83
+ text = _norm(sib.get_text(" ", strip=True))
84
+ if text:
85
+ lines.append(text)
86
+ return "\n".join(lines)
87
+
88
+
89
+ def parse_directive(html, url, title, date):
90
+ """Parse one NJC directive page into one chunk per h2/h3 section."""
91
+ soup = BeautifulSoup(html, "html.parser")
92
+ main = soup.find("main")
93
+ if main is None:
94
+ return []
95
+ for tag in main.find_all(["section", "div"]):
96
+ tag.unwrap() # flatten so each heading and its content become siblings
97
+
98
+ code_match = _CODE.search(url)
99
+ code = code_match.group(1) if code_match else url
100
+ if not date: # recover a currency date from the page
101
+ date = _page_effective_date(main)
102
+ chunks = []
103
+ current_part = ""
104
+ sec_idx = 0
105
+ for h in main.find_all(["h2", "h3"]):
106
+ text = _norm(h.get_text(" ", strip=True))
107
+ if not text:
108
+ continue
109
+ if h.name == "h2":
110
+ current_part = text
111
+ body = _block_text(h)
112
+ if not body:
113
+ continue
114
+ numbered = _NUMBERED.match(text)
115
+ if numbered:
116
+ number, note = numbered.group(1), numbered.group(2).strip()
117
+ citation = f"{title}, s. {number}"
118
+ else:
119
+ # Un-numbered heading: keep the text in the note only, so the rendered
120
+ # header (citation + note) does not repeat it.
121
+ number, note = "", text
122
+ citation = title
123
+ sec_idx += 1
124
+ # Split a long section so each piece fits the embed window; pieces of one
125
+ # section share the base id 'directive-{code}-{sec_idx}' and take a
126
+ # '-p<k>' suffix, so the diversity cap treats them as one source.
127
+ pieces = _split(body)
128
+ for k, piece in enumerate(pieces, start=1):
129
+ base = f"directive-{code}-{sec_idx}"
130
+ chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
131
+ pnote = note if len(pieces) == 1 else f"{note} (part {k})"
132
+ chunks.append({
133
+ "id": chunk_id,
134
+ "doc_type": "directive",
135
+ "act_code": code,
136
+ "act_short": title,
137
+ "act_name": f"NJC {title}",
138
+ "section": number or text,
139
+ "marginal_note": pnote,
140
+ "part": current_part if h.name == "h3" else "",
141
+ "division": "",
142
+ "heading": "",
143
+ "text": piece,
144
+ "history": "",
145
+ "last_amended": "",
146
+ "current_to": date,
147
+ "citation": citation,
148
+ "source_url": url,
149
+ })
150
+ return chunks
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
+
165
+
166
+ def preserve_failed(chunks, failures, stored):
167
+ """Re-attach the last-good chunks of directives this run could not parse.
168
+
169
+ Ingestion is a full rebuild, so a directive whose page 404s or is restyled
170
+ vanishes from the corpus entirely -- and a directive that is still in force
171
+ is better served from the copy we already had than not at all. Keyed on
172
+ act_code (the /directive/<code>/ slug), which survives the title rewordings
173
+ the NJC index goes through; restricted to the directives that actually
174
+ failed, so any the NJC has genuinely retired stay gone.
175
+ Returns (chunks, preserved directive codes).
176
+ """
177
+ failed = {code for code, _title, _why in failures}
178
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
179
+ only=failed)
180
+
181
+
182
+ def ingest(force=False, limit=None, allow_shrink=False):
183
+ directives = directive_links(force=force)
184
+ if limit:
185
+ directives = directives[:limit]
186
+ print(f"Ingesting {len(directives)} NJC directives...")
187
+ all_chunks, failures = [], []
188
+ for url, title, date in directives:
189
+ code_match = _CODE.search(url)
190
+ code = code_match.group(1) if code_match else "x"
191
+ try:
192
+ html = _fetch(url, DIRECTIVE_DIR / f"{code}.html", force=force)
193
+ chunks = parse_directive(html, url, title, date)
194
+ if not chunks:
195
+ # Multi-page directive: the landing page is only a table of
196
+ # contents -- follow its "Print Full Directive" link.
197
+ print_url = _print_link(html)
198
+ if print_url:
199
+ full = _fetch(print_url, DIRECTIVE_DIR / f"{code}-full.html",
200
+ force=force)
201
+ chunks = parse_directive(full, url, title, date)
202
+ if chunks:
203
+ all_chunks.extend(chunks)
204
+ print(f" {title}: {len(chunks)} chunks")
205
+ else:
206
+ failures.append((code, title, "no content parsed"))
207
+ except Exception as exc:
208
+ failures.append((code, title, f"{type(exc).__name__}: {exc}"))
209
+ if not limit:
210
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
211
+ stored_chunks(OUT_FILE))
212
+ if preserved:
213
+ print(f" kept the last-good copy of {len(preserved)} unparseable "
214
+ f"directive(s): {', '.join(preserved)}")
215
+ # Before the write, so a refusal does not bury why the run came up short.
216
+ for _code, title, why in failures:
217
+ print(f" FAILED {title}: {why}")
218
+ # The guard applies to --limit runs too: a smoke test over two directives
219
+ # must not be able to write a two-directive corpus over the real one.
220
+ if not write_corpus(OUT_FILE, all_chunks, indent=2,
221
+ allow_shrink=allow_shrink):
222
+ return False
223
+ print(f"\n{len(all_chunks)} chunks from {len(directives) - len(failures)} "
224
+ f"directives -> {OUT_FILE.name}")
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
+
296
+
297
+ if __name__ == "__main__":
298
+ main()
canlex/dmemo.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest CBSA D-Memoranda (HTML) into structured, section-level chunks.
2
+
3
+ D-Memoranda are CBSA's administrative guidance on how it applies the Customs Act
4
+ and related law. They are persuasive, not binding -- every chunk is tagged
5
+ doc_type="memorandum" so the rest of CanLex can keep them distinct from statute.
6
+ """
7
+ import io
8
+ import json
9
+ import re
10
+ import sys
11
+ from urllib.parse import urljoin
12
+
13
+ from bs4 import BeautifulSoup
14
+ from pypdf import PdfReader
15
+
16
+ from ._common import (POLITE_UA, dehyphenate, document_words, fetch_cached,
17
+ norm_ws as _norm, preserve_dropped, split_lines,
18
+ stored_chunks, write_corpus)
19
+ from .config import RAW_DIR, PROCESSED_DIR
20
+
21
+ INDEX_URL = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
22
+ # CBSA rebuilt the index on 2026-07-10: the page now ships an empty WET
23
+ # DataTable and NO memo links at all -- the rows arrive from this JSON
24
+ # datasource. Each row's NUMBER holds the anchor
25
+ # ('<a href="/publications/dm-md/d1/d1-2-1-eng.html">D1-2-1</a>'), and
26
+ # cancelled memos carry STATUS "Cancelled" (usually with the link stripped).
27
+ # Cancelled memos are deliberately not ingested -- same rule the ENF manuals
28
+ # follow: retired guidance must not surface as if it were current.
29
+ DATASOURCE_URL = ("https://www.cbsa-asfc.gc.ca/publications/dm-md/"
30
+ "dmemoranda-datasource-eng.json")
31
+ 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
+ "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):
47
+ return fetch_cached(url, dest, ua=POLITE_UA, force=force)
48
+
49
+
50
+ def datasource_urls(raw):
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:
58
+ continue
59
+ full = urljoin(DATASOURCE_URL, match.group(1))
60
+ if full not in seen:
61
+ seen.add(full)
62
+ urls.append(full)
63
+ return urls
64
+
65
+
66
+ def index_urls(html):
67
+ """D-memo URLs linked from the index page itself. Pure, for tests.
68
+
69
+ How the index worked before 2026-07 and how it would work again if CBSA
70
+ reverts; kept as the fallback for datasource_urls.
71
+ """
72
+ soup = BeautifulSoup(html, "html.parser")
73
+ urls, seen = [], set()
74
+ for a in soup.find_all("a", href=True):
75
+ if _MEMO_HREF.search(a["href"]):
76
+ full = urljoin(INDEX_URL, a["href"])
77
+ if full not in seen:
78
+ seen.add(full)
79
+ urls.append(full)
80
+ return urls
81
+
82
+
83
+ def memo_urls(force=False):
84
+ """All individual active D-memo URLs, from the CBSA index's datasource,
85
+ falling back to links on the index page if the datasource is gone."""
86
+ try:
87
+ urls = datasource_urls(_fetch(DATASOURCE_URL,
88
+ DMEMO_DIR / "_datasource-eng.json",
89
+ force=force))
90
+ except Exception as exc:
91
+ print(f" datasource unavailable ({type(exc).__name__}: {exc}); "
92
+ f"falling back to index page links")
93
+ urls = []
94
+ if not urls:
95
+ urls = index_urls(_fetch(INDEX_URL, DMEMO_DIR / "_index.html",
96
+ force=force))
97
+ return urls
98
+
99
+
100
+ def _render_section(h2):
101
+ """Readable text from an <h2> up to the next <h2> (sections already unwrapped)."""
102
+ lines = []
103
+ for sib in h2.find_next_siblings():
104
+ if sib.name == "h2" or sib.get("id") == "wb-dtmd":
105
+ break
106
+ if sib.name in ("ul", "ol"):
107
+ for li in sib.find_all("li", recursive=False):
108
+ item = _norm(li.get_text(" ", strip=True))
109
+ if item:
110
+ lines.append(f"- {item}")
111
+ else:
112
+ text = _norm(sib.get_text(" ", strip=True))
113
+ if text:
114
+ lines.append(text)
115
+ return "\n".join(lines)
116
+
117
+
118
+ def parse_memo(html, url):
119
+ """Parse one D-memo HTML page into one chunk per <h2> content section."""
120
+ soup = BeautifulSoup(html, "html.parser")
121
+ main = soup.find("main")
122
+ if main is None:
123
+ return []
124
+ for section in main.find_all("section"):
125
+ section.unwrap() # flatten so each <h2> and its content become siblings
126
+
127
+ match = _URL_NUMBER.search(url)
128
+ number = match.group(1).upper() if match else url
129
+ h1 = main.find("h1")
130
+ topic = ""
131
+ if h1:
132
+ # Pages vary: most carry the memo title in <h1><small>, others as plain
133
+ # "Memorandum DNN-N-N: Title" h1 text. Use the <small> if present, else
134
+ # the h1 text, and strip any leading memo-number prefix either way.
135
+ small = h1.find("small")
136
+ raw = (small.get_text(" ", strip=True) if small
137
+ else h1.get_text(" ", strip=True))
138
+ topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "",
139
+ _norm(raw), flags=re.I)
140
+ dm = main.find("time", attrs={"property": "dateModified"})
141
+ date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
142
+
143
+ chunks = []
144
+ for h2 in main.find_all("h2"):
145
+ heading = _norm(h2.get_text(" ", strip=True))
146
+ if not heading or heading.lower() in _SKIP_HEADINGS:
147
+ continue
148
+ body = _render_section(h2)
149
+ if not body:
150
+ continue
151
+ # One chunk per h2 section. (Splitting long memos to the embed window
152
+ # was trialled and reverted: it tripled memo chunk-count and gave
153
+ # D-memoranda enough extra retrieval surface to out-rank the statutes
154
+ # and sibling memos they relate to -- a measured eval regression. The
155
+ # memo-tail recall gap is better addressed later by multi-vector
156
+ # embedding than by more competing chunks.)
157
+ chunks.append({
158
+ "id": f"dmemo-{number}-{len(chunks) + 1}",
159
+ "doc_type": "memorandum",
160
+ "act_code": "D-Memo",
161
+ "act_short": "D-Memo",
162
+ "act_name": "CBSA D-Memoranda",
163
+ "section": number,
164
+ "marginal_note": heading,
165
+ "part": topic,
166
+ "division": "",
167
+ "heading": "",
168
+ "text": body,
169
+ "history": "",
170
+ "last_amended": date,
171
+ "current_to": date,
172
+ "citation": f"Memorandum {number}",
173
+ "source_url": url,
174
+ })
175
+ return chunks
176
+
177
+
178
+ def _pdf_clean(text):
179
+ text = re.sub(r"[ \t]+", " ", text)
180
+ text = re.sub(r"\n[ \t]+", "\n", text)
181
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
182
+
183
+
184
+ def _pdf_text(pdf_bytes):
185
+ try:
186
+ reader = PdfReader(io.BytesIO(pdf_bytes))
187
+ text = _pdf_clean("\n".join((p.extract_text() or "") for p in reader.pages))
188
+ # line-break hyphens closed up (keeping the hyphen unless the memo
189
+ # itself uses the joined word; 2026-09-22)
190
+ return dehyphenate(text, document_words([text]))
191
+ except Exception:
192
+ return ""
193
+
194
+
195
+ def _split(text, target=3000):
196
+ """Split long PDF text into ~target-sized pieces at line boundaries."""
197
+ return split_lines(text, target)
198
+
199
+
200
+ def parse_pdf_memo(html, url, force=False):
201
+ """Fallback for memos whose HTML page is only a stub linking to a PDF.
202
+ `force` re-downloads the PDF (a memo CBSA reposts as a revised PDF at
203
+ the same URL was invisible to --force until 2026-09-21)."""
204
+ soup = BeautifulSoup(html, "html.parser")
205
+ main = soup.find("main")
206
+ if main is None:
207
+ return []
208
+ pdf_href = next((a["href"] for a in main.find_all("a", href=True)
209
+ if a["href"].lower().endswith(".pdf")), None)
210
+ if not pdf_href:
211
+ return []
212
+ pdf_url = urljoin(url, pdf_href)
213
+
214
+ match = _URL_NUMBER.search(url)
215
+ number = match.group(1).upper() if match else url
216
+ h1 = main.find("h1")
217
+ topic = _norm(h1.get_text(" ", strip=True)) if h1 else ""
218
+ topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "", topic, flags=re.I)
219
+ dm = main.find("time", attrs={"property": "dateModified"})
220
+ date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
221
+
222
+ pdf_bytes = _fetch(pdf_url, DMEMO_DIR / "pdf" / pdf_url.rsplit("/", 1)[-1],
223
+ force=force)
224
+ text = _pdf_text(pdf_bytes)
225
+ if not text:
226
+ return []
227
+
228
+ parts = _split(text)
229
+ chunks = []
230
+ for i, part in enumerate(parts, 1):
231
+ label = topic or number
232
+ if len(parts) > 1:
233
+ label = f"{label} (part {i})"
234
+ chunks.append({
235
+ "id": f"dmemo-{number}-pdf{i}",
236
+ "doc_type": "memorandum",
237
+ "act_code": "D-Memo",
238
+ "act_short": "D-Memo",
239
+ "act_name": "CBSA D-Memoranda",
240
+ "section": number,
241
+ "marginal_note": label,
242
+ "part": topic,
243
+ "division": "",
244
+ "heading": "",
245
+ "text": part,
246
+ "history": "",
247
+ "last_amended": date,
248
+ "current_to": date,
249
+ "citation": f"Memorandum {number}",
250
+ "source_url": url,
251
+ })
252
+ return chunks
253
+
254
+
255
+ def preserve_failed(chunks, failures, stored):
256
+ """Re-attach the last-good chunks of memos that could not be fetched.
257
+
258
+ Ingestion is a full rebuild, so anything that fails a fetch disappears from
259
+ the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
260
+ whose pages 404, and a transient network error looks the same -- in both
261
+ cases dropping guidance that is still in force is worse than serving the
262
+ copy we already had. Restricted to memos that actually failed, so the
263
+ cancelled ones (filtered out before fetching) are not resurrected.
264
+ Returns (chunks, preserved memo numbers).
265
+ """
266
+ failed = set()
267
+ for url, _why in failures:
268
+ match = _URL_NUMBER.search(url)
269
+ if match:
270
+ failed.add(match.group(1).upper())
271
+ return preserve_dropped(chunks, stored,
272
+ key=lambda c: c["section"].upper(), only=failed)
273
+
274
+
275
+ def ingest(force=False, limit=None, allow_shrink=False):
276
+ urls = memo_urls(force=force)
277
+ if limit:
278
+ urls = urls[:limit]
279
+ print(f"Ingesting {len(urls)} D-Memoranda...")
280
+ all_chunks, failures = [], []
281
+ for i, url in enumerate(urls, 1):
282
+ try:
283
+ html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
284
+ chunks = parse_memo(html, url) or parse_pdf_memo(html, url, force=force)
285
+ if chunks:
286
+ all_chunks.extend(chunks)
287
+ else:
288
+ failures.append((url, "no content parsed"))
289
+ except Exception as exc:
290
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
291
+ if i % 50 == 0:
292
+ print(f" {i}/{len(urls)} ...")
293
+ if failures:
294
+ print(f" {len(failures)} memos with no content / errors:")
295
+ for url, why in failures[:15]:
296
+ print(f" - {url.rsplit('/', 1)[-1]}: {why}")
297
+
298
+ if not limit:
299
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
300
+ stored_chunks(OUT_FILE))
301
+ if preserved:
302
+ print(f" kept the last-good copy of {len(preserved)} unfetchable "
303
+ f"memo(s): {', '.join(preserved)}")
304
+ # The guard applies to --limit runs too: a smoke test over five memos must
305
+ # not be able to write a five-memo corpus over the real one.
306
+ if not write_corpus(OUT_FILE, all_chunks, allow_shrink=allow_shrink):
307
+ return False
308
+ print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
309
+ f"-> {OUT_FILE.name}")
310
+ return True
311
+
312
+
313
+ def ingest_fr(force=False, limit=None, allow_shrink=False):
314
+ """The French editions (Mémorandums D), mirroring ingest().
315
+
316
+ Same pages at -fra.html, same WET markup, same parser; the chunks are
317
+ post-processed with lang='fr', a dmemo-fr- id prefix and the French
318
+ citation so language-routed retrieval (index.query_lang) serves them
319
+ only to French queries."""
320
+ try:
321
+ urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
322
+ DMEMO_DIR / "_datasource-fra.json",
323
+ force=force))
324
+ except Exception as exc:
325
+ print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
326
+ urls = []
327
+ if limit:
328
+ urls = urls[:limit]
329
+ print(f"Ingesting {len(urls)} Mémorandums D (français)...")
330
+ all_chunks, failures = [], []
331
+ for i, url in enumerate(urls, 1):
332
+ try:
333
+ html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
334
+ chunks = parse_memo(html, url) or parse_pdf_memo(html, url, force=force)
335
+ if chunks:
336
+ all_chunks.extend(chunks)
337
+ else:
338
+ failures.append((url, "no content parsed"))
339
+ except Exception as exc:
340
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
341
+ if i % 50 == 0:
342
+ print(f" {i}/{len(urls)} ...")
343
+ for c in all_chunks:
344
+ c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
345
+ c["lang"] = "fr"
346
+ c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
347
+ if failures:
348
+ print(f" {len(failures)} mémorandums with no content / errors "
349
+ f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
350
+ if not limit:
351
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
352
+ stored_chunks(OUT_FILE_FR))
353
+ if preserved:
354
+ print(f" kept the last-good copy of {len(preserved)} "
355
+ f"unfetchable mémorandum(s)")
356
+ if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
357
+ return False
358
+ print(f" {len(all_chunks)} section-chunks from "
359
+ f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
360
+ return True
361
+
362
+
363
+ def main():
364
+ force = "--force" in sys.argv
365
+ limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
366
+ if a.startswith("--limit=")), None)
367
+ runner = ingest_fr if "--fr" in sys.argv else ingest
368
+ ok = runner(force=force, limit=limit,
369
+ allow_shrink="--allow-shrink" in sys.argv)
370
+ sys.exit(0 if ok else 1)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ main()
canlex/embed.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build semantic embeddings for ingested chunks (local, key-free).
2
+
3
+ Uses BAAI's bge-small-en-v1.5 sentence-embedding model as ONNX, run on CPU via
4
+ onnxruntime -- no API key. A transformer embedding has far stronger retrieval
5
+ recall than a static one: it can connect a natural-language question to a
6
+ provision even when the two share few exact words.
7
+
8
+ Incremental by default: embeddings.npz stores a content hash per row, and a
9
+ rebuild re-encodes only the chunks whose embed text changed (or is new),
10
+ reusing every other row. A weekly consolidation refresh that touches a few
11
+ hundred sections therefore takes seconds, not the ~40 minutes of a full
12
+ pass over ~55k windows. `--full` forces the full pass (use it after changing
13
+ the model, the pooling, the prefixes or embed_texts itself -- the hash
14
+ covers the text, not the recipe).
15
+
16
+ python -m canlex.embed [--full]
17
+ python -m canlex.embed --check # exit 1 unless the store is current
18
+ """
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import sys
23
+
24
+ import numpy as np
25
+ from tokenizers import Tokenizer
26
+
27
+ from .config import PROCESSED_DIR
28
+
29
+ # The embedding model is env-configurable so an alternative (e.g. a multilingual
30
+ # model for French support) can be trialled without code edits; the defaults
31
+ # reproduce the production bge-small-en-v1.5 setup exactly. A model swap means
32
+ # matching three things to that model's training: the HF repo, the pooling
33
+ # ('cls' for BGE, 'mean' for E5/MiniLM), and the query/passage prefixes (BGE
34
+ # prefixes only the query; E5 prefixes both with 'query: ' / 'passage: ').
35
+ EMB_REPO = os.environ.get("CANLEX_EMB_REPO", "Xenova/bge-small-en-v1.5")
36
+ _POOLING = os.environ.get("CANLEX_EMB_POOLING", "cls").lower()
37
+ _QUERY_PREFIX = os.environ.get(
38
+ "CANLEX_EMB_QUERY_PREFIX",
39
+ "Represent this sentence for searching relevant passages: ")
40
+ _PASSAGE_PREFIX = os.environ.get("CANLEX_EMB_PASSAGE_PREFIX", "")
41
+ EMB_PATH = PROCESSED_DIR / "embeddings.npz"
42
+ _MAX_TOKENS = 512
43
+ _MAX_BODY = 2000 # cap embedded body text so long sections stay topically focused
44
+
45
+
46
+ def load_chunks():
47
+ chunks = []
48
+ for path in sorted(PROCESSED_DIR.glob("*.json")):
49
+ chunks.extend(json.loads(path.read_text(encoding="utf-8")))
50
+ return chunks
51
+
52
+
53
+ # A chunk longer than the embed window gets additional vectors for its tail,
54
+ # up to this many per chunk (an 87K-char D-memo section would otherwise need
55
+ # 40+; eight windows cover 16K chars, past which BM25 carries the tail).
56
+ _MAX_WINDOWS = 8
57
+
58
+
59
+ def embed_text(chunk):
60
+ """Compact, retrieval-focused representation of one section (head only)."""
61
+ # The section title is the strongest topical signal, so it is repeated to
62
+ # emphasise it. Title selection is doc_type-aware (see index.topical_title):
63
+ # a D-memo's marginal_note is a generic banner so its actual subject in
64
+ # 'part' is used; a case-law chunk's marginal_note is just the paragraph
65
+ # range so the case proposition in 'heading' is used.
66
+ from .index import topical_title
67
+ note = topical_title(chunk)
68
+ body = chunk["text"][:_MAX_BODY]
69
+ parts = [chunk["act_short"], note, note, chunk["heading"], body]
70
+ return " . ".join(p for p in parts if p)
71
+
72
+
73
+ def embed_texts(chunk):
74
+ """One representation per embedding window of a chunk.
75
+
76
+ The first is the classic head representation; a chunk whose body exceeds
77
+ the embed window additionally gets tail windows (same topical-title
78
+ anchor, successive body slices), so a fact buried mid-memo is reachable
79
+ through semantic recall instead of BM25 alone. All windows share the
80
+ chunk's id -- the index scores a chunk by its best window."""
81
+ from .index import topical_title
82
+ texts = [embed_text(chunk)]
83
+ body = chunk["text"]
84
+ if len(body) > _MAX_BODY:
85
+ note = topical_title(chunk)
86
+ anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
87
+ for start in range(_MAX_BODY, len(body), _MAX_BODY):
88
+ if len(texts) >= _MAX_WINDOWS:
89
+ break
90
+ texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
91
+ return texts
92
+
93
+
94
+ class Embedder:
95
+ """Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
96
+
97
+ No API key; the model is downloaded once and cached. Produces L2-normalized
98
+ vectors, so a dot product between them is cosine similarity.
99
+ """
100
+
101
+ def __init__(self):
102
+ # Each file from the local cache first (rerank.hub_file); the
103
+ # quantized model is preferred, the full one the fallback.
104
+ from .rerank import hub_file, make_ort_session, make_run_options
105
+ model_path = None
106
+ for name in ("onnx/model_quantized.onnx", "onnx/model.onnx"):
107
+ try:
108
+ model_path = hub_file(EMB_REPO, name)
109
+ break
110
+ except Exception:
111
+ continue
112
+ if model_path is None:
113
+ raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
114
+ tok_path = hub_file(EMB_REPO, "tokenizer.json")
115
+ self.session = make_ort_session(model_path)
116
+ self.run_options = make_run_options()
117
+ self.input_names = {i.name for i in self.session.get_inputs()}
118
+ self.tokenizer = Tokenizer.from_file(tok_path)
119
+ self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
120
+
121
+ def _run(self, texts):
122
+ """Tokenize, run the encoder, CLS-pool and L2-normalize one batch."""
123
+ encs = self.tokenizer.encode_batch(list(texts))
124
+ width = max(len(e.ids) for e in encs)
125
+ input_ids = np.zeros((len(encs), width), dtype=np.int64)
126
+ attention = np.zeros((len(encs), width), dtype=np.int64)
127
+ type_ids = np.zeros((len(encs), width), dtype=np.int64)
128
+ for row, enc in enumerate(encs):
129
+ n = len(enc.ids)
130
+ input_ids[row, :n] = enc.ids
131
+ attention[row, :n] = enc.attention_mask
132
+ type_ids[row, :n] = enc.type_ids
133
+ feed = {"input_ids": input_ids, "attention_mask": attention}
134
+ if "token_type_ids" in self.input_names:
135
+ feed["token_type_ids"] = type_ids
136
+ hidden = np.asarray(self.session.run(None, feed, self.run_options)[0],
137
+ dtype=np.float32)
138
+ if hidden.ndim != 3:
139
+ pooled = hidden # already pooled
140
+ elif _POOLING == "mean": # E5 / MiniLM
141
+ mask = attention[:, :, None].astype(np.float32)
142
+ pooled = (hidden * mask).sum(1) / np.maximum(mask.sum(1), 1e-9)
143
+ else: # BGE: CLS token
144
+ pooled = hidden[:, 0, :]
145
+ norms = np.linalg.norm(pooled, axis=1, keepdims=True)
146
+ return pooled / np.maximum(norms, 1e-9)
147
+
148
+ def encode(self, texts, batch_size=32):
149
+ """Return L2-normalized embeddings for passages, one row per text."""
150
+ texts = [_PASSAGE_PREFIX + t for t in texts]
151
+ if not texts:
152
+ return np.zeros((0, 384), dtype=np.float32)
153
+ rows = [self._run(texts[i:i + batch_size])
154
+ for i in range(0, len(texts), batch_size)]
155
+ return np.vstack(rows)
156
+
157
+ def encode_query(self, text):
158
+ """Return the L2-normalized embedding for one search query."""
159
+ return self._run([_QUERY_PREFIX + text])[0]
160
+
161
+
162
+ def content_hash(texts):
163
+ """Hash of a chunk's embed windows -- the identity a stored row keeps."""
164
+ h = hashlib.sha1()
165
+ for t in texts:
166
+ h.update(t.encode("utf-8"))
167
+ h.update(b"\x00")
168
+ return h.hexdigest()
169
+
170
+
171
+ def load_stored(path=None):
172
+ """{chunk id: (hash, rows)} from an existing npz, or {} when there is none
173
+ or it predates per-row hashes (then nothing can be trusted for reuse)."""
174
+ path = path or EMB_PATH
175
+ if not path.exists():
176
+ return {}
177
+ with np.load(path) as data:
178
+ if "hashes" not in data.files:
179
+ return {}
180
+ ids = data["ids"].tolist()
181
+ hashes = data["hashes"].tolist()
182
+ vectors = data["vectors"]
183
+ stored = {}
184
+ for row, (cid, h) in enumerate(zip(ids, hashes)):
185
+ entry = stored.setdefault(cid, (h, []))
186
+ if entry[0] == h:
187
+ entry[1].append(vectors[row])
188
+ return stored
189
+
190
+
191
+ def plan(chunks, stored):
192
+ """Split chunks into (reusable, to_embed): a chunk is reusable when the
193
+ stored npz holds rows under its id with the same content hash. Pure, so
194
+ the reuse rule is testable without a model."""
195
+ reusable, to_embed = [], []
196
+ for c in chunks:
197
+ texts = embed_texts(c)
198
+ h = content_hash(texts)
199
+ entry = stored.get(c["id"])
200
+ if entry and entry[0] == h and len(entry[1]) == len(texts):
201
+ reusable.append((c["id"], h, entry[1]))
202
+ else:
203
+ to_embed.append((c["id"], h, texts))
204
+ return reusable, to_embed
205
+
206
+
207
+ def currency(chunks=None, stored=None):
208
+ """(missing, stale): chunks with no stored vector at all, and chunks
209
+ whose stored vectors were embedded from other text (another content
210
+ hash or window count) -- the two halves of what an incremental build
211
+ would re-embed (plan's to_embed). (0, 0) means the store is current.
212
+ Defaults to the processed corpus and embeddings.npz. Audit follow-up
213
+ 2026-09-21 (Missing 15): the only signal was a stderr line at load."""
214
+ chunks = load_chunks() if chunks is None else chunks
215
+ stored = load_stored() if stored is None else stored
216
+ _, to_embed = plan(chunks, stored)
217
+ missing = sum(1 for cid, _, _ in to_embed if cid not in stored)
218
+ return missing, len(to_embed) - missing
219
+
220
+
221
+ def build(full=False, encoder=None):
222
+ chunks = load_chunks()
223
+ if not chunks:
224
+ print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
225
+ return
226
+ stored = {} if full else load_stored()
227
+ reusable, to_embed = plan(chunks, stored)
228
+ n_windows = sum(len(t) for _, _, t in to_embed)
229
+ print(f"Embedding {len(to_embed)} of {len(chunks)} sections "
230
+ f"({n_windows} windows) with {EMB_REPO}; reusing "
231
+ f"{len(reusable)} unchanged ...")
232
+ fresh = {}
233
+ if to_embed:
234
+ encoder = encoder or Embedder()
235
+ texts = [t for _, _, ts in to_embed for t in ts]
236
+ vecs = encoder.encode(texts)
237
+ row = 0
238
+ for cid, h, ts in to_embed:
239
+ fresh[cid] = list(vecs[row:row + len(ts)])
240
+ row += len(ts)
241
+ by_id = {cid: (h, rows) for cid, h, rows in reusable}
242
+ by_id.update({cid: (h, fresh[cid]) for cid, h, _ in to_embed})
243
+ ids, hashes, vectors = [], [], []
244
+ for c in chunks: # chunk order, every window kept
245
+ h, rows = by_id[c["id"]]
246
+ for v in rows:
247
+ ids.append(c["id"])
248
+ hashes.append(h)
249
+ vectors.append(np.asarray(v, dtype=np.float32))
250
+ vectors = np.vstack(vectors) if vectors else np.zeros((0, 384), np.float32)
251
+ # float16 + compression: ~60% smaller on disk with no measurable
252
+ # retrieval cost (cosine ranks are insensitive to half-precision
253
+ # storage; the index upcasts to float32 at load). The npz ships in the
254
+ # Space repo via LFS, where every pushed version counts against the
255
+ # 1 GB storage cap -- size is a deploy constraint, not vanity.
256
+ np.savez_compressed(EMB_PATH, ids=np.array(ids), hashes=np.array(hashes),
257
+ vectors=vectors.astype(np.float16))
258
+ print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ if "--check" in sys.argv:
263
+ # Exit 1 unless every processed chunk has a current vector.
264
+ n_missing, n_stale = currency()
265
+ print(f"embeddings.npz: {n_missing} chunk(s) missing, {n_stale} stale")
266
+ sys.exit(1 if n_missing or n_stale else 0)
267
+ build(full="--full" in sys.argv)
canlex/enf.py ADDED
@@ -0,0 +1,1023 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the IRCC/CBSA ENF operational-manual chapters.
2
+
3
+ The ENF chapters are the operational layer between the statute and the
4
+ officer: ENF 4 (port-of-entry examinations), ENF 5 (writing A44(1) reports),
5
+ ENF 6 (Minister's-delegate review), ENF 12 (search and seizure), ENF 20
6
+ (detention) and their siblings are what an immigration-secondary BSO actually
7
+ opens. The legacy PDFs live under the canada.ca manuals directory that
8
+ delegation.py already fetches IL3 from (same PowerShell TLS workaround), and
9
+ are parsed with pypdf, TOC-segmented by their internal numbered headings.
10
+
11
+ Chapters are probed rather than hardcoded: the numbering has gaps and IRCC
12
+ retires chapters, so anything that 404s is skipped with a note. doc_type is
13
+ 'memorandum' -- operational guidance, persuasive not binding, exactly like
14
+ the D-memoranda.
15
+
16
+ python -m canlex.enf [--allow-shrink]
17
+ """
18
+ import re
19
+ import sys
20
+ import urllib.error
21
+
22
+ from ._common import (dehyphenate, document_words, fetch_cached,
23
+ mark_soft_breaks, norm_ws as _norm, powershell_status,
24
+ preserve_dropped, split_lines, stored_chunks, uniquify_ids,
25
+ write_corpus)
26
+ from .config import PROCESSED_DIR, RAW_DIR
27
+
28
+ BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
29
+ "resources/manuals/enf/enf{n:02d}-eng.pdf")
30
+ # The French editions, published beside the English ones (`--fr`). ENF 20's
31
+ # French current edition is enf20a-fr.pdf (verified 2026-09-09); ENF 3's
32
+ # standard French filename serves the same STALE 2015 edition as the English
33
+ # one and no current French filename was found, so it is skipped rather than
34
+ # ingested out of date; the ATIP chapters (2, 14) exist in English only.
35
+ BASE_FR = ("https://www.canada.ca/content/dam/ircc/migration/ircc/francais/"
36
+ "ressources/guides/enf/enf{n:02d}-fra.pdf")
37
+ _URL_OVERRIDES_FR = {
38
+ 20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/francais/"
39
+ "ressources/guides/enf/enf20a-fr.pdf"),
40
+ }
41
+ _FR_STALE = {3}
42
+ RAW = RAW_DIR / "enf"
43
+ _CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
44
+
45
+ # Chapters whose current edition lives under a non-standard filename in the
46
+ # same directory (verified 2026-07-22 against the OBM Active-manuals index).
47
+ # ENF 3's old enf03-eng.pdf still returns 200 but is a STALE 2015-era copy;
48
+ # ENF 20 (Detention) was renamed enf20a-en.pdf (Aug 2024 edition).
49
+ _URL_OVERRIDES = {
50
+ 3: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
51
+ "resources/manuals/enf/enf-3-admissibility-(en)-final.pdf"),
52
+ 20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
53
+ "resources/manuals/enf/enf20a-en.pdf"),
54
+ }
55
+
56
+ # Chapters IRCC withdrew from public distribution whose only public text is
57
+ # an ATIP-released copy hosted by a third party (meurrensonimmigration.com,
58
+ # verified live 2026-07-22). Ingested at the maintainer's decision
59
+ # (2026-07-23) with an unmistakable UNOFFICIAL banner on every chunk: the
60
+ # text may lag the internal current version, and nothing here is an official
61
+ # publication. ENF 14/OP 19 is the operational manual behind criminal
62
+ # rehabilitation -- directly relevant to canlex_rehabilitation.
63
+ UNOFFICIAL = {
64
+ 2: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2023/06/"
65
+ "ENF2-3.pdf",
66
+ "title": "Evaluating Inadmissibility (unofficial ATIP copy)",
67
+ "note": "ATIP-released copy dated 2021-08-25; retired from canada.ca "
68
+ "in 2018 with no public successor"},
69
+ # ENF 14's ATIP release is a 56-page image scan with no text layer; its
70
+ # text comes from a Windows-OCR pass cached as enf14-ocr.txt (see the
71
+ # OCR fallback in build()). Regenerate the cache with pymupdf
72
+ # rasterization + Windows.Media.Ocr if the source PDF ever changes.
73
+ 14: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2025/03/"
74
+ "Enforcement-Manual-14-%E2%80%93-Rehabilitation.pdf",
75
+ "title": "Criminal Rehabilitation (ENF 14/OP 19, unofficial ATIP "
76
+ "copy)",
77
+ "note": "withdrawn from public distribution ~2007; ATIP-released "
78
+ "scan, text recovered by OCR (last internal change "
79
+ "2008-04-21) -- OCR artifacts possible"},
80
+ }
81
+ # The structured status flag every chunk of those chapters carries
82
+ # (`source_status`). Retrieval keys its ATIP-copy penalty on this field --
83
+ # never on the act_name wording, which differs between the two chapters
84
+ # ('(unofficial ATIP copy)' vs '(ENF 14/OP 19, unofficial ATIP copy)') and
85
+ # so silently missed ENF 14 when a suffix test was tried (eval-diff draft
86
+ # 2026-09-12). canlex/index.py ATIP_STATUS must equal this string.
87
+ ATIP_STATUS = "unofficial-atip-copy"
88
+ _UNOFFICIAL_BANNER = (
89
+ "[UNOFFICIAL ATIP COPY — this chapter was withdrawn from public "
90
+ "distribution; this text comes from an access-to-information release "
91
+ "hosted by a third party and may lag the current internal version. "
92
+ "Treat as historical guidance; verify against internal sources.] ")
93
+
94
+ # Curated titles for the chapters; anything else gets a generic "ENF n".
95
+ # NOT display-only: index.topical_title uses a memorandum's `part` (this
96
+ # title) as its topical title, so it is weighted in BM25 and embedded. Every
97
+ # entry for an ingested chapter was checked against the chapter's own cover
98
+ # page on 2026-09-12 (tests/test_enf.py pins that when the PDFs are cached);
99
+ # before that, ENF 14 (Criminal Rehabilitation) sat under a war-crimes title
100
+ # and ENF 6, 17, 18, 21 and 34 carried other chapters' titles. 7, 9, 11, 13
101
+ # and 27 are not ingested (absent upstream) and are unverified.
102
+ TITLES = {
103
+ 1: "Inadmissibility",
104
+ 2: "Evaluating inadmissibility",
105
+ 3: "Admissibility hearings and detention reviews",
106
+ 4: "Port of entry examinations",
107
+ 5: "Writing 44(1) reports",
108
+ 6: "Review of reports under subsection A44(2)",
109
+ 7: "Investigations and arrests",
110
+ 8: "Deposits and guarantees",
111
+ 9: "Judicial review",
112
+ 10: "Removals",
113
+ 11: "Verifying departure",
114
+ 12: "Search, seizure, fingerprinting and photographing",
115
+ 13: "Examining bank drafts and other negotiable instruments",
116
+ 14: "Criminal rehabilitation",
117
+ 15: "Obligations of transporters",
118
+ 17: "Maritime procedures",
119
+ 18: "Human or international rights violations",
120
+ 19: "Appeals before the Immigration Appeal Division",
121
+ 20: "Detention",
122
+ 21: "Recovering missing, abducted and exploited children",
123
+ 22: "Persons serving a sentence",
124
+ 23: "Loss of permanent resident status",
125
+ 24: "Ministerial interventions",
126
+ 26: "Appeals at the Refugee Appeal Division",
127
+ 27: "Permanent resident card",
128
+ 28: "Ministerial opinions on danger to the public",
129
+ 29: "Alternative means of examination programs",
130
+ 34: "Alternatives to detention program",
131
+ }
132
+
133
+ _DATE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
134
+ # A manual heading line: "5. Departmental policy" / "5.1 Authority to ..."
135
+ _HEADING = re.compile(r"^(\d{1,2}(?:\.\d{1,2})?)\.?\s+([A-ZÀ-Ý][^\n]{2,90})$")
136
+
137
+ # --- Heading-candidate vetting --------------------------------------------
138
+ # _HEADING alone accepted anything shaped "<number> <Capitalised text>", so
139
+ # (measured on the 2026-09 corpus) 120 of ENF 2's 145 chunks sat under
140
+ # "s. 1" titled by the ATIP page stamp ("1 A-2023-83511-000094"), ENF 1's
141
+ # Saini note sat under "s. 64 (F.T.R. 311 (Fed. T.D.))" (a wrapped reporter
142
+ # citation), ENF 19 opened with "s. 22 August 2024" and ENF 2 had
143
+ # "s. 27 March 1992 until 10 October 1996." A candidate is rejected when its
144
+ # text is one of those shapes, and then when its number does not continue the
145
+ # chapter's numbering (numbering_run).
146
+ # No \b before the "A": the stamp is sometimes glued to the column number
147
+ # ("1A-2023-83511-000047").
148
+ _ATIP_STAMP = re.compile(r"(?<![A-Za-z])A-\d{4}-\d+-\d+\b")
149
+ _REPORTER = re.compile(
150
+ r"F\.T\.R\.|D\.L\.R\.|Imm\.\s?L\.R\.|\bN\.R\.|C\.R\.R\.|S\.C\.R\.|R\.C\.S\."
151
+ r"|C\.C\.C\.|Admin\.\s?L\.R\.|\(Fed\.|\bF\.C\.|\[\d{4}\]"
152
+ r"|\b(?:19|20)\d{2}\s+(?:FCA|FC|SCC|CAF|CF|CSC)\s+\d+")
153
+ _MONTHS = ("January|February|March|April|May|June|July|August|September|"
154
+ "October|November|December|janvier|février|mars|avril|mai|juin|"
155
+ "juillet|août|septembre|octobre|novembre|décembre")
156
+ # "August 2024", "March 1992 until ...", "May 3, 2000", "2021-08-25 ..."
157
+ _DATE_START = re.compile(
158
+ rf"^(?:(?:{_MONTHS})\s+(?:\d{{1,2}}(?:er)?,?\s+)?\d{{4}}\b"
159
+ r"|\d{4}-\d{2}-\d{2}\b)", re.I)
160
+ _LEADERS = re.compile(r"\.{4,}|…") # a table-of-contents entry
161
+ # A heading may legitimately end in an initialism ("... via the U.S.",
162
+ # "... vers les É.-U."); any other final period marks a sentence.
163
+ _INITIALISM_END = re.compile(r"(?<!\w)(?:[A-Za-zÀ-ÿ]\.-?){2,}$")
164
+
165
+
166
+ def heading_rejection(heading):
167
+ """Why `heading` (the text after a heading number) is not a section
168
+ title, or None when it can be one. Pure, for tests."""
169
+ if _ATIP_STAMP.search(heading):
170
+ return "atip-stamp"
171
+ if _REPORTER.search(heading):
172
+ return "reporter-citation"
173
+ if _DATE_START.match(heading):
174
+ return "date"
175
+ if _LEADERS.search(heading):
176
+ return "toc-entry"
177
+ tail = heading.rstrip(" \"'”’»")
178
+ if tail.endswith(".") and not _INITIALISM_END.search(tail):
179
+ return "sentence"
180
+ if tail.endswith(","):
181
+ return "fragment" # "15 Rue Fournier," (an address list)
182
+ return None
183
+
184
+
185
+ # --- Headings wrapped at the PDF line end -----------------------------------
186
+ # A long heading wraps: "13. Carrying out a decision of the" / "Immigration
187
+ # Division". _HEADING sees only the first line, so (measured on the 2026-09
188
+ # corpus) 73 EN and 107 FR section titles ended mid-phrase and the rest of the
189
+ # title opened the chunk body. A title that ends where no title can end -- on
190
+ # a function word, an open bracket or a dash -- takes the next line when that
191
+ # line reads as its continuation: a short line opening on a letter, not
192
+ # itself a numbered heading or an appendix title, and the joined title still
193
+ # passes heading_rejection (so a body sentence ending in '.' is never pulled
194
+ # in). Only the title changes: the section number, and with it the chunk id,
195
+ # and the chunk text stay exactly as printed.
196
+ _OPEN_END = {
197
+ "en": frozenset("of the a an to in for with by and or under from at"
198
+ .split()),
199
+ "fr": frozenset("de la le les du des à aux dans que en et pour par sur au"
200
+ .split()),
201
+ }
202
+ _OPEN_TAIL = ("(", "[", "–", "—", "-")
203
+ _CONTINUATION_MAX = 80 # a wrapped title's second line is a short line
204
+ _CONTINUATION_LINES = 2 # at most this many lines are joined on
205
+
206
+
207
+ def _open_ended(heading, lang):
208
+ words = heading.split()
209
+ return bool(words) and (words[-1].lower() in _OPEN_END[lang]
210
+ or heading.endswith(_OPEN_TAIL))
211
+
212
+
213
+ def complete_heading(heading, following, lang="en"):
214
+ """`heading` with the line(s) it wrapped onto joined back on.
215
+
216
+ `following` is the normalised, non-empty lines after the heading line
217
+ and before the next heading. A hyphen glued to its word ("non-",
218
+ "personnes-") closes up with the next line, keeping the hyphen; any
219
+ other join takes a space. Pure, for tests."""
220
+ for line in following[:_CONTINUATION_LINES]:
221
+ if not _open_ended(heading, lang):
222
+ break
223
+ if (len(line) > _CONTINUATION_MAX or not line[0].isalpha()
224
+ or _HEADING.match(line) or _APPENDIX.match(line)):
225
+ break
226
+ glued = heading.endswith("-") and not heading.endswith(" -")
227
+ joined = heading + ("" if glued else " ") + line
228
+ if heading_rejection(joined):
229
+ break
230
+ heading = joined
231
+ return heading
232
+
233
+
234
+ # A word the typesetting split with a space ("Impos er, modifier ou annuler",
235
+ # "resolut ion"). The halves close up when the whole word is at least this
236
+ # many times as common in the chapter as the rarer half -- the rule
237
+ # caselaw._join_lines uses for the Federal Courts Reports ("a part" stays two
238
+ # words).
239
+ _WORD = re.compile(r"[^\W\d_]+")
240
+ _JOIN_RATIO = 3
241
+
242
+
243
+ def chapter_words(lines):
244
+ """Lower-cased word counts over a chapter's lines (for
245
+ repair_split_words and the line-break hyphens, _common.dehyphenate)."""
246
+ return document_words(lines)
247
+
248
+
249
+ def repair_split_words(heading, words):
250
+ """`heading` with space-split words closed up (see _JOIN_RATIO). The
251
+ first half must be a bare word; the second must open lower-case, and
252
+ may carry trailing punctuation ("er,"). Pure, for tests."""
253
+ out = []
254
+ for tok in heading.split(" "):
255
+ prev = out[-1] if out else ""
256
+ tail = _WORD.match(tok)
257
+ if (prev and _WORD.fullmatch(prev) and tail and tok[0].islower()):
258
+ b = tail.group(0)
259
+ whole = words.get((prev + b).lower(), 0)
260
+ rarer = min(words.get(prev.lower(), 0), words.get(b.lower(), 0))
261
+ if whole and whole >= _JOIN_RATIO * rarer:
262
+ out[-1] = prev + tok
263
+ continue
264
+ out.append(tok)
265
+ return " ".join(out)
266
+
267
+
268
+ # How far a chapter's major number may jump between two accepted headings.
269
+ # Extraction can lose a heading or two; a bigger jump is a list item, a
270
+ # date or a page number ("27 March 1992" after s. 14).
271
+ _MAX_JUMP = 3
272
+
273
+
274
+ def _num_key(num):
275
+ major, _, minor = num.partition(".")
276
+ return int(major), int(minor or 0)
277
+
278
+
279
+ def _step_ok(prev, nxt):
280
+ (p_major, p_minor), (n_major, n_minor) = prev, nxt
281
+ if n_major == p_major:
282
+ return n_minor > p_minor
283
+ return p_major < n_major <= p_major + _MAX_JUMP and n_minor <= 2
284
+
285
+
286
+ def numbering_run(nums, substantive=None):
287
+ """Indices of the heading numbers that form the chapter's numbering.
288
+
289
+ The best-scoring subsequence, in document order, in which each number
290
+ continues the previous one (same major with a larger minor, or the next
291
+ major -- up to _MAX_JUMP ahead -- at its start), beginning near 1.
292
+ Taking the best run rather than checking greedily means one stray
293
+ "5 Something" early on cannot lock out the real 4, 4.1, ... that follow.
294
+
295
+ `substantive[k]` says whether candidate k heads real text (more than a
296
+ line or two before the next candidate). A heading that does outweighs any
297
+ number of ones that do not, so a table of contents printed without dot
298
+ leaders -- ENF 14's OCR lists "1 What this chapter is about", "2 Program
299
+ objectives", ... as plain lines -- can never win over the body headings
300
+ it duplicates; among equals, more headings win. Ties keep the earliest
301
+ occurrence, so a heading repeated at a page break stays where it first
302
+ appears and the repeat reads as body text. A numbered annex that restarts
303
+ at 1 (ENF 19's IRB-CBSA MOU) is not a continuation either, so it stays
304
+ body text of the last real section instead of posing as the chapter's own
305
+ ss. 1-13. Pure, for tests."""
306
+ keys = [_num_key(n) for n in nums]
307
+ if substantive is None:
308
+ substantive = [True] * len(keys)
309
+ weight = [1000 if s else 1 for s in substantive]
310
+ best, back = [0] * len(keys), [-1] * len(keys)
311
+ for j, key in enumerate(keys):
312
+ if key[0] <= _MAX_JUMP and key[1] <= 2:
313
+ best[j] = weight[j]
314
+ for i in range(j):
315
+ if (best[i] and best[i] + weight[j] > best[j]
316
+ and _step_ok(keys[i], key)):
317
+ best[j], back[j] = best[i] + weight[j], i
318
+ if not any(best):
319
+ return set()
320
+ j = max(range(len(keys)), key=lambda x: (best[x], -x))
321
+ keep = set()
322
+ while j != -1:
323
+ keep.add(j)
324
+ j = back[j]
325
+ return keep
326
+
327
+
328
+ # --- ATIP release furniture -------------------------------------------------
329
+ # The two UNOFFICIAL chapters are access-to-information releases: every page
330
+ # of ENF 2 carries an OCR'd IRCC wordmark, the "Information disclosed under
331
+ # the Access to Information Act" banner in both languages, the printed page
332
+ # number and the release stamp "1 A-2023-83511-000094"; ENF 14's OCR carries
333
+ # the intranet print furniture. None of it is guidance, and it used to sit in
334
+ # the chunk text (and, for the stamp, in the section title). It is stripped;
335
+ # the stamp is kept only as a page pinpoint in the citation. ATIP exemption
336
+ # markers ("s.16(1)(b)") are kept: they say text was withheld there.
337
+ _STAMP_IN_LINE = re.compile(r"(?:\b\d{1,3}\s*)?(?<![A-Za-z])A-\d{4}-\d+-\d+\b")
338
+ _ATIP_FURNITURE = [re.compile(p) for p in (
339
+ r"^I\s*[•·]\s*I$",
340
+ r"^lmm\S*", # OCR of the 'Immigration' wordmark
341
+ r"^(?:and|et)\s+C[i1]t\S*\s+Canada\b",
342
+ r"^Information disclosed under the Access to Information Act$",
343
+ r"^L[’'`]information d\S* en vertu de la loi sur\b",
344
+ r"^DISTRIBUTION LIMITED TO CIC AND THE CBSA$",
345
+ r"^Date Last Modified: \d{4}-\d{2}-\d{2}$",
346
+ r"^Previous\s+[I|l1]\s+Next$",
347
+ r"^Page\s+\S{1,3}(?:\s+Of\S*)?$",
348
+ r"^https?://cicintranet\.",
349
+ r"^UO/UO/LU 1$",
350
+ r"^ENF 14 1 OP 19 Criminal Rehabilitation$",
351
+ )]
352
+ _ATIP_BANNER = re.compile(
353
+ r"\s*Information disclosed under the Access to Information Act\s*")
354
+
355
+
356
+ def clean_atip_pages(pages):
357
+ """[(line, stamp)] for an ATIP release, furniture removed.
358
+
359
+ `stamp` is the release's identifier for the page the line is printed on
360
+ (it sits at the page foot, so it labels the text above it), or None when
361
+ the page carries none (ENF 14's OCR has no stamps). Pure, for tests."""
362
+ rows = []
363
+ for page in pages:
364
+ kept, stamp = [], None
365
+ for line in page.split("\n"):
366
+ s = line.strip()
367
+ found = _ATIP_STAMP.search(s)
368
+ if found:
369
+ stamp = found.group(0)
370
+ s = _STAMP_IN_LINE.sub(" ", s).strip()
371
+ if not s:
372
+ # The printed page number sits on the line above.
373
+ while kept and not kept[-1].strip():
374
+ kept.pop()
375
+ if kept and re.fullmatch(r"\d{1,3}", kept[-1].strip()):
376
+ kept.pop()
377
+ continue
378
+ line = s
379
+ if any(p.search(s) for p in _ATIP_FURNITURE):
380
+ continue
381
+ if _ATIP_BANNER.search(line):
382
+ line = _ATIP_BANNER.sub(" ", line).strip()
383
+ if not line:
384
+ continue
385
+ kept.append(line)
386
+ rows.extend((line, stamp) for line in kept)
387
+ return rows
388
+
389
+
390
+ def _pinpoint(stamps):
391
+ stamps = [s for s in stamps if s]
392
+ if not stamps:
393
+ return ""
394
+ first, last = stamps[0], stamps[-1]
395
+ return first if first == last else f"{first}–{last.rsplit('-', 1)[1]}"
396
+
397
+
398
+ # --- ENF 1's jurisprudence annex -------------------------------------------
399
+ # ENF 1 closes with "Appendix A Some notable inadmissibility jurisprudence":
400
+ # for each IRPA ground, a "Jurisprudence" list of case citations, each with
401
+ # its holdings. It has no numbered headings of its own, so the heading regex
402
+ # used to cut it at reporter volumes that happened to start a line ("55
403
+ # D.L.R. (4th) 321 ..."). It is segmented by case instead: one segment per
404
+ # case, headed by the style of cause, tagged with the ground it is listed
405
+ # under. The French edition is laid out the same way ("Appendice A", "c.").
406
+ _ANNEX_TITLE = {
407
+ 1: re.compile(r"^(?:Appendix|Appendice)\s+A\s+(?:Some notable "
408
+ r"inadmissibility jurisprudence|Décisions marquantes)"),
409
+ }
410
+ _ANNEX_LABEL = {
411
+ "en": "Appendix A — Some notable inadmissibility jurisprudence",
412
+ "fr": ("Appendice A — Décisions marquantes faisant partie de la "
413
+ "jurisprudence relative aux interdictions de territoire"),
414
+ }
415
+ _JURIS_LINE = re.compile(r"^Jurisprudence\s*:?$")
416
+ # A citation line: a style of cause ("X v. Y" / "X c. Y", at most one
417
+ # parenthetical before the "v.", or "X, Re") followed by the report: a year
418
+ # in parentheses, a [year] volume, a bare volume and reporter, or a neutral
419
+ # citation.
420
+ _CASE = re.compile(
421
+ r"^(?P<name>[A-Z][^()\n]{0,60}?(?:\s\([^()\n]*\))?\s+(?:v|c)\.\s+[A-Z]"
422
+ r"[^\n]*?|[A-Z][\w'’. -]{1,40}, Re)"
423
+ r"(?=\s*\((?:\d{4}|[^()\n]*\d{4})\)|,?\s*\[\d{4}\]"
424
+ r"|,?\s+\d{1,3}\s+[A-Z]|,\s*\d{4}\s+(?:FCA|FC|SCC|CAF|CF|CSC)\b)")
425
+ _GROUND = re.compile(r"^[AL](\d{1,3})(?:\((\d+(?:\.\d+)?)\))?")
426
+ _PARTY_STOP = {"canada", "mci", "maci", "r", "re", "the", "minister",
427
+ "ministre", "attorney", "procureur", "solicitor",
428
+ "solliciteur", "secretary", "secretaire", "general", "her",
429
+ "majesty", "queen"}
430
+ # A holding starts with a bullet glyph (the PDFs use a private-use-area
431
+ # character); a citation-only entry has none.
432
+ _HOLDING = re.compile(r"^[-•▪◦‣∙-]")
433
+
434
+
435
+ def _ascii(text):
436
+ import unicodedata
437
+ return unicodedata.normalize("NFKD", text).encode("ascii",
438
+ "ignore").decode()
439
+
440
+
441
+ def _party_slug(name):
442
+ """The id-safe party name of a style of cause: the first party word that
443
+ is not the Crown, the Minister or 'Re' ("Canada (MCI) v. Fenner" ->
444
+ 'fenner', "Baroud, Re" -> 'baroud', "D’Souza v. Canada" -> 'dsouza')."""
445
+ for side in re.split(r"\s(?:v|c)\.\s", name, maxsplit=1):
446
+ side = re.sub(r"\([^)]*\)", " ", side)
447
+ for tok in side.replace(",", " ").split():
448
+ slug = re.sub(r"[^a-z]", "", _ascii(tok).lower())
449
+ if slug and slug not in _PARTY_STOP:
450
+ return slug
451
+ return "case"
452
+
453
+
454
+ def _ground_slug(ground):
455
+ m = _GROUND.match(ground or "")
456
+ if not m:
457
+ return ""
458
+ return m.group(1) + (f"-{m.group(2).replace('.', '-')}" if m.group(2)
459
+ else "")
460
+
461
+
462
+ def _annex_start(n, lines):
463
+ """Index of the annex's own title line (not its table-of-contents
464
+ entry), or None when the chapter has no jurisprudence annex."""
465
+ pat = _ANNEX_TITLE.get(n)
466
+ if not pat:
467
+ return None
468
+ hits = [i for i, line in enumerate(lines)
469
+ if pat.match(_norm(line)) and not _LEADERS.search(line)]
470
+ return hits[-1] if hits else None
471
+
472
+
473
+ def _is_holding(raw, text):
474
+ """A holding line: bullet glyph first (English edition), or indented
475
+ prose (the French edition's bullets do not survive extraction, but the
476
+ indent does). Citation continuations start flush, or with a digit or a
477
+ parenthesis."""
478
+ return bool(_HOLDING.match(text) or re.match(r"^\s+[^\W\d_]", raw))
479
+
480
+
481
+ def annex_segments(rows):
482
+ """Split annex rows [(line, pin)] into per-case segments.
483
+
484
+ Returns dicts {ground, case, absorbed, rows}: the ground is the provision
485
+ line the case is listed under ("A36(1)(a), (b) and (c)"), the case its
486
+ style of cause, rows its normalised (line, pin) pairs. The leading text
487
+ before the first case is one segment with case None. A citation-only
488
+ entry (no holding under it) is folded into the case before it under the
489
+ same ground rather than left as a stub -- so a ground listed as bare
490
+ citations (ENF 1's A38 list) becomes one segment, `absorbed` counting the
491
+ cases folded in. Never across grounds. Pure, for tests."""
492
+ items = [(_norm(raw), pin, raw) for raw, pin in rows]
493
+ items = [it for it in items if it[0]]
494
+ segs = [{"ground": "", "case": None, "rows": [], "holding": False}]
495
+ ground, pending = "", []
496
+ for k, (text, pin, raw) in enumerate(items):
497
+ nxt = items[k + 1][0] if k + 1 < len(items) else ""
498
+ if _JURIS_LINE.match(nxt) and not _JURIS_LINE.match(text):
499
+ ground = text # the provision heading
500
+ pending = [(text, pin)]
501
+ continue
502
+ if _JURIS_LINE.match(text) and pending:
503
+ pending.append((text, pin))
504
+ continue
505
+ prev = segs[-1]["rows"][-1][0] if segs[-1]["rows"] else ""
506
+ continuation = prev.count("(") > prev.count(")")
507
+ m = None if continuation else _CASE.match(text)
508
+ if m:
509
+ segs.append({"ground": ground, "case": _norm(m.group("name")),
510
+ "rows": pending + [(text, pin)], "holding": False})
511
+ else:
512
+ segs[-1]["rows"].extend(pending + [(text, pin)])
513
+ segs[-1]["holding"] |= _is_holding(raw, text)
514
+ pending = []
515
+ merged = []
516
+ for seg in segs:
517
+ prev = merged[-1] if merged else None
518
+ if (seg["case"] and not seg["holding"] and prev and prev["case"]
519
+ and prev["ground"] == seg["ground"]):
520
+ prev["rows"].extend(seg["rows"])
521
+ prev["absorbed"] += 1
522
+ continue
523
+ merged.append({"ground": seg["ground"], "case": seg["case"],
524
+ "absorbed": 0, "rows": seg["rows"]})
525
+ # A citation-only entry that opens its ground has nothing before it to
526
+ # join ("Bellido v. MCI, [2005] FC 452" heads the A40 list with no
527
+ # holding). Too short to stand alone, it would be dropped; it is folded
528
+ # into the case after it instead, which carries the same ground line.
529
+ out = []
530
+ for k, seg in enumerate(merged):
531
+ short = len("\n".join(t for t, _ in seg["rows"])) <= 120
532
+ nxt = merged[k + 1] if k + 1 < len(merged) else None
533
+ if (seg["case"] and short and nxt and nxt["case"]
534
+ and nxt["ground"] == seg["ground"]):
535
+ nxt["rows"][:0] = [r for r in seg["rows"]
536
+ if r not in nxt["rows"][:2]]
537
+ nxt["absorbed"] += 1 + seg["absorbed"]
538
+ continue
539
+ out.append(seg)
540
+ return out
541
+
542
+
543
+ _FRONT_LABEL = {"en": "Cover and updates to chapter",
544
+ "fr": "Page couverture et mises à jour du chapitre"}
545
+ # A contents entry that ends in a short dot run and its page number
546
+ # ("11. Rules applicable to appeals ... 23"): too few dots for _LEADERS.
547
+ _TOC_PAGE = re.compile(r"\.{2,}\s*\d{1,3}$")
548
+
549
+
550
+ def _head_key(heading):
551
+ """A heading's comparable opening: case- and space-folded, trailing
552
+ dots and page number dropped, first 25 characters."""
553
+ h = re.sub(r"[\s.…]*\d{0,3}$", "", _norm(heading)).lower()
554
+ return re.sub(r"\s+", " ", h)[:25]
555
+
556
+
557
+ def _body_heads(lines, start, end):
558
+ """{number: {heading key}} of every numbered-heading line in
559
+ lines[start:end] -- vetted or not, since a contents page lists what
560
+ the body prints."""
561
+ heads = {}
562
+ for line in lines[start:end]:
563
+ m = _HEADING.match(line.strip())
564
+ if m:
565
+ heads.setdefault(m.group(1), set()).add(_head_key(m.group(2)))
566
+ return heads
567
+
568
+
569
+ def contents_entry(text, body_heads):
570
+ """True when a numbered front-matter line is a table-of-contents entry:
571
+ its number and opening words repeat a heading printed in the body, or it
572
+ ends in dots and a page number. Any other numbered line (an update note
573
+ that opens with the section it changed) is not. Pure, for tests."""
574
+ m = _HEADING.match(text)
575
+ if not m:
576
+ return False
577
+ if _TOC_PAGE.search(text):
578
+ return True
579
+ key = _head_key(m.group(2))
580
+ for body in body_heads.get(m.group(1), ()):
581
+ k = min(len(key), len(body))
582
+ if k >= 8 and key[:k] == body[:k]:
583
+ return True
584
+ return False
585
+
586
+ # --- Trailing appendices ------------------------------------------------------
587
+ # Most chapters end in lettered appendices (sample letters, forms, MOUs, case
588
+ # lists). Many number their own items from 1, which numbering_run rightly
589
+ # refuses as chapter sections -- so without this their text would be cited as
590
+ # the chapter's last section (ENF 24's Article 1F case law as "s. 9.14").
591
+ # An appendix title line counts only after the last numbered section (so the
592
+ # cross-references and update notes that mention appendices mid-chapter are
593
+ # ignored), and the titles must run A, B, C ... through numbering_run's own
594
+ # logic, so a list of the appendices printed ahead of them (ENF 8) loses to
595
+ # the appendices themselves.
596
+ _APPENDIX = re.compile(r"^(?:Appendix|Annex|Appendice|Annexe)\s+([A-Z]|\d{1,2})"
597
+ r"\b\s*[:–—-]?\s*(.*)$")
598
+
599
+
600
+ def appendix_marks(lines, start, end):
601
+ """[(line index, letter, title)] of the appendix title lines in
602
+ lines[start:end]. Pure, for tests."""
603
+ cands = []
604
+ for i in range(start, end):
605
+ s = _norm(lines[i])
606
+ m = _APPENDIX.match(s)
607
+ if not m or len(s) > 120 or _LEADERS.search(s):
608
+ continue
609
+ title = m.group(2).strip()
610
+ # A cross-reference fragment ("Appendix C.)", "Annexe 1).") has no
611
+ # title after the letter. A real title may end in "(c)" or wrap
612
+ # mid-name ("... decision in Baker v."), so endings are not tested.
613
+ if not title or title[0] in ".),;":
614
+ continue
615
+ cands.append((i, m.group(1), title))
616
+ if not cands:
617
+ return []
618
+ nums = [str(ord(letter) - 64) if letter.isalpha() else letter
619
+ for _i, letter, _t in cands]
620
+ spans = [len(_norm(" ".join(lines[i + 1:(cands[k + 1][0]
621
+ if k + 1 < len(cands) else end)])))
622
+ for k, (i, _l, _t) in enumerate(cands)]
623
+ keep = numbering_run(nums, [span > 120 for span in spans])
624
+ return [c for k, c in enumerate(cands) if k in keep]
625
+
626
+
627
+ # --- chapter currency ---------------------------------------------------------
628
+ _LISTING = re.compile(r"^(?:\d+(?:\.\d+)*\.?\s+)?(?:Listing by date|Liste par "
629
+ r"date|Liste des mises à jour par date)\b", re.I)
630
+
631
+
632
+ def chapter_updated(pages, today=None):
633
+ """The chapter's date: the latest ISO date on its first three pages
634
+ (the cover/header convention most chapters follow) or, failing that,
635
+ the latest date in its own 'Updates to chapter -- Listing by date'
636
+ block. ENF 2's ATIP copy prints no date on its cover; its listing,
637
+ on ATIP page A-2023-83511-000005, opens with 2021-08-25. Dates later
638
+ than `today` are ignored in the listing. Pure, for tests."""
639
+ dates = _DATE.findall("\n".join(pages[:3]))
640
+ if dates:
641
+ return max(dates)
642
+ from datetime import date
643
+ today = today or date.today().isoformat()
644
+ lines = "\n".join(pages[:10]).split("\n")
645
+ for i, line in enumerate(lines):
646
+ s = _norm(line)
647
+ if _LISTING.match(s) and not _LEADERS.search(s):
648
+ found = [d for d in _DATE.findall("\n".join(lines[i:i + 40]))
649
+ if d <= today]
650
+ if found:
651
+ return max(found)
652
+ return ""
653
+
654
+
655
+ def _pdf_text(path):
656
+ from pypdf import PdfReader
657
+ reader = PdfReader(str(path))
658
+ pages = []
659
+ for page in reader.pages:
660
+ try:
661
+ pages.append(page.extract_text() or "")
662
+ except Exception:
663
+ pages.append("")
664
+ return pages
665
+
666
+
667
+ def _pieces(rows):
668
+ """Split normalised (line, pin) rows into chunk-sized pieces, keeping
669
+ each piece's pins: [(text, pinpoint)]. Same boundaries as before the
670
+ pins existed -- split_lines only ever cuts between lines, so walking its
671
+ pieces' line counts recovers each piece's rows."""
672
+ body = "\n".join(t for t, _ in rows)
673
+ texts = split_lines(body, 1800) if len(body) > 2400 else [body]
674
+ out, at = [], 0
675
+ for text in texts:
676
+ count = text.count("\n") + 1
677
+ out.append((text, _pinpoint([p for _, p in rows[at:at + count]])))
678
+ at += count
679
+ return out
680
+
681
+
682
+ def _chunks_for(n, pages, url, lang="en", atip=False, stats=None):
683
+ """Chunks for one chapter. `atip` strips an ATIP release's furniture
684
+ and pinpoints its page stamps; `stats`, if given, collects the rejected
685
+ heading candidates by reason."""
686
+ title = TITLES.get(n, f"ENF {n}")
687
+ fr = lang == "fr"
688
+ # a line-final soft hyphen is the printed hyphen of a broken word; kept
689
+ # as '-' so it survives norm_ws for the dehyphenate pass below
690
+ pages = [mark_soft_breaks(page) for page in pages]
691
+ updated = chapter_updated(pages)
692
+ rows = (clean_atip_pages(pages) if atip
693
+ else [(line, None) for page in pages for line in page.split("\n")])
694
+ lines = [line for line, _ in rows]
695
+ annex_at = _annex_start(n, lines)
696
+ main_end = len(lines) if annex_at is None else annex_at
697
+ # Segment on the manual's own numbered headings when enough are found;
698
+ # PDF extraction is uneven, so fall back to plain size-splitting.
699
+ cands = []
700
+ for i, line in enumerate(lines[:main_end]):
701
+ m = _HEADING.match(line.strip())
702
+ if not m:
703
+ continue
704
+ heading = _norm(m.group(2))
705
+ why = heading_rejection(heading)
706
+ if why:
707
+ if stats is not None:
708
+ stats[why] = stats.get(why, 0) + 1
709
+ continue
710
+ cands.append((i, m.group(1), heading))
711
+ spans = [len(_norm(" ".join(lines[i + 1:(cands[k + 1][0] if k + 1 < len(cands)
712
+ else main_end)])))
713
+ for k, (i, _num, _h) in enumerate(cands)]
714
+ keep = numbering_run([num for _, num, _ in cands],
715
+ [span > 120 for span in spans])
716
+ if stats is not None and len(keep) < len(cands):
717
+ stats["out-of-sequence"] = (stats.get("out-of-sequence", 0)
718
+ + len(cands) - len(keep))
719
+ marks = [c for k, c in enumerate(cands) if k in keep]
720
+ words = chapter_words(lines)
721
+
722
+ def full_title(heading, seg):
723
+ # seg[0] is the heading line itself; the title may wrap onto the
724
+ # lines after it (complete_heading).
725
+ return repair_split_words(
726
+ complete_heading(heading, [t for t, _ in seg[1:]], lang), words)
727
+
728
+ # (id key, section, marginal note, heading field, cite label, rows)
729
+ segments = []
730
+ if len(marks) >= 6:
731
+ # Front matter: the cover and the 'Updates to chapter' listing, which
732
+ # say when and how the chapter changed. Before the heading vetting it
733
+ # survived by accident, under a table-of-contents line taken for a
734
+ # heading; now it precedes the first heading, so it is kept as its
735
+ # own segment, minus the contents lines: dot-leader entries, and the
736
+ # numbered entries a contents page prints without leaders (ENF 14's
737
+ # OCR lists all 36 section titles that way) -- a block of every
738
+ # section title would compete with the sections themselves. A
739
+ # numbered line that is not a contents entry stays: the update
740
+ # listing's own notes often open with the section they changed
741
+ # ("7.5 The table for search levels previously found at 7.6 has
742
+ # been modified", ENF 12).
743
+ body_heads = _body_heads(lines, marks[0][0], main_end)
744
+ front = [(t, p) for t, p in _norm_rows(rows[:marks[0][0]])
745
+ if not _LEADERS.search(t)
746
+ and not contents_entry(t, body_heads)]
747
+ if len("\n".join(t for t, _ in front)) > 120:
748
+ heading = _FRONT_LABEL[lang]
749
+ label = f"{'liminaire' if fr else 'front matter'} ({heading})"
750
+ segments.append(("front", f"ENF {n} front matter", heading, "",
751
+ label, "", front))
752
+ apps = appendix_marks(lines, marks[-1][0] + 1, main_end)
753
+ body_end = apps[0][0] if apps else main_end
754
+ for j, (i, num, heading) in enumerate(marks):
755
+ end = marks[j + 1][0] if j + 1 < len(marks) else body_end
756
+ seg = _norm_rows(rows[i:end])
757
+ if len("\n".join(t for t, _ in seg)) > 120:
758
+ heading = full_title(heading, seg)
759
+ label = (f"art. {num} ({heading})" if fr
760
+ else f"s. {num} ({heading})")
761
+ segments.append((num.replace(".", "-"), f"ENF {n} s. {num}",
762
+ heading, "", label, "", seg))
763
+ carry = []
764
+ for j, (i, letter, heading) in enumerate(apps):
765
+ end = apps[j + 1][0] if j + 1 < len(apps) else main_end
766
+ seg = carry + _norm_rows(rows[i:end])
767
+ if len("\n".join(t for t, _ in seg)) <= 120 and j + 1 < len(apps):
768
+ # A title-only appendix (its form is an image: ENF 20's
769
+ # "Annex A - Detention Oversight Form") joins the next one
770
+ # rather than vanishing.
771
+ carry = seg
772
+ continue
773
+ carry = []
774
+ if len("\n".join(t for t, _ in seg)) > 120:
775
+ heading = full_title(heading, _norm_rows(rows[i:end]))
776
+ label = f"{'app.' if fr else 'App.'} {letter} ({heading})"
777
+ segments.append((f"app-{letter.lower()}",
778
+ f"ENF {n} App. {letter}", heading, "",
779
+ label, "", seg))
780
+ if not segments:
781
+ text = "\n".join(lines[:main_end])
782
+ at = 0
783
+ for k, piece in enumerate(split_lines(text, 1800), start=1):
784
+ count = piece.count("\n") + 1
785
+ seg = _norm_rows(rows[at:at + count])
786
+ at += count
787
+ if len("\n".join(t for t, _ in seg)) > 120:
788
+ heading = f"{title} (part {k})"
789
+ label = (f"art. {k} ({heading})" if fr
790
+ else f"s. {k} ({heading})")
791
+ segments.append((str(k), f"ENF {n} s. {k}", heading, "",
792
+ label, "", seg))
793
+ if annex_at is not None:
794
+ segments.extend(_annex_as_segments(n, rows[annex_at:], lang))
795
+ out = []
796
+ for key, section, heading, ground, label, division, seg in segments:
797
+ pieces = _pieces(seg)
798
+ total = len(pieces)
799
+ for k, (piece, pin) in enumerate(pieces, start=1):
800
+ suffix = "" if total == 1 else f"-p{k}"
801
+ note = "" if total == 1 else f" (part {k} of {total})"
802
+ where = f", ATIP p. {pin}" if pin else ""
803
+ out.append({
804
+ "id": (f"enf-fr-{n}-{key}{suffix}" if fr
805
+ else f"enf-{n}-{key}{suffix}"),
806
+ "doc_type": "memorandum",
807
+ "act_code": f"ENF-{n}",
808
+ "act_short": f"ENF {n}",
809
+ "act_name": (f"ENF {n} — {title} (édition française)" if fr
810
+ else f"ENF {n} — {title}"),
811
+ "section": section,
812
+ "marginal_note": heading,
813
+ "part": title,
814
+ "division": division,
815
+ "heading": ground,
816
+ # line-break hyphens closed up inside the chunk only, after
817
+ # the titles, ids and pins are fixed (2026-09-22)
818
+ "text": dehyphenate(piece, words),
819
+ "history": "",
820
+ "last_amended": updated,
821
+ "current_to": updated,
822
+ "citation": f"ENF {n}, {label}{note}{where}",
823
+ "source_url": url,
824
+ **({"lang": "fr"} if fr else {}),
825
+ })
826
+ return out
827
+
828
+
829
+ def _annex_as_segments(n, rows, lang):
830
+ """ENF 1's jurisprudence annex as per-case segments (annex_segments)."""
831
+ fr = lang == "fr"
832
+ app = "app. A" if fr else "App. A"
833
+ segments = []
834
+ for seg in annex_segments(rows):
835
+ if seg["case"] is None:
836
+ heading, key, label = (_ANNEX_LABEL[lang].split(" — ", 1)[1],
837
+ "app-a", app)
838
+ ground = ""
839
+ else:
840
+ ground = seg["ground"]
841
+ gslug = _ground_slug(ground)
842
+ key = "-".join(p for p in ("app-a", gslug,
843
+ _party_slug(seg["case"])) if p)
844
+ heading = seg["case"]
845
+ k = seg["absorbed"]
846
+ if k:
847
+ heading += (f" (et {k} autre{'s' if k > 1 else ''} "
848
+ f"décision{'s' if k > 1 else ''})" if fr else
849
+ f" (and {k} more case{'s' if k > 1 else ''})")
850
+ sep = " : " if fr else ": "
851
+ label = (f"{app} ({ground}{sep}{heading})" if ground
852
+ else f"{app} ({heading})")
853
+ if len("\n".join(t for t, _ in seg["rows"])) > 120:
854
+ segments.append((key, f"ENF {n} App. A", heading, ground, label,
855
+ _ANNEX_LABEL[lang], seg["rows"]))
856
+ return segments
857
+
858
+
859
+ def _norm_rows(rows):
860
+ """Normalised non-empty (line, pin) rows."""
861
+ return [(t, p) for t, p in ((_norm(line), p) for line, p in rows) if t]
862
+
863
+
864
+ def _norm_block(lines):
865
+ return "\n".join(p for p in (_norm(l) for l in lines) if p)
866
+
867
+
868
+ _HTTP_404 = re.compile(r"\b404\b")
869
+
870
+
871
+ def is_missing_upstream(exc):
872
+ """True when a fetch failed because the chapter simply is not there (404).
873
+
874
+ _CHAPTERS is a blind range(1, 41) probe, so on a healthy run most numbers
875
+ 404 -- and a chapter IRCC RETIRES starts 404ing too. That is this module's
876
+ documented signal to drop a chapter, so a 404 must not be preserved
877
+ through: doing so would pin every withdrawn chapter in the corpus forever.
878
+ Every other fetch error (TLS, timeout, a dropped connection) is a real
879
+ failure the stored copy should survive. The two fetch paths report
880
+ differently -- urllib raises HTTPError with a status, the PowerShell
881
+ fallback raises CalledProcessError with the status only in its stderr
882
+ prose ("The remote server returned an error: (404) Not Found."), so that
883
+ side is a text match.
884
+ """
885
+ if isinstance(exc, urllib.error.HTTPError):
886
+ return exc.code == 404
887
+ stderr = getattr(exc, "stderr", "") or ""
888
+ if isinstance(stderr, bytes):
889
+ stderr = stderr.decode("utf-8", "replace")
890
+ # the fetch script's own status line decides when it is there (since the
891
+ # verification fixes of 2026-09-22): a 5xx error page that happens to
892
+ # contain '404' is not a retirement; without the marker, the prose match
893
+ status = powershell_status(stderr)
894
+ if status is not None:
895
+ return status == 404
896
+ return bool(_HTTP_404.search(stderr))
897
+
898
+
899
+ def preserve_failed(chunks, failures, stored):
900
+ """Re-attach the last-good chunks of chapters this run could not produce.
901
+
902
+ A chapter that stops fetching for any reason OTHER than a 404 -- a dropped
903
+ network, a TLS refusal, a PDF that suddenly has no text layer -- has not
904
+ been retired, it just did not arrive, and losing guidance that is still in
905
+ force is the worse error. So the stored copy stays and the run says so
906
+ loudly enough to be investigated. Chapters that 404 (retired, or never
907
+ existed) never enter `failures` and are not resurrected.
908
+ Returns (chunks, preserved act_codes).
909
+ """
910
+ failed = {f"ENF-{n}" for n, _why in failures}
911
+ kept, preserved = preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
912
+ only=failed)
913
+ # A kept chapter goes back in its place, not at the end: on 2026-10-20 the
914
+ # kept ENF 2 and ENF 14 followed ENF 34 in enf.json. The sort is stable, so
915
+ # each chapter's own order is unchanged.
916
+ return sorted(kept, key=_chapter_number), preserved
917
+
918
+
919
+ def _chapter_number(chunk):
920
+ """ENF chapter number of a chunk's act_code ('ENF-14' -> 14); anything else last."""
921
+ m = re.fullmatch(r"ENF-(\d+)", chunk.get("act_code", ""))
922
+ return int(m.group(1)) if m else float("inf")
923
+
924
+
925
+ def build(chapters=_CHAPTERS, allow_shrink=False, force=False, lang="en"):
926
+ # Returns the chunks written, or False if the write was refused.
927
+ fr = lang == "fr"
928
+ all_chunks, skipped, failures = [], [], []
929
+ for n in chapters:
930
+ unofficial = None if fr else UNOFFICIAL.get(n)
931
+ if fr and n in _FR_STALE:
932
+ skipped.append(n)
933
+ continue
934
+ if fr:
935
+ url = _URL_OVERRIDES_FR.get(n, BASE_FR.format(n=n))
936
+ else:
937
+ url = (unofficial["url"] if unofficial
938
+ else _URL_OVERRIDES.get(n, BASE.format(n=n)))
939
+ dest = RAW / (f"enf{n:02d}-fra.pdf" if fr else f"enf{n:02d}.pdf")
940
+ try:
941
+ # The ATIP host is an ordinary WordPress site; canada.ca needs
942
+ # the PowerShell TLS workaround.
943
+ fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0,
944
+ force=force)
945
+ except Exception as exc:
946
+ skipped.append(n)
947
+ if not is_missing_upstream(exc):
948
+ # Not a 404: the chapter may well still be published, so it
949
+ # counts as a failure and its stored chunks are preserved.
950
+ failures.append((n, f"fetch failed: {type(exc).__name__}: {exc}"))
951
+ if dest.exists():
952
+ dest.unlink() # don't cache an error page as a PDF
953
+ continue
954
+ try:
955
+ pages = _pdf_text(dest)
956
+ if sum(len(p) for p in pages) < 500:
957
+ # Image-scan PDF: fall back to the OCR cache (form-feed
958
+ # separated pages), produced offline via Windows OCR.
959
+ ocr = RAW / (f"enf{n:02d}-fra-ocr.txt" if fr
960
+ else f"enf{n:02d}-ocr.txt")
961
+ if ocr.exists():
962
+ pages = ocr.read_text(encoding="utf-8").split("\f")
963
+ print(f" ENF {n:2d}: using OCR cache "
964
+ f"({sum(len(p) for p in pages)} chars)")
965
+ else:
966
+ print(f" !! ENF {n}: no text layer and no OCR cache "
967
+ f"({ocr.name}); skipping")
968
+ failures.append((n, f"no text layer and no {ocr.name}"))
969
+ continue
970
+ rejected = {}
971
+ chunks = _chunks_for(n, pages, url, lang=lang,
972
+ atip=bool(unofficial), stats=rejected)
973
+ if rejected:
974
+ print(f" ENF {n:2d}: heading candidates rejected: "
975
+ + ", ".join(f"{k} {v}" for k, v in
976
+ sorted(rejected.items())))
977
+ if not chunks:
978
+ # Fetched fine, parsed to nothing: the PDF's heading structure
979
+ # changed under us. That is the shape the D-memo loss took, so
980
+ # treat it as a failure and keep the stored chapter.
981
+ print(f" !! ENF {n}: no chunks parsed; keeping stored copy")
982
+ failures.append((n, "no content parsed"))
983
+ continue
984
+ except Exception as exc:
985
+ print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
986
+ failures.append((n, f"parse failed: {type(exc).__name__}: {exc}"))
987
+ continue
988
+ if unofficial:
989
+ for c in chunks:
990
+ c["act_name"] = f"ENF {n} — {unofficial['title']}"
991
+ c["text"] = _UNOFFICIAL_BANNER + c["text"]
992
+ c["citation"] += " (unofficial ATIP copy)"
993
+ c["history"] = unofficial["note"]
994
+ c["source_status"] = ATIP_STATUS
995
+ print(f" ENF {n:2d}: {len(chunks)} chunks"
996
+ + (" [UNOFFICIAL ATIP]" if unofficial else ""))
997
+ all_chunks.extend(chunks)
998
+ uniquify_ids(all_chunks)
999
+ out = PROCESSED_DIR / ("enf-fr.json" if fr else "enf.json")
1000
+ # Preserved chunks keep the ids they were stored with, so they are added
1001
+ # after uniquify_ids -- ids embed the chapter number, and a preserved
1002
+ # chapter is by definition absent from this run's chunks.
1003
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
1004
+ stored_chunks(out))
1005
+ if preserved:
1006
+ print(f" kept the last-good copy of {len(preserved)} chapter(s) "
1007
+ f"that failed this run: {', '.join(preserved)}")
1008
+ if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
1009
+ return False
1010
+ print(f"enf: {len(all_chunks)} chunks -> {out.name} "
1011
+ f"(chapters absent upstream: {skipped})")
1012
+ return all_chunks
1013
+
1014
+
1015
+ def main():
1016
+ chunks = build(allow_shrink="--allow-shrink" in sys.argv,
1017
+ force="--force" in sys.argv,
1018
+ lang="fr" if "--fr" in sys.argv else "en")
1019
+ sys.exit(1 if chunks is False else 0)
1020
+
1021
+
1022
+ if __name__ == "__main__":
1023
+ main()
canlex/eval.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure CanLex retrieval quality against a curated question set.
2
+
3
+ Each item in data/eval/questions.json pairs a realistic legal question with the
4
+ provision(s) or case(s) that answer it. This runs every question through the
5
+ retrieval index and reports Hit@k and MRR. Re-run it after any retrieval change
6
+ -- a new reranker, different embeddings, a chunking tweak -- to see whether
7
+ quality moved, and read the "Misses" list to see exactly what to fix.
8
+
9
+ Question schema (per item):
10
+ query the question text
11
+ answers [[act_short, section], ...]; empty section matches any chunk
12
+ style optional tag ('standard' when absent): 'fact-pattern',
13
+ 'keyword', 'typo', 'french', 'no-answer', 'criminality',
14
+ 'lookup' (a section lookup in the officer's phrasing, the
15
+ pinned provision as gold -- 'IRPA 36(1)(a)', 'ss. 17 and 18
16
+ of IRPR'; 2026-09-21), ... -- metrics are reported per style
17
+ so a hard new slice cannot silently drag the comparable
18
+ legacy numbers
19
+ added optional YYYY-MM-DD: the date the item joined the set. Any
20
+ dated item is outside the frozen legacy slice.
21
+ holdout optional bool; holdout questions are excluded from parameter
22
+ sweeps (sweep.py) so knob choices cannot fit their noise --
23
+ eval.py reports them separately
24
+ must_see optional list of keys that should ALL be in view (the top
25
+ VIEW_K results): {act_short, marginal_note}, {act_short,
26
+ section}, or {act_short} alone for a case (any paragraph).
27
+ Guidance is keyed by marginal note, so a re-chunk that
28
+ renumbers its sections does not invalidate the key. A
29
+ must_see key is not an answer: it never scores Hit@k.
30
+ 'no-answer' style items carry no answers: they exist to calibrate the
31
+ confidence signal (a good corpus-coverage score should be LOW on them),
32
+ reported as a separate table, never as misses.
33
+
34
+ Beyond Hit@k and MRR, each slice reports (roadmap T5.1):
35
+ cov@6 mean share of an item's must_see keys in the top VIEW_K results
36
+ (items with must_see only)
37
+ guid@6 share of items with a guidance result (doc_type 'memorandum':
38
+ ENF, PDI, IRB papers, IRCC guides) in the top VIEW_K that is an
39
+ answer or a must_see key (items naming guidance only)
40
+ Each is printed twice, 'a/b': a over the ranked view, b with the
41
+ official guidance the index co-surfaces for that view ('read next',
42
+ LegislationIndex._cosurface_guidance), which follows the results and
43
+ is never ranked.
44
+
45
+ py -m canlex.eval
46
+ """
47
+ import json
48
+ import os
49
+ import sys
50
+ from collections import defaultdict
51
+
52
+ from .config import ROOT
53
+ from .index import LegislationIndex
54
+
55
+ QUESTIONS = ROOT / "data" / "eval" / "questions.json"
56
+ EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
57
+ VIEW_K = 6 # what the officer sees: cov@6 and guid@6 read this window
58
+ GUIDANCE_DOC_TYPES = frozenset({"memorandum"})
59
+
60
+ # The historical baseline slice: styles that existed before the 2026-07
61
+ # expansion. Numbers over this slice stay comparable across commits -- which
62
+ # only holds if the slice is frozen, so a question added later carries an
63
+ # `added` date (YYYY-MM-DD) and is excluded here while still reported in its
64
+ # style slice. Without this, eleven hard questions added on 2026-09-09
65
+ # moved legacy Hit@1 by 0.05 with no existing question regressing.
66
+ _LEGACY_STYLES = {"standard"}
67
+
68
+
69
+ def is_legacy(item):
70
+ return item["style"] in _LEGACY_STYLES and not item.get("added")
71
+
72
+
73
+ def load_questions():
74
+ """The question set with defaults applied; None if the file is absent."""
75
+ if not QUESTIONS.exists():
76
+ return None
77
+ items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
78
+ for item in items:
79
+ item.setdefault("style", "standard")
80
+ item.setdefault("holdout", False)
81
+ return items
82
+
83
+
84
+ def sweep_questions():
85
+ """The slice parameter sweeps may see (sweep.py calls this): everything
86
+ except holdout items and the no-answer calibration set."""
87
+ items = load_questions() or []
88
+ return [i for i in items
89
+ if not i["holdout"] and i["style"] != "no-answer"]
90
+
91
+
92
+ def _matches(result, answers):
93
+ """True if a search result is one of the gold answers (act + section).
94
+
95
+ A gold answer is [act, section]; an empty section matches any chunk of that
96
+ act/case (used for case-law answers, whose chunks carry no section number).
97
+ """
98
+ r_acts = {result.get("act_short", "").lower(),
99
+ result.get("act_code", "").lower()}
100
+ r_sec = result.get("section", "")
101
+ for act, section in answers:
102
+ if act.lower() in r_acts and (section == r_sec or section == ""):
103
+ return True
104
+ return False
105
+
106
+
107
+ def _norm(text):
108
+ return " ".join((text or "").split()).lower()
109
+
110
+
111
+ def must_match(result, key):
112
+ """True if a result satisfies one must_see key. The key's act_short may
113
+ be the act's short name or its code; a marginal_note compares case- and
114
+ whitespace-folded; an empty section, or no section and no marginal note,
115
+ matches any chunk of the act or case."""
116
+ if _norm(key.get("act_short")) not in {_norm(result.get("act_short")),
117
+ _norm(result.get("act_code"))}:
118
+ return False
119
+ if "marginal_note" in key:
120
+ return _norm(result.get("marginal_note")) == _norm(key["marginal_note"])
121
+ if "section" in key:
122
+ return key["section"] == "" or result.get("section", "") == key["section"]
123
+ return True
124
+
125
+
126
+ def guidance_acts(chunks):
127
+ """Case-folded act_short and act_code of every guidance chunk, so an item
128
+ can tell whether its answers or must_see keys name guidance at all."""
129
+ out = set()
130
+ for c in chunks:
131
+ if c.get("doc_type") in GUIDANCE_DOC_TYPES:
132
+ out.add(_norm(c.get("act_short")))
133
+ out.add(_norm(c.get("act_code")))
134
+ out.discard("")
135
+ return frozenset(out)
136
+
137
+
138
+ def score_item(item, results, read_next=(), guidance=frozenset()):
139
+ """Per-question measures from one search.
140
+
141
+ rank 1-based rank of the first result matching an answer (0 = absent).
142
+ Co-surfaced read-next results (marked 'cosurfaced') follow the
143
+ ranked list and are never ranked.
144
+ cov6 share of the item's must_see keys in the top VIEW_K (None
145
+ without must_see); cov6_rn adds `read_next`, the guidance the
146
+ index co-surfaces for that view.
147
+ g6 a guidance result in the top VIEW_K that is an answer or a
148
+ must_see key (None when the item names no guidance -- `guidance`
149
+ is guidance_acts()); g6_rn adds `read_next`."""
150
+ ranked = [r for r in results if not r.get("cosurfaced")]
151
+ answers = [tuple(a) for a in item.get("answers") or []]
152
+ rank = next((i for i, r in enumerate(ranked, start=1)
153
+ if _matches(r, answers)), 0)
154
+ keys = item.get("must_see") or []
155
+ view = ranked[:VIEW_K]
156
+ view_rn = view + list(read_next)
157
+
158
+ def coverage(rows):
159
+ if not keys:
160
+ return None
161
+ return sum(1 for k in keys
162
+ if any(must_match(r, k) for r in rows)) / len(keys)
163
+
164
+ wants = (any(_norm(a) in guidance for a, _ in answers)
165
+ or any(_norm(k.get("act_short")) in guidance for k in keys))
166
+
167
+ def guided(rows):
168
+ if not wants:
169
+ return None
170
+ return any(r.get("doc_type") in GUIDANCE_DOC_TYPES
171
+ and (_matches(r, answers)
172
+ or any(must_match(r, k) for k in keys))
173
+ for r in rows)
174
+
175
+ return {"rank": rank, "cov6": coverage(view), "cov6_rn": coverage(view_rn),
176
+ "g6": guided(view), "g6_rn": guided(view_rn)}
177
+
178
+
179
+ def _metrics(ranks):
180
+ n = len(ranks) or 1
181
+ hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
182
+ mrr = sum(1.0 / r for r in ranks if r) / n
183
+ return hit(1), hit(3), hit(5), hit(10), mrr
184
+
185
+
186
+ def summarize(scores):
187
+ """Slice aggregates over score_item() dicts."""
188
+ ranks = [s["rank"] for s in scores]
189
+ h1, h3, h5, h10, mrr = _metrics(ranks)
190
+ out = {"n": len(scores), "hit1": h1, "hit3": h3, "hit5": h5,
191
+ "hit10": h10, "mrr": mrr}
192
+ for field in ("cov6", "cov6_rn", "g6", "g6_rn"):
193
+ vals = [float(s[field]) for s in scores if s.get(field) is not None]
194
+ out[field] = sum(vals) / len(vals) if vals else None
195
+ out[f"n_{field}"] = len(vals)
196
+ return out
197
+
198
+
199
+ def _report_line(label, scores):
200
+ m = summarize(scores)
201
+ line = (f" {label:24} n={m['n']:<4} Hit@1 {m['hit1']:.2f} "
202
+ f"Hit@3 {m['hit3']:.2f} Hit@5 {m['hit5']:.2f} "
203
+ f"Hit@10 {m['hit10']:.2f} MRR {m['mrr']:.2f}")
204
+ if m["n_cov6"]:
205
+ line += f" cov@6 {m['cov6']:.2f}/{m['cov6_rn']:.2f} (n={m['n_cov6']})"
206
+ if m["n_g6"]:
207
+ line += f" guid@6 {m['g6']:.2f}/{m['g6_rn']:.2f} (n={m['n_g6']})"
208
+ print(line)
209
+
210
+
211
+ def read_next_for(index, results, positions):
212
+ """The guidance chunks the index co-surfaces for the top VIEW_K of
213
+ `results` (what an officer asking for VIEW_K results would get)."""
214
+ top = [positions[r["id"]] for r in results
215
+ if not r.get("cosurfaced") and r.get("id") in positions][:VIEW_K]
216
+ return [index.chunks[i] for i in index._cosurface_guidance(top)[len(top):]]
217
+
218
+
219
+ def run():
220
+ items = load_questions()
221
+ if items is None:
222
+ print(f"No question set at {QUESTIONS}.", file=sys.stderr)
223
+ return
224
+ # Sweep mode (CANLEX_EVAL_SWEEP=1, set by sweep.py): score only the
225
+ # sweep-visible slice and print the legacy vertical metric block its
226
+ # parser expects -- holdout questions stay blind to every knob choice.
227
+ sweep_mode = bool(os.environ.get("CANLEX_EVAL_SWEEP"))
228
+ if sweep_mode:
229
+ items = [i for i in items
230
+ if not i["holdout"] and i["style"] != "no-answer"]
231
+ index = LegislationIndex()
232
+ positions = {c["id"]: i for i, c in enumerate(index.chunks)}
233
+ guidance = guidance_acts(index.chunks)
234
+ scored = [] # (item, score_item dict)
235
+ misses = []
236
+ no_answer = [] # (query, top confidence) for the calibration table
237
+ for item in items:
238
+ if item["style"] == "no-answer":
239
+ results = index.search(item["query"], top_k=3)
240
+ conf = results[0].get("confidence") if results else None
241
+ no_answer.append((item["query"], conf))
242
+ continue
243
+ results = index.search(item["query"], top_k=EVAL_TOP_K)
244
+ score = score_item(item, results,
245
+ read_next_for(index, results, positions), guidance)
246
+ scored.append((item, score))
247
+ rank = score["rank"]
248
+ if rank == 0 or rank > 5:
249
+ ranked = [r for r in results if not r.get("cosurfaced")]
250
+ misses.append((item, [tuple(a) for a in item["answers"]], rank,
251
+ ranked[0] if ranked else None))
252
+
253
+ all_scores = [s for _, s in scored]
254
+ if sweep_mode:
255
+ h1, h3, h5, h10, mrr = _metrics([s["rank"] for s in all_scores])
256
+ print(f"CanLex retrieval evaluation -- {len(all_scores)} questions "
257
+ f"(sweep slice)\n")
258
+ print(f" Hit@1: {h1:.2f}")
259
+ print(f" Hit@3: {h3:.2f}")
260
+ print(f" Hit@5: {h5:.2f}")
261
+ print(f" Hit@10: {h10:.2f}")
262
+ print(f" MRR: {mrr:.2f}")
263
+ return
264
+ by_style = defaultdict(list)
265
+ for it, s in scored:
266
+ by_style[it["style"]].append(s)
267
+
268
+ print(f"CanLex retrieval evaluation -- {len(all_scores)} scored questions")
269
+ source = getattr(index, "see_also_source", None)
270
+ print(f" (cov@6 and guid@6 print as ranked-view/with-read-next; "
271
+ f"read-next source: {source or 'none -- off'})\n")
272
+ _report_line("ALL", all_scores)
273
+ _report_line("legacy (comparable)", [s for it, s in scored if is_legacy(it)])
274
+ for style in sorted(by_style):
275
+ if style not in _LEGACY_STYLES:
276
+ _report_line(f"style: {style}", by_style[style])
277
+ held = [s for it, s in scored if it["holdout"]]
278
+ if held:
279
+ _report_line("holdout (sweep-blind)", held)
280
+ # The release gate's holdout line (with legacy). A dated slice's
281
+ # holdouts join the line above -- the 2026-09-15 criminality slice's
282
+ # ten took it from Hit@1 0.75 to 0.66 with no comparable question
283
+ # moving -- so the gate reads the undated holdouts, frozen like
284
+ # legacy and comparable across commits.
285
+ _report_line("holdout (undated)",
286
+ [s for it, s in scored if it["holdout"] and not it.get("added")])
287
+ _report_line("sweep-visible", [s for it, s in scored if not it["holdout"]])
288
+
289
+ if no_answer:
290
+ print("\nNo-answer calibration (confidence should be LOW here; the "
291
+ "weak-match hedge fires below 0.72):")
292
+ for query, conf in no_answer:
293
+ shown = f"{conf:.3f}" if conf is not None else "n/a"
294
+ flag = "" if (conf is None or conf < 0.72) else " <-- OVER-CONFIDENT"
295
+ print(f" {shown} {query[:70]}{flag}")
296
+
297
+ if misses:
298
+ print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
299
+ for item, answers, rank, top in misses:
300
+ gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
301
+ where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
302
+ got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
303
+ if top else "nothing")
304
+ print(f" [{where}] ({item['style']}) {item['query']}")
305
+ print(f" gold: {gold} | top result: {got}")
306
+ print()
307
+
308
+
309
+ if __name__ == "__main__":
310
+ run()
canlex/index.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/ingest.py ADDED
@@ -0,0 +1,671 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest Justice Laws XML into structured, section-level JSON chunks."""
2
+ import contextlib
3
+ import re
4
+ import sys
5
+ import time
6
+ import urllib.request
7
+ import xml.etree.ElementTree as ET
8
+
9
+ from ._common import (atomic_write_bytes, norm_ws as _norm, split_lines,
10
+ with_retry, write_corpus)
11
+ from .config import SOURCES, RAW_DIR, PROCESSED_DIR
12
+
13
+ LIMS = "{http://justice.gc.ca/lims}"
14
+ BLOCK_TAGS = {"Subsection", "Paragraph", "Subparagraph", "Clause", "Definition"}
15
+ # A French consolidation heads its divisions 'SECTION 1', 'SECTION 7.1', 'SECTION IV'
16
+ # where the English one says 'DIVISION'; the division field was empty on every
17
+ # French chunk (2026-10-12: all 571 of the CASR 2012's). Upper case and a bare
18
+ # label: an English heading 'Section 5 of the Act — Trafficking' (SOR/97-234) is
19
+ # not a division.
20
+ _FR_DIVISION = re.compile(r"SECTION [0-9IVXLC]+(?:\.\d+)?(?: - |$)")
21
+
22
+ # A section whose entire rendered body is a placeholder marker -- "30 [Amendment]",
23
+ # "162 [Amendments]", "5 [Related provision]" -- left where an enacting Act amended
24
+ # another Act. Content-free; dropped at ingest. ([Repealed, ...] is deliberately
25
+ # excluded here so repealed sections are kept and flagged instead.)
26
+ #
27
+ # The French consolidations print the same placeholders as "[Modification]",
28
+ # "[Modifications]" and "[Disposition connexe]" (measured 2026-09-21: 229, 125
29
+ # and 2 in the raw FR XML); until then the pattern was English-only and the
30
+ # French twins kept 314 content-free stubs as live provisions. A stub may name
31
+ # a RANGE of sections -- '204 et 205 [Modifications]', '211 à 214
32
+ # [Modifications]', '30 to 32 [Amendments]' -- and 104 of those survived the
33
+ # one-token rule (the audit follow-up of 2026-09-21).
34
+ _AMENDMENT_STUB = re.compile(
35
+ r"^\S+(?:\s+(?:et|à|a|to|and|-|–)\s+\S+)?\s*\[(?:Amendments?|Related provisions?"
36
+ r"|Modifications?|Dispositions? connexes?)\]\s*$", re.IGNORECASE)
37
+
38
+ # The semantic embedder (embed.py) only reads the first _MAX_BODY=2000 chars of
39
+ # a chunk, so the tail of a long section is invisible to semantic search. A
40
+ # section whose rendered text exceeds _SPLIT_THRESHOLD is therefore emitted as
41
+ # several piece-chunks, each <= _PIECE_TARGET chars (under the embed window) and
42
+ # split on subsection/line boundaries, so every part is embeddable. Sections at
43
+ # or below the threshold are emitted unchanged -- same id, same embedding --
44
+ # which keeps ~90% of legislation chunks (and their retrieval behaviour) intact.
45
+ _SPLIT_THRESHOLD = 2400
46
+ _PIECE_TARGET = 1800
47
+
48
+
49
+ def _split_text(text, target=_PIECE_TARGET):
50
+ """Split rendered section text into <=target pieces at line boundaries,
51
+ folding small leading/trailing remnants into a neighbour so no piece is a
52
+ stub."""
53
+ return split_lines(text, target, fold_stubs=True)
54
+
55
+
56
+ def _itertext(el):
57
+ return _norm("".join(el.itertext()))
58
+
59
+
60
+ def _label_text(el):
61
+ """A <Label>'s text without its editorial footnote marker.
62
+
63
+ Justice Laws puts `<FootnoteRef idref="...">*</FootnoteRef>` inside the
64
+ Label of a provision whose in-force date is footnoted (IRPA ss. 189-193,
65
+ s. 188(4); the YCJA, the Cannabis Act, the CBSA Act...). _itertext kept
66
+ the '*', so 70 chunks were stored as section '*189', cited as 's. *189',
67
+ linked to a section-*189.html that does not exist, and an exact
68
+ get_section lookup found nothing. Only the Label reads use this: a
69
+ FootnoteRef inside a body <Text> stays in the text as published."""
70
+ parts = [el.text or ""]
71
+ for child in el:
72
+ if child.tag != "FootnoteRef":
73
+ parts.append("".join(child.itertext()))
74
+ parts.append(child.tail or "")
75
+ return _norm("".join(parts))
76
+
77
+
78
+ # The first kilobyte of a Justice Laws consolidation names its root element;
79
+ # a maintenance page or an error body does not, and must not be cached.
80
+ _XML_ROOTS = (b"<Statute", b"<Regulation")
81
+
82
+
83
+ def _xml_body(url):
84
+ """One attempt at reading a consolidation's bytes."""
85
+ req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
86
+ with urllib.request.urlopen(req, timeout=120) as resp:
87
+ return resp.read()
88
+
89
+
90
+ def _download_xml(url, dest):
91
+ """Download a consolidation to `dest` atomically (a temporary file renamed
92
+ into place) and only when the body is Justice Laws XML, so a truncated or
93
+ non-XML response can never poison the cache for every later plain run.
94
+ A 5xx, a dropped connection or a timeout is retried once
95
+ (_common.with_retry); a 404 or a non-XML body is not."""
96
+ data = with_retry(lambda: _xml_body(url), what=url)
97
+ head = data[3:1024] if data[:3] == b"\xef\xbb\xbf" else data[:1024]
98
+ if not any(root in head for root in _XML_ROOTS):
99
+ raise ValueError(f"{url}: response is not Justice Laws XML "
100
+ f"(no <Statute>/<Regulation> in the first KB); not cached")
101
+ atomic_write_bytes(dest, data)
102
+ time.sleep(1.0) # be polite to the Justice Laws server between downloads
103
+ return dest
104
+
105
+
106
+ def fetch_xml(code, force=False):
107
+ src = SOURCES[code]
108
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
109
+ dest = RAW_DIR / f"{code}.xml"
110
+ if dest.exists() and not force:
111
+ return dest
112
+ print(f" downloading {src['xml_url']}")
113
+ return _download_xml(src["xml_url"], dest)
114
+
115
+
116
+ def _parse_or_evict(xml_path, code):
117
+ """parse_legislation, evicting the cached XML when it cannot be parsed:
118
+ a truncated body that passed the root check, or a cache written before
119
+ the check existed, would otherwise fail identically on every plain run
120
+ with nothing saying the cache is the cause."""
121
+ try:
122
+ return parse_legislation(xml_path, code)
123
+ except (ET.ParseError, ValueError) as exc:
124
+ with contextlib.suppress(OSError):
125
+ xml_path.unlink()
126
+ raise type(exc)(f"{exc} -- cached {xml_path.name} removed; re-run to "
127
+ f"download it again") from exc
128
+
129
+
130
+ def _heading_text(h):
131
+ """Render a <Heading> as 'LABEL - TITLE' when both parts are present."""
132
+ label, title = h.find("Label"), h.find("TitleText")
133
+ if label is not None or title is not None:
134
+ parts = [_label_text(label) if label is not None else "",
135
+ _itertext(title) if title is not None else ""]
136
+ return " - ".join(p for p in parts if p)
137
+ return _itertext(h)
138
+
139
+
140
+ def _render_block(el, is_section=False):
141
+ """Recursively render a provision element into readable, structured text."""
142
+ label = note = ""
143
+ inline, blocks = [], []
144
+ for child in el:
145
+ tag = child.tag
146
+ if tag == "Label":
147
+ label = _label_text(child)
148
+ elif tag == "MarginalNote":
149
+ if not is_section: # a section's own marginal note is its title
150
+ note = _itertext(child)
151
+ elif tag == "Text":
152
+ inline.append(_itertext(child))
153
+ elif tag == "HistoricalNote":
154
+ continue
155
+ elif tag == "TableGroup":
156
+ # a table inside a provision (2026-10-12: CASR 2012 s. 78(2), IRPR
157
+ # s. 7.01, the Cannabis Regulations' s. 21): recursing into it kept
158
+ # only the lists nested in its cells and dropped every cell's text
159
+ blocks.append(_table_text(child))
160
+ elif tag in BLOCK_TAGS:
161
+ blocks.append(_render_block(child))
162
+ else:
163
+ nested = _render_block(child)
164
+ if nested:
165
+ blocks.append(nested)
166
+ head = " ".join(p for p in (label, f"[{note}]" if note else "", " ".join(inline)) if p)
167
+ if blocks:
168
+ return (head + "\n" if head else "") + "\n".join(b for b in blocks if b)
169
+ return head
170
+
171
+
172
+ def _table_text(tablegroup):
173
+ """Render a CALS <TableGroup> as caption/title lines plus one
174
+ pipe-separated line per row. Column structure survives as ' | '
175
+ separators, which is enough for BM25/semantic matching and readable in a
176
+ result pane; no attempt is made to preserve spanned cells."""
177
+ out = []
178
+ for t in (tablegroup.find("Caption"), tablegroup.find("table/title")):
179
+ if t is not None:
180
+ txt = _itertext(t)
181
+ if txt:
182
+ out.append(txt)
183
+ for row in tablegroup.iter("row"):
184
+ cells = [_cell_text(e) for e in row.findall("entry")]
185
+ # an empty cell keeps its column: stripping it made the CASR 2012 Schedule 4
186
+ # row 'Section 14 | 5,000 |' (individual maximum only) read as a corporation
187
+ # amount. Only a row with no text at all is dropped.
188
+ if any(cells):
189
+ out.append(" | ".join(cells).strip())
190
+ return "\n".join(out)
191
+
192
+
193
+ # Elements a table cell's words are separated at (CASR 2012 s. 78(2)'s conditions
194
+ # are a <Provision><List><Item> tree; SOR/96-263's cells hold image captions and
195
+ # footnotes). Any other element is inline (Emphasis, Language, a cross-reference)
196
+ # and never splits a word.
197
+ _CELL_BREAKS = BLOCK_TAGS | {"Provision", "List", "Item", "Label", "Text", "Footnote",
198
+ "ImageGroup", "Caption", "AlternateText", "LineBreak"}
199
+
200
+
201
+ def _cell_text(entry):
202
+ """One table cell on one line, every word of it kept: the cell's text with a
203
+ space at each structural boundary, so a nested list reads '(a) ... (b) ...'
204
+ instead of '(a)the officer'. A cell holding only inline text reads exactly as
205
+ its itertext."""
206
+ parts = []
207
+
208
+ def walk(el):
209
+ brk = " " if el.tag in _CELL_BREAKS else ""
210
+ parts.append(brk + (el.text or ""))
211
+ for child in el:
212
+ walk(child)
213
+ parts.append(child.tail or "")
214
+ parts.append(brk)
215
+
216
+ walk(entry)
217
+ return _norm("".join(parts))
218
+
219
+
220
+ def _first_table_title(sched):
221
+ """The title of the table a schedule opens with, or '' -- the CASR 2012 names
222
+ its aerodrome schedules only there ('Class 1 Aerodromes')."""
223
+ for child in sched:
224
+ if child.tag in ("ScheduleFormHeading", "HistoricalNote"):
225
+ continue
226
+ title = child.find("table/title") if child.tag == "TableGroup" else None
227
+ return _itertext(title) if title is not None else ""
228
+ return ""
229
+
230
+
231
+ def _sched_body(el):
232
+ """Render a <Schedule>'s content elements into readable text.
233
+
234
+ Observed shapes across the corpus: List/Item trees (CDSA I-IV, IRPR
235
+ 1.1), DocumentInternal/Group/Provision prose (CDSA V-VI, IRPA's Refugee
236
+ Convention), and TableGroup grids (IRPR Schedule 2 violations, the AMPS
237
+ designated-provisions tables). Lists, groups and provisions reuse
238
+ _render_block, whose Label/Text recursion matches their structure;
239
+ tables get the dedicated renderer above so rows keep their shape."""
240
+ parts = []
241
+ for child in el:
242
+ tag = child.tag
243
+ if tag in ("ScheduleFormHeading", "HistoricalNote"):
244
+ continue
245
+ if tag == "TableGroup":
246
+ parts.append(_table_text(child))
247
+ elif tag == "GroupHeading":
248
+ parts.append(_heading_text(child))
249
+ elif tag in ("DocumentInternal", "Group", "RegulationPiece", "BillPiece"):
250
+ parts.append(_sched_body(child))
251
+ else:
252
+ rendered = _render_block(child)
253
+ if rendered:
254
+ parts.append(rendered)
255
+ return "\n".join(p for p in parts if p)
256
+
257
+
258
+ # Schedule ids with dedicated handling: NifProvs is ingested separately as
259
+ # not-in-force chunks; RelatedProvs (transitional provisions of past
260
+ # amendments) is deliberately excluded -- see _nif_chunks.
261
+ _SCHED_SKIP_IDS = {"NifProvs", "RelatedProvs"}
262
+
263
+
264
+ def _heading_part(heading, tag):
265
+ """The full text of a ScheduleFormHeading child, or ''."""
266
+ el = heading.find(tag) if heading is not None else None
267
+ return _itertext(el) if el is not None else ""
268
+
269
+
270
+ def _schedule_chunks(root, code, src, current_to):
271
+ """Chunks for an Act's substantive Schedules.
272
+
273
+ Previously every Schedule except NifProvs was silently dropped -- which
274
+ meant the CDSA's controlled-substance lists, the AMPS designated-
275
+ provisions tables and IRPA's Refugee Convention articles were absent
276
+ from the corpus even though the sections that depend on them were
277
+ ingested. A source can opt out with 'skip_schedules': True (the Customs
278
+ Tariff does: its schedule is the full tariff nomenclature, served
279
+ separately by tariff_schedule.py from the CBSA edition)."""
280
+ if src.get("skip_schedules"):
281
+ return []
282
+ chunks = []
283
+ for n, sched in enumerate(root.findall("Schedule"), start=1):
284
+ if sched.get("id") in _SCHED_SKIP_IDS:
285
+ continue
286
+ h = sched.find("ScheduleFormHeading")
287
+ label = _norm(h.findtext("Label") or "") if h is not None else ""
288
+ # the whole element's text: findtext stops at the first child, so
289
+ # '(Sections <XRefInternal>797</XRefInternal> and ...)' was stored as '(Sections'
290
+ title = _heading_part(h, "TitleText")
291
+ oref = _heading_part(h, "OriginatingRef")
292
+ label = label or (f"SCHEDULE ({n})" if not title else title)
293
+ table_title = "" if title else _first_table_title(sched)
294
+ repealed = sched.find("Repealed") is not None
295
+ body = "[Repealed]" if repealed else _sched_body(sched)
296
+ if not body:
297
+ continue
298
+ header = " — ".join(p for p in (title, oref) if p)
299
+ text = (header + "\n" if header else "") + body
300
+ base = {
301
+ "act_code": code,
302
+ "act_short": src["short"],
303
+ "act_name": src["name"],
304
+ "section": label.title(),
305
+ "marginal_note": title or table_title or label.title(),
306
+ "part": "SCHEDULES",
307
+ "division": "",
308
+ "heading": label.title(),
309
+ "history": "",
310
+ "last_amended": sched.get(f"{LIMS}lastAmendedDate", ""),
311
+ "in_force": sched.get(f"{LIMS}inforce-start-date", ""),
312
+ "status": "repealed" if repealed else "in force",
313
+ "current_to": current_to,
314
+ "citation": f"{src['short']}, {label.title()}",
315
+ "source_url": src["web_base"],
316
+ }
317
+ pieces = (_split_text(text)
318
+ if len(text) > _SPLIT_THRESHOLD else [text])
319
+ total = len(pieces)
320
+ for k, piece in enumerate(pieces, start=1):
321
+ if total == 1:
322
+ chunks.append({**base, "id": f"{code}-sched{n}", "text": piece})
323
+ else:
324
+ chunks.append({
325
+ **base, "id": f"{code}-sched{n}-p{k}", "text": piece,
326
+ "citation": f"{base['citation']} (part {k} of {total})"})
327
+ return chunks
328
+
329
+
330
+ def _history(section):
331
+ note = section.find("HistoricalNote")
332
+ if note is None:
333
+ return ""
334
+ return "; ".join(_itertext(i) for i in note.iter("HistoricalNoteSubItem"))
335
+
336
+
337
+ def parse_legislation(xml_path, code):
338
+ src = SOURCES[code]
339
+ data = xml_path.read_bytes()
340
+ if data[:3] == b"\xef\xbb\xbf": # some Justice Laws XML files carry a UTF-8 BOM
341
+ data = data[3:]
342
+ root = ET.fromstring(data)
343
+ current_to = root.get(f"{LIMS}current-date", "")
344
+ body = root.find("Body")
345
+ if body is None:
346
+ # A schedule-only instrument (e.g. the Import Control List) has no
347
+ # <Body> at all -- its entire substance is in Schedules.
348
+ if not root.findall("Schedule"):
349
+ raise ValueError(f"{code}: no <Body> element (root <{root.tag}>)")
350
+ body = ()
351
+
352
+ headings, chunks = {}, []
353
+ for el in body:
354
+ if el.tag == "Heading":
355
+ try:
356
+ level = int(el.get("level", "0"))
357
+ except ValueError:
358
+ level = 0
359
+ headings = {k: v for k, v in headings.items() if k < level}
360
+ headings[level] = _heading_text(el)
361
+ continue
362
+ if el.tag != "Section":
363
+ continue
364
+
365
+ label_el = el.find("Label")
366
+ number = _label_text(label_el) if label_el is not None else ""
367
+ note_el = el.find("MarginalNote")
368
+ marginal = _itertext(note_el) if note_el is not None else ""
369
+ body_text = _render_block(el, is_section=True)
370
+
371
+ # Drop content-free amendment/related-provision stubs. When an Act
372
+ # amends OTHER Acts, the consolidation keeps a placeholder section whose
373
+ # whole body is just a marker -- e.g. "30 [Amendment]" -- the actual
374
+ # text living in the Act being amended. These carry no substance, yet
375
+ # were being indexed and rendered with an 'in force' currency line as if
376
+ # they were live provisions (117 in the CBSA Act alone). Skip them.
377
+ # (Repealed sections, "<n> [Repealed, ...]", are NOT dropped -- they are
378
+ # flagged status='repealed' and kept so a section lookup still informs.)
379
+ if _AMENDMENT_STUB.match(body_text):
380
+ continue
381
+
382
+ if number:
383
+ chunk_id = f"{code}-s{number}"
384
+ citation = f"{src['short']}, s. {number}"
385
+ source_url = f"{src['web_base']}section-{number}.html"
386
+ else:
387
+ # An unnumbered <Section> is a preamble or enacting recital. Keep it
388
+ # unless it has no text; the LIMS id keeps the chunk id unique when
389
+ # an Act contains more than one such block.
390
+ if not body_text:
391
+ continue
392
+ number = marginal or "Preamble"
393
+ marginal = marginal or "Preamble"
394
+ lims_id = el.get(f"{LIMS}id") or str(len(chunks))
395
+ chunk_id = f"{code}-pre-{lims_id}"
396
+ citation = f"{src['short']}, {number}"
397
+ source_url = src["web_base"]
398
+
399
+ part = next((v for _, v in sorted(headings.items())
400
+ if v.upper().startswith("PART")), "")
401
+ division = next((v for _, v in sorted(headings.items())
402
+ if v.upper().startswith("DIVISION") or _FR_DIVISION.match(v)), "")
403
+ nearest = headings[max(headings)] if headings else ""
404
+
405
+ # Per-section coming-into-force date (lims:inforce-start-date is present
406
+ # on every section) and repeal status. A wholly-repealed section is a
407
+ # <Section> whose own <Text> is a <Repealed> marker (e.g. "[Repealed,
408
+ # 2018, c. 27, s. 174]"); a live section with a repealed subsection has
409
+ # the <Repealed> nested deeper, so we key on the direct Text/Repealed to
410
+ # flag only sections that are themselves no longer law.
411
+ in_force = el.get(f"{LIMS}inforce-start-date", "")
412
+ status = "repealed" if el.find("Text/Repealed") is not None else "in force"
413
+
414
+ base = {
415
+ "act_code": code,
416
+ "act_short": src["short"],
417
+ "act_name": src["name"],
418
+ "section": number,
419
+ "marginal_note": marginal,
420
+ "part": part,
421
+ "division": division,
422
+ "heading": nearest,
423
+ "history": _history(el),
424
+ "last_amended": el.get(f"{LIMS}lastAmendedDate", ""),
425
+ "in_force": in_force,
426
+ "status": status,
427
+ "current_to": current_to,
428
+ "source_url": source_url,
429
+ }
430
+
431
+ # Short sections (the large majority) stay one chunk with their
432
+ # original id and citation -- unchanged embedding and retrieval. Long
433
+ # ones are split into embeddable pieces; each piece keeps the section
434
+ # number (so citation lookups, section-ref pinning and the eval still
435
+ # match) and notes its part in the citation.
436
+ pieces = (_split_text(body_text)
437
+ if len(body_text) > _SPLIT_THRESHOLD else [body_text])
438
+ n = len(pieces)
439
+ for k, piece in enumerate(pieces, start=1):
440
+ if n == 1:
441
+ chunks.append({**base, "id": chunk_id, "text": piece,
442
+ "citation": citation})
443
+ else:
444
+ chunks.append({**base, "id": f"{chunk_id}-p{k}", "text": piece,
445
+ "citation": f"{citation} (part {k} of {n})"})
446
+
447
+ chunks.extend(_schedule_chunks(root, code, src, current_to))
448
+ chunks.extend(_nif_chunks(root, code, src, current_to))
449
+ return chunks
450
+
451
+
452
+ def _nif_chunks(root, code, src, current_to):
453
+ """Chunks for the Act's AMENDMENTS NOT IN FORCE schedule.
454
+
455
+ Justice Laws consolidations carry enacted-but-not-yet-in-force amendments
456
+ in a Schedule with id='NifProvs' (each entry a RelatedOrNotInForce node:
457
+ a citation heading for the amending Act plus the amendment text as regular
458
+ Section elements). Previously the parser dropped these silently -- the
459
+ corpus's biggest currency blind spot, since a Royal-Assented amendment
460
+ days from changing the law was invisible until consolidation. They are
461
+ ingested with status='not-in-force': retrieval down-weights them below
462
+ live law and the server banners them, but a search for their subject
463
+ matter now surfaces the pending law instead of nothing.
464
+
465
+ The sibling Schedule id='RelatedProvs' (transitional related provisions)
466
+ is deliberately NOT ingested here: those are in-force companion rules of
467
+ past amendments, not pending law, and would need their own treatment.
468
+
469
+ Note the Label inside an entry is the AMENDING Act's section number, so
470
+ the chunk's 'section' is left empty -- these must never satisfy a lookup
471
+ or pin for the target Act's own section numbers.
472
+ """
473
+ nif = next((s for s in root.findall("Schedule")
474
+ if s.get("id") == "NifProvs"), None)
475
+ if nif is None:
476
+ return []
477
+ chunks = []
478
+ for n, entry in enumerate(nif.iter("RelatedOrNotInForce"), start=1):
479
+ title = entry.find("Heading/TitleText")
480
+ cite = _norm("".join(title.itertext())) if title is not None else ""
481
+ cite = cite.lstrip("—- ").strip()
482
+ sections = entry.findall("Section")
483
+ body = "\n".join(p for p in (_render_block(s, is_section=True)
484
+ for s in sections) if p)
485
+ if not body:
486
+ continue
487
+ note_el = sections[0].find("MarginalNote") if sections else None
488
+ marginal = _norm("".join(note_el.itertext())) if note_el is not None else ""
489
+ pieces = (_split_text(body)
490
+ if len(body) > _SPLIT_THRESHOLD else [body])
491
+ total = len(pieces)
492
+ for k, piece in enumerate(pieces, start=1):
493
+ suffix = "" if total == 1 else f"-p{k}"
494
+ part_note = "" if total == 1 else f" (part {k} of {total})"
495
+ chunks.append({
496
+ "id": f"{code}-nif-{n}{suffix}",
497
+ "act_code": code,
498
+ "act_short": src["short"],
499
+ "act_name": src["name"],
500
+ "section": "",
501
+ "marginal_note": marginal or f"Amendment not in force — {cite}",
502
+ "part": "AMENDMENTS NOT IN FORCE",
503
+ "division": "",
504
+ "heading": "Amendments not in force",
505
+ "text": piece,
506
+ "history": cite,
507
+ "last_amended": "",
508
+ "in_force": "",
509
+ "status": "not-in-force",
510
+ "current_to": current_to,
511
+ "citation": (f"{src['short']} — amendment not in force "
512
+ f"({cite}){part_note}"),
513
+ "source_url": src["web_base"],
514
+ })
515
+ return chunks
516
+
517
+
518
+ def ingest(code, force=False, allow_shrink=False):
519
+ print(f"Ingesting {code} ({SOURCES[code]['short']})...")
520
+ xml_path = fetch_xml(code, force=force)
521
+ chunks = _parse_or_evict(xml_path, code)
522
+ out = PROCESSED_DIR / f"{code}.json"
523
+ # An Act whose XML schema shifts parses to a fraction of its sections; the
524
+ # guard turns that into a refusal instead of a quietly gutted statute.
525
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
526
+ raise RuntimeError(f"{code}: refused to write a collapsed corpus")
527
+ print(f" {len(chunks)} sections -> {out.name}")
528
+ return chunks
529
+
530
+
531
+ # --- French corpus (phase 3 of bilingual support) ----------------------------
532
+ # Justice Laws publishes every consolidation as parallel French XML in the
533
+ # SAME schema (English element names, French text, identical section
534
+ # numbering), so the whole parser -- schedules included -- works unchanged.
535
+ # French chunks carry lang='fr', a '-fr-' id infix, French citation style
536
+ # ('art.' / 'annexe') and the French web URL; retrieval filters by query
537
+ # language (index.py), and get_section/xref/list_acts stay English-first.
538
+
539
+ def _fr_xml_url(src):
540
+ return (src["xml_url"]
541
+ .replace("/eng/XML/", "/fra/XML/")
542
+ .replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
543
+ .replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
544
+
545
+
546
+ # The parser writes a few structural labels of its own, in English: the
547
+ # SCHEDULES part, the AMENDMENTS NOT IN FORCE schedule and its entries, the
548
+ # '(part k of n)' note on a split provision, the 'Preamble' and 'SCHEDULE (n)'
549
+ # fallbacks. The French twins carried them verbatim (2026-09-21 audit:
550
+ # 'SCHEDULES' on 845 French chunks, 'Amendments not in force' on 452, the
551
+ # part note on every split article); these are the French consolidations' own
552
+ # words. (field, English pattern, French replacement), applied in order.
553
+ _FR_LABELS = (
554
+ ("part", re.compile(r"^SCHEDULES$"), "ANNEXES"),
555
+ ("part", re.compile(r"^AMENDMENTS NOT IN FORCE$"), "MODIFICATIONS NON EN VIGUEUR"),
556
+ ("heading", re.compile(r"^Amendments not in force$"), "Modifications non en vigueur"),
557
+ ("marginal_note", re.compile(r"^Amendment not in force — "), "Modification non en vigueur — "),
558
+ ("citation", re.compile(r" — amendment not in force \("), " — modification non en vigueur ("),
559
+ ("citation", re.compile(r"\(part (\d+) of (\d+)\)"), r"(partie \1 de \2)"),
560
+ ("citation", re.compile(r", Preamble$"), ", Préambule"),
561
+ ("section", re.compile(r"^Preamble$"), "Préambule"),
562
+ ("marginal_note", re.compile(r"^Preamble$"), "Préambule"),
563
+ ("section", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
564
+ ("heading", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
565
+ ("marginal_note", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
566
+ )
567
+
568
+ # The English section label a French one pairs with: a range reads '55 et
569
+ # 56' / '211 à 214' in French and '55 and 56' / '211 to 214' in English, a
570
+ # schedule 'Annexe 1' / 'Schedule 1', a form 'Formule 6' / 'Form 6' (322
571
+ # French labels with no English twin of the same spelling in the 2026-09-21
572
+ # corpus; this table pairs all but nine). Section labels are title-cased at
573
+ # parse, so the connectors come in both cases.
574
+ _SECTION_EN = (
575
+ (re.compile(r"^Annexes\b"), "Schedules"),
576
+ (re.compile(r"^Annexe\b"), "Schedule"),
577
+ (re.compile(r"^Formule\b"), "Form"),
578
+ (re.compile(r"^Préambule$"), "Preamble"),
579
+ (re.compile(r"\bDe La Partie\b"), "To Part"),
580
+ (re.compile(r" et "), " and "), (re.compile(r" Et "), " And "),
581
+ (re.compile(r" à "), " to "), (re.compile(r" À "), " To "),
582
+ )
583
+
584
+
585
+ def _fr_labels(chunk):
586
+ """`chunk` (a copy) with the parser's English structural labels in French."""
587
+ for field, pattern, french in _FR_LABELS:
588
+ if chunk.get(field):
589
+ chunk[field] = pattern.sub(french, chunk[field])
590
+ return chunk
591
+
592
+
593
+ def section_en(section):
594
+ """The English twin's section label for a French one (see _SECTION_EN)."""
595
+ for pattern, english in _SECTION_EN:
596
+ section = pattern.sub(english, section)
597
+ return section
598
+
599
+
600
+ def _frenchify(chunks, code):
601
+ """French twins of `chunks`: a '-fr-' id, lang 'fr', the French citation
602
+ style and URL, the parser's structural labels in French (_FR_LABELS), and
603
+ `section_en`, the English twin's section label, for pairing the two."""
604
+ out = []
605
+ for c in chunks:
606
+ c = dict(c)
607
+ c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
608
+ c["lang"] = "fr"
609
+ c["citation"] = (c["citation"]
610
+ .replace(", s. ", ", art. ")
611
+ .replace(", Schedule", ", annexe"))
612
+ # the French site cites a regulation as DORS- and a C.R.C. chapter as ch.,
613
+ # as _fr_xml_url does: /fra/reglements/SOR-... and _c._ return 404, so
614
+ # every French regulation link was dead until 2026-10-12
615
+ c["source_url"] = (c["source_url"]
616
+ .replace("/eng/acts/", "/fra/lois/")
617
+ .replace("/eng/regulations/SOR-", "/fra/reglements/DORS-")
618
+ .replace("/eng/regulations/", "/fra/reglements/")
619
+ .replace("_c._", "_ch._"))
620
+ c = _fr_labels(c)
621
+ c["section_en"] = section_en(c.get("section") or "")
622
+ out.append(c)
623
+ return out
624
+
625
+
626
+ def ingest_fr(code, force=False, allow_shrink=False):
627
+ src = SOURCES[code]
628
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
629
+ dest = RAW_DIR / f"{code}-fr.xml"
630
+ if not dest.exists() or force:
631
+ url = _fr_xml_url(src)
632
+ print(f" downloading {url}")
633
+ _download_xml(url, dest)
634
+ chunks = _frenchify(_parse_or_evict(dest, code), code)
635
+ out = PROCESSED_DIR / f"{code}-fr.json"
636
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
637
+ raise RuntimeError(f"{code}: refused to write a collapsed corpus")
638
+ print(f" {len(chunks)} articles -> {out.name}")
639
+ return chunks
640
+
641
+
642
+ def main():
643
+ force = "--force" in sys.argv
644
+ french = "--fr" in sys.argv
645
+ allow_shrink = "--allow-shrink" in sys.argv
646
+ only = [a for a in sys.argv[1:] if not a.startswith("-")]
647
+ codes = only or list(SOURCES)
648
+ failures = []
649
+ for code in codes:
650
+ if code not in SOURCES:
651
+ print(f" SKIP {code}: not a known source")
652
+ continue
653
+ try:
654
+ if french:
655
+ print(f"Ingesting {code} (français)...")
656
+ ingest_fr(code, force=force, allow_shrink=allow_shrink)
657
+ else:
658
+ ingest(code, force=force, allow_shrink=allow_shrink)
659
+ except Exception as exc:
660
+ failures.append((code, exc))
661
+ print(f" FAILED {code}: {type(exc).__name__}: {exc}")
662
+ print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
663
+ for code, exc in failures:
664
+ print(f" FAILED {code}: {exc}")
665
+ # One collapsed Act must not pass as a clean run: refresh.py and the
666
+ # RUNBOOK chain this into `&& py -m canlex.embed`.
667
+ sys.exit(1 if failures else 0)
668
+
669
+
670
+ if __name__ == "__main__":
671
+ main()
canlex/irb_guidelines.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the IRB Chairperson's Guidelines the border workflow actually meets.
2
+
3
+ Guideline 2 (Detention) is what Immigration Division members apply at every
4
+ 48-hour/7-day/30-day review an officer attends; Guidelines 3 (child
5
+ claimants) and 8 (vulnerable persons) govern how detained or referred minors
6
+ and vulnerable people are handled. Soft law: issued under IRPA
7
+ s. 159(1)(h), members are expected to apply them or explain why not --
8
+ ingested as doc_type='memorandum' (guidance), like the D-memoranda.
9
+
10
+ Pages are public HTML on irb-cisr.gc.ca (same host caselaw.py already
11
+ parses for jurisprudential guides).
12
+
13
+ python -m canlex.irb_guidelines
14
+ """
15
+ import re
16
+ import sys
17
+
18
+ from bs4 import BeautifulSoup
19
+
20
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
21
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
22
+ from .config import PROCESSED_DIR, RAW_DIR
23
+
24
+ RAW = RAW_DIR / "irb_guidelines"
25
+
26
+ GUIDELINES = [
27
+ {"num": "2", "file": "guideline2",
28
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir02.aspx",
29
+ "title": "Detention"},
30
+ {"num": "3", "file": "guideline3",
31
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir03.aspx",
32
+ "title": "Child Refugee Claimants: Procedural and Evidentiary Issues"},
33
+ {"num": "8", "file": "guideline8",
34
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir08.aspx",
35
+ "title": "Accessibility to IRB Proceedings — Procedural Accommodations "
36
+ "and Substantive Considerations"},
37
+ # The three not loaded in the first pass: gender-based claims (4), how
38
+ # an RPD hearing is prepared and run (7), and SOGIESC claims (9) --
39
+ # what a port-of-entry claim referred to the RPD meets next.
40
+ {"num": "4", "file": "guideline4",
41
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir04.aspx",
42
+ "title": "Gender Considerations in Proceedings Before the IRB"},
43
+ {"num": "7", "file": "guideline7",
44
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir07.aspx",
45
+ "title": "Preparation and Conduct of a Hearing in the Refugee "
46
+ "Protection Division"},
47
+ {"num": "9", "file": "guideline9",
48
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir09.aspx",
49
+ "title": "Proceedings Before the IRB Involving Sexual Orientation, "
50
+ "Gender Identity and Expression, and Sex Characteristics"},
51
+ ]
52
+
53
+ # "Effective date: May 31, 2021" (or amended/revised) in the page prose.
54
+ _EFFECTIVE = re.compile(
55
+ r"(?:effective|amended|revised)[^.:\n]*:?\s*((?:January|February|March|"
56
+ r"April|May|June|July|August|September|October|November|December)\s+"
57
+ r"\d{1,2},\s+\d{4})", re.I)
58
+ _MONTHS = {m: i for i, m in enumerate(
59
+ ("January", "February", "March", "April", "May", "June", "July",
60
+ "August", "September", "October", "November", "December"), start=1)}
61
+
62
+
63
+ def _iso(prose):
64
+ m = re.match(r"(\w+)\s+(\d{1,2}),\s+(\d{4})", prose or "")
65
+ if not m or m.group(1) not in _MONTHS:
66
+ return ""
67
+ return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
68
+
69
+
70
+ def _sections(main):
71
+ """Yield (label, heading, text) per numbered heading in the page body."""
72
+ heads = main.find_all(["h2", "h3"])
73
+ for i, h in enumerate(heads):
74
+ heading = _norm(h.get_text())
75
+ m = re.match(r"(\d+(?:\.\d+)*)\.?\s+(.*)", heading)
76
+ label = m.group(1) if m else str(i + 1)
77
+ title = m.group(2) if m else heading
78
+ parts = []
79
+ for sib in h.next_siblings:
80
+ if getattr(sib, "name", None) in ("h2", "h3"):
81
+ break
82
+ if getattr(sib, "get_text", None):
83
+ parts.append(sib.get_text("\n"))
84
+ body = "\n".join(p for p in (_norm(x) for x in
85
+ "\n".join(parts).split("\n")) if p)
86
+ if len(body) > 80:
87
+ yield label, title, body
88
+
89
+
90
+ def build(allow_shrink=False, force=False):
91
+ chunks, failures = [], []
92
+ for g in GUIDELINES:
93
+ try:
94
+ html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
95
+ pause=1.0,
96
+ force=force).decode("utf-8", "replace")
97
+ except Exception as exc:
98
+ print(f" !! Guideline {g['num']}: fetch failed "
99
+ f"({type(exc).__name__}: {exc})")
100
+ failures.append(f"IRB-G{g['num']}")
101
+ continue
102
+ soup = BeautifulSoup(html, "html.parser")
103
+ main = soup.find("main") or soup
104
+ eff = _EFFECTIVE.search(main.get_text())
105
+ effective = _iso(eff.group(1)) if eff else ""
106
+ n = 0
107
+ for label, title, body in _sections(main):
108
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
109
+ total = len(pieces)
110
+ for k, piece in enumerate(pieces, start=1):
111
+ suffix = "" if total == 1 else f"-p{k}"
112
+ note = "" if total == 1 else f" (part {k} of {total})"
113
+ chunks.append({
114
+ "id": f"irbg-{g['num']}-{label.replace('.', '-')}{suffix}",
115
+ "doc_type": "memorandum",
116
+ "act_code": f"IRB-G{g['num']}",
117
+ "act_short": f"IRB Guideline {g['num']}",
118
+ "act_name": f"IRB Chairperson's Guideline {g['num']} — "
119
+ f"{g['title']}",
120
+ "section": f"G{g['num']} s. {label}",
121
+ "marginal_note": title,
122
+ "part": g["title"],
123
+ "division": "",
124
+ "heading": "",
125
+ "text": piece,
126
+ "history": "",
127
+ "last_amended": effective,
128
+ "current_to": effective,
129
+ "citation": f"Chairperson's Guideline {g['num']}, "
130
+ f"s. {label} ({title}){note}",
131
+ "source_url": g["url"],
132
+ })
133
+ n += 1
134
+ if not n:
135
+ # Fetched, parsed to nothing: the IRB page template moved.
136
+ print(f" !! Guideline {g['num']}: no content parsed; "
137
+ f"keeping stored copy")
138
+ failures.append(f"IRB-G{g['num']}")
139
+ continue
140
+ print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
141
+ f"effective {effective or '?'}")
142
+ out = PROCESSED_DIR / "irb_guidelines.json"
143
+ chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
144
+ key=lambda c: c["act_code"],
145
+ only=set(failures))
146
+ if preserved:
147
+ print(f" kept the last-good copy of {len(preserved)} guideline(s) "
148
+ f"that failed this run: {', '.join(preserved)}")
149
+ uniquify_ids(chunks)
150
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
151
+ return False
152
+ print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
153
+ return chunks
154
+
155
+
156
+ def main():
157
+ # `is not False` rather than truthiness: a first-ever run that writes an
158
+ # empty corpus succeeded, a refused write did not.
159
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
160
+ force="--force" in sys.argv) is not False
161
+ sys.exit(0 if ok else 1)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
canlex/irb_papers.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the IRB Legal Services papers the s. 36 screening chain leans on.
2
+
3
+ The equivalency pairings cite Hill, Brannson, Li, Steward, Lei, Ward, Masasi
4
+ and the rest through the IRB Legal Services papers -- *Removal Order
5
+ Appeals*, ch. 7 (Criminal Grounds for Removal), ch. 8 (Criminal
6
+ Equivalency) and ch. 9 (Discretionary Jurisdiction), and *Sponsorship
7
+ Appeals*, ch. 2 (Criminal Refusals) -- which synthesise the Federal Court
8
+ line on equivalency, on what counts as a conviction, on the ID's and IAD's
9
+ jurisdiction over the criminality grounds and on the IAD's humanitarian
10
+ discretion after a s. 36 removal order. Public PDFs on irb-cisr.gc.ca;
11
+ ingested as doc_type='memorandum' (persuasive legal commentary by the
12
+ Board's own counsel, not law), like the Chairperson's Guidelines.
13
+
14
+ Archived. The IRB's legal-concepts index pages (RoaAmr.aspx, SpoPar.aspx)
15
+ title every chapter 'ARCHIVED' and carry the Government of Canada archived-
16
+ content notice (kept for reference, not updated since archiving); both
17
+ pages were last modified 2018-07-06. Every chunk records this: status
18
+ 'archived', `archived` (that date), `archive_index` (the index page) and a
19
+ `history` line the renderer prints under the text. The papers are dated
20
+ 2008/2009, so they predate later case law and amendments.
21
+
22
+ Parsing reads PyMuPDF's font information, not bare text lines (a plain-text
23
+ parse titled 38 of SPO ch. 2's 39 chunks 'January 1, 2008' and three ROA
24
+ ch. 8 sections with sentence fragments):
25
+ - running heads -- series name, page number, 'Legal Services', the paper's
26
+ date, '<title> – Ch. N' -- are bold lines in the footer band, dropped
27
+ (with a text filter for the same strings on any bold line), so the date
28
+ is never a section title;
29
+ - a heading is a short bold block of body size or larger: Arial Bold for a
30
+ section, Times Bold for a sub-heading ('Steps in Analysis', titled
31
+ '<section>: <sub-heading>'); a bold ALL-CAPS line ('GENERAL PRINCIPLES')
32
+ is a part heading and goes in `division`;
33
+ - a bold candidate that reads as a fragment -- ends in ':', ',' or ';' or a
34
+ function word, or has a footnote number glued to a word ('Dayan,19') --
35
+ stays body text;
36
+ - footnotes (10 pt, opened by a small marker) become '[fn N] ...' paragraphs
37
+ after the page's text, and superscript references read '[fn N]'.
38
+
39
+ Section numbers are CanLex's (the PDFs number nothing). ROA ch. 8's were in
40
+ use before this parser, so its PAPERS row pins them (`pins`): a pinned
41
+ heading keeps its number, a heading found between two pins takes a decimal
42
+ (§ 1.1, § 20.2), and the old § 9 and § 12 -- page-break fragments, not
43
+ headings -- are folded into the sections the PDF puts them in (§ 8, § 11).
44
+ Unpinned papers number their sections 1, 2, 3, ...
45
+
46
+ python -m canlex.irb_papers [--force] [--allow-shrink]
47
+ """
48
+ import re
49
+ import sys
50
+
51
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
52
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
53
+ from .config import PROCESSED_DIR, RAW_DIR
54
+
55
+ RAW = RAW_DIR / "irb_papers"
56
+ _BASE = "https://www.irb-cisr.gc.ca/en/legal-policy/legal-concepts/Documents/"
57
+ _INDEX = "https://www.irb-cisr.gc.ca/en/legal-policy/legal-concepts/Pages/"
58
+ # 'Date modified' of both index pages, which title every chapter ARCHIVED.
59
+ ARCHIVED = "2018-07-06"
60
+
61
+ # ROA ch. 8's section numbers as first ingested (2026-09-10), in document
62
+ # order. The second 'Committing an Offence Outside Canada' is a real second
63
+ # heading (under 'Evidentiary Matters').
64
+ _ROA8_PINS = [
65
+ ("Introduction", 1),
66
+ ("Equating the Foreign Offence to a Canadian Federal Statute", 2),
67
+ ("Determining Equivalency", 3),
68
+ ("Determining Equivalency: Leading Federal Court Dicta", 4),
69
+ ("Where the Foreign Law is Available", 5),
70
+ ("Where the Foreign Law is Available: Steps in Analysis", 6),
71
+ ("Where the Foreign Law Is Not Available", 7),
72
+ ("Malum in se Offences", 8),
73
+ ("Committing an Offence Outside Canada", 10),
74
+ ("Foreign Convictions", 11),
75
+ ("Committing an Offence Outside Canada", 13),
76
+ ("Relevant Time for Determining Inadmissibility", 14),
77
+ ("Section 44 Report as a Limiting Factor", 15),
78
+ ("Discharges and Pardons", 16),
79
+ ("Effect of a Discharge", 17),
80
+ ("Effect of a Pardon", 18),
81
+ ("Rehabilitation", 19),
82
+ ("Offences Committed Outside Canada by Minors", 20),
83
+ ("Cases", 21),
84
+ ]
85
+
86
+
87
+ def _roa(ch, title, **extra):
88
+ return dict({
89
+ "code": f"IRB-LS-ROA{ch}", "file": f"RoaAmr{ch:02d}_e.pdf",
90
+ "short": f"IRB Legal Services, Removal Order Appeals ch. {ch}",
91
+ "name": f"IRB Legal Services, Removal Order Appeals, Chapter {ch} -- "
92
+ f"{title} (January 1, 2009; archived by the IRB)",
93
+ "title": title, "label": f"ROA ch. {ch}", "dated": "2009-01-01",
94
+ "index": _INDEX + "RoaAmr.aspx"}, **extra)
95
+
96
+
97
+ PAPERS = [
98
+ _roa(7, "Criminal Grounds for Removal"),
99
+ _roa(8, "Criminal Equivalency", pins=_ROA8_PINS),
100
+ _roa(9, "Discretionary Jurisdiction"),
101
+ {"code": "IRB-LS-SPO2", "file": "SpoPar02_e.pdf",
102
+ "short": "IRB Legal Services, Sponsorship Appeals ch. 2",
103
+ "name": "IRB Legal Services, Sponsorship Appeals, Chapter 2 -- "
104
+ "Criminal Refusals (January 1, 2008; archived by the IRB)",
105
+ "title": "Criminal Refusals", "label": "SPO ch. 2",
106
+ "dated": "2008-01-01", "index": _INDEX + "SpoPar.aspx"},
107
+ ]
108
+
109
+ _MONTH_NAMES = ("January", "February", "March", "April", "May", "June",
110
+ "July", "August", "September", "October", "November",
111
+ "December")
112
+ _MONTHS = {m: i for i, m in enumerate(_MONTH_NAMES, start=1)}
113
+ _M = "|".join(_MONTH_NAMES)
114
+ _DATE = re.compile(rf"({_M}) (\d{{1,2}}), (\d{{4}})")
115
+ # The running-head pieces the papers print on every page, each usually its
116
+ # own line: series name, 'Legal Services', page number, the paper's date,
117
+ # '<title> – Ch. N'. A line made only of these pieces is a running head.
118
+ _PIECE = (rf"(?:Removal Order Appeals|Sponsorship Appeals|Legal Services|"
119
+ rf"\d{{1,3}}|(?:{_M}) \d{{1,2}}, \d{{4}}|"
120
+ rf"[A-Z][^\n]{{2,80}}?\s[–-]\s?Ch\.\s?\d{{1,2}})")
121
+ RUNNING_LINE = re.compile(rf"^{_PIECE}(?:\s+{_PIECE})*$", re.I)
122
+
123
+ _FOOTER = 90 # pt from the page bottom: the running-head band
124
+ _TITLE_SIZE = 14 # 'Chapter Eight' / the chapter title (16 pt)
125
+ _HEAD_SIZE = 11.5 # headings are 12 pt; running heads 11 pt
126
+ _NOTE_SIZE = 10.8 # footnote text is 10 pt; body 11-12 pt
127
+ _SUP_SIZE = 8.5 # superscript references 8 pt; footnote markers 6.5 pt
128
+ _FUNCTION_END = re.compile(
129
+ r"\b(?:of|the|and|or|to|in|for|a|an|with|by|on|at|from|that|as|is|are|"
130
+ r"under|where|which)$", re.I)
131
+ _GLUED_NUMBER = re.compile(r"[a-z][,;:]\d{1,3}\b") # 'Dayan,19', 'follows:1'
132
+ _TERMINATED = re.compile(r"[.?!:;\"”’)\]]$")
133
+ _FN_REF = re.compile(r"\s*\[fn \d+\]")
134
+
135
+
136
+ def is_fragment(text):
137
+ """A bold line that is a sentence fragment or a running date, not a
138
+ heading: ends in ':', ',' or ';' or a function word, carries a footnote
139
+ number glued to a word, or is a bare date."""
140
+ t = text.strip()
141
+ return (t.endswith((":", ",", ";")) or bool(_FUNCTION_END.search(t))
142
+ or bool(_GLUED_NUMBER.search(t)) or bool(_DATE.fullmatch(t)))
143
+
144
+
145
+ def _is_bold(span):
146
+ return "Bold" in span["font"] or bool(span.get("flags", 0) & 16)
147
+
148
+
149
+ def _is_glyph(span):
150
+ return any(f in span["font"] for f in ("Wingdings", "Symbol"))
151
+
152
+
153
+ def line_record(spans):
154
+ """One PDF line as {text, size, bold, arial, fn, bullet}, or None when
155
+ blank. Superscript digits become '[fn N]' in the text; a line opened by
156
+ a small digit and otherwise at footnote size is a footnote (fn = N).
157
+ Pure (takes PyMuPDF span dicts), for tests."""
158
+ live = [s for s in spans if s["text"].strip()]
159
+ if not live:
160
+ return None
161
+ top = max(s["size"] for s in spans)
162
+
163
+ def sup(s):
164
+ return (s["text"].strip().isdigit() and s["size"] <= _SUP_SIZE
165
+ and (s["size"] < 0.8 * top or len(live) == 1))
166
+
167
+ fn, bullet, parts = None, False, []
168
+ rest = [s for s in live[1:]]
169
+ if sup(live[0]) and max((s["size"] for s in rest), default=0) <= _NOTE_SIZE:
170
+ fn = live[0]["text"].strip()
171
+ for s in spans:
172
+ if s is live[0] and fn is not None:
173
+ continue
174
+ text = s["text"]
175
+ if s is live[0] and (_is_glyph(s) or text.strip() in ("•", "▪")) \
176
+ and len(text.strip()) == 1:
177
+ bullet = True
178
+ continue
179
+ if text.strip() and sup(s):
180
+ parts.append(f"[fn {text.strip()}]")
181
+ else:
182
+ parts.append(text)
183
+ words = [s for s in live if not sup(s) and not _is_glyph(s)]
184
+ text = _norm("".join(parts))
185
+ text = re.sub(r"(\S)\[fn", r"\1 [fn", text)
186
+ return {"text": text,
187
+ "size": max((s["size"] for s in words), default=top),
188
+ "bold": bool(words) and all(_is_bold(s) for s in words),
189
+ "arial": bool(words) and "Arial" in words[0]["font"],
190
+ "fn": fn, "bullet": bullet}
191
+
192
+
193
+ def pdf_pages(path):
194
+ """Per page, the line records in reading order, each with its block
195
+ number, top y and the page height."""
196
+ import fitz # PyMuPDF
197
+ pages = []
198
+ with fitz.open(path) as doc:
199
+ for page in doc:
200
+ lines = []
201
+ blocks = page.get_text("dict", sort=True)["blocks"]
202
+ for bno, block in enumerate(blocks):
203
+ for line in block.get("lines", []):
204
+ rec = line_record(line["spans"])
205
+ if rec:
206
+ rec.update(block=bno, y=line["bbox"][1],
207
+ h=page.rect.height)
208
+ lines.append(rec)
209
+ pages.append(lines)
210
+ return pages
211
+
212
+
213
+ def _join(texts):
214
+ """Line texts into one paragraph, de-hyphenating line-end breaks."""
215
+ out = ""
216
+ for t in texts:
217
+ t = _norm(t)
218
+ if not t:
219
+ continue
220
+ if out.endswith("-") and len(out) > 1 and out[-2].isalpha() \
221
+ and t[:1].islower():
222
+ out = out[:-1] + t
223
+ else:
224
+ out = f"{out} {t}" if out else t
225
+ return out
226
+
227
+
228
+ def _iso(m):
229
+ return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
230
+
231
+
232
+ def items(pages):
233
+ """(items, date): the paper as a flat list of ('part', title),
234
+ ('h', level, title), ('p', text) and ('fn', number, text), plus the
235
+ date its running head carries ('' if none). Pure given line records."""
236
+ out, date, in_cases, pending = [], "", False, ""
237
+
238
+ def last_p():
239
+ """Index of the last paragraph when only footnotes follow it."""
240
+ for i in range(len(out) - 1, -1, -1):
241
+ if out[i][0] == "p":
242
+ return i
243
+ if out[i][0] != "fn":
244
+ return None
245
+ return None
246
+
247
+ def add_p(text, first_on_page):
248
+ i = last_p() if first_on_page else None
249
+ if i is not None and text[:1].islower() \
250
+ and not _TERMINATED.search(_FN_REF.sub("", out[i][1])):
251
+ out[i] = ("p", f"{out[i][1]} {text}") # continues across the page
252
+ else:
253
+ out.append(("p", text))
254
+
255
+ def flush_pending():
256
+ nonlocal pending
257
+ if pending:
258
+ out.append(("h", 2, pending.rstrip(" –-")))
259
+ pending = ""
260
+
261
+ for lines in pages:
262
+ blocks = []
263
+ for ln in lines:
264
+ if ln["bold"] and (ln["y"] >= ln["h"] - _FOOTER
265
+ or RUNNING_LINE.match(ln["text"])):
266
+ m = _DATE.search(ln["text"])
267
+ if m and not date:
268
+ date = _iso(m)
269
+ continue
270
+ if ln["size"] >= _TITLE_SIZE:
271
+ continue
272
+ if not ln["text"] and ln["fn"] is None:
273
+ continue # a glyph alone (the steps diagrams' arrows)
274
+ if blocks and blocks[-1][0]["block"] == ln["block"]:
275
+ blocks[-1].append(ln)
276
+ else:
277
+ blocks.append([ln])
278
+ first_on_page = True
279
+ for blk in blocks:
280
+ text = _FN_REF.sub("", _join(ln["text"] for ln in blk))
281
+ heading = (all(ln["bold"] and ln["fn"] is None for ln in blk)
282
+ and len(blk) <= 3 and len(text) <= 160
283
+ and text[:1].isupper()
284
+ and (min(ln["size"] for ln in blk) >= _HEAD_SIZE
285
+ or text.upper() == "CASES"))
286
+ # The table of cases: 'CASES' in bold (ch. 7, 8), 'Cases' in
287
+ # plain type (ch. 9) -- a block of that one word either way.
288
+ if text.upper() == "CASES" and len(blk) == 1:
289
+ flush_pending()
290
+ out.append(("h", 2, "Cases"))
291
+ in_cases = True
292
+ continue
293
+ if heading and text.endswith(("–", "-")):
294
+ pending = text # 'Rehabilitation –' / 'Burden of Proof'
295
+ continue
296
+ if heading and pending:
297
+ text, pending = f"{pending} {text}", ""
298
+ if heading and text.isupper() and len(text) > 4:
299
+ out.append(("part", text.title()))
300
+ continue
301
+ if heading and not is_fragment(text):
302
+ out.append(("h", 2 if blk[0]["arial"] else 3, text))
303
+ continue
304
+ flush_pending()
305
+ body = []
306
+ for k, ln in enumerate(blk):
307
+ if ln["fn"] is not None and not ln["text"] and not (
308
+ k + 1 < len(blk) and blk[k + 1]["size"] < _NOTE_SIZE):
309
+ # a lone superscript on its own line (a numbered step's
310
+ # reference), not a footnote opener: keep it inline
311
+ ref = f"[fn {ln['fn']}]"
312
+ if body:
313
+ body[-1] = f"{body[-1]} {ref}"
314
+ elif out and out[-1][0] == "p":
315
+ out[-1] = ("p", f"{out[-1][1]} {ref}")
316
+ continue
317
+ if ln["fn"] is not None:
318
+ if body:
319
+ add_p(_join(body), first_on_page)
320
+ first_on_page, body = False, []
321
+ out.append(("fn", ln["fn"], ln["text"]))
322
+ elif (not in_cases and not ln["bold"]
323
+ and ln["size"] < _NOTE_SIZE
324
+ and any(it[0] == "fn" for it in out)):
325
+ if out[-1][0] == "fn": # footnote text / continuation
326
+ n, t = out[-1][1], out[-1][2]
327
+ out[-1] = ("fn", n, _join([t, ln["text"]]))
328
+ else: # note continued from the last page
329
+ j = max(i for i, it in enumerate(out) if it[0] == "fn")
330
+ out.append(("fn", out[j][1], ln["text"]))
331
+ else:
332
+ if ln["bullet"] and body:
333
+ add_p(_join(body), first_on_page)
334
+ first_on_page, body = False, []
335
+ body.append(("- " if ln["bullet"] else "") + ln["text"])
336
+ if body:
337
+ add_p(_join(body), first_on_page)
338
+ first_on_page = False
339
+ flush_pending()
340
+ return out, date
341
+
342
+
343
+ def sections(its, pins=None):
344
+ """(sections, unused_pins): [{num, title, division, body}] from items().
345
+ A level-3 heading is titled '<section>: <sub-heading>'. Numbers follow
346
+ `pins` ([(title, number)] in document order) when given -- unpinned
347
+ headings between pins take decimals -- else run 1, 2, 3, ..."""
348
+ queue, has_pins = list(pins or []), bool(pins)
349
+ secs, parent, division = [], "", ""
350
+ state = {"int": 0, "sub": 0}
351
+
352
+ def number(title):
353
+ if queue and queue[0][0] == title:
354
+ state["int"], state["sub"] = queue.pop(0)[1], 0
355
+ return str(state["int"])
356
+ if has_pins:
357
+ state["sub"] += 1
358
+ return f"{state['int']}.{state['sub']}"
359
+ state["int"] += 1
360
+ return str(state["int"])
361
+
362
+ def open_section(title):
363
+ secs.append({"num": number(title), "title": title,
364
+ "division": division, "body": []})
365
+
366
+ for it in its:
367
+ if it[0] == "part":
368
+ division = it[1]
369
+ elif it[0] == "h":
370
+ title = it[2]
371
+ if it[1] == 2:
372
+ parent = title
373
+ elif parent:
374
+ title = f"{parent}: {title}"
375
+ open_section(title)
376
+ else:
377
+ if not secs:
378
+ open_section("Introduction")
379
+ secs[-1]["body"].append(it[1] if it[0] == "p"
380
+ else f"[fn {it[1]}] {it[2]}")
381
+ for s in secs:
382
+ s["body"] = "\n".join(s["body"])
383
+ return secs, queue
384
+
385
+
386
+ def history(paper, dated):
387
+ page = paper["index"].rsplit("/", 1)[-1]
388
+ return (f"ARCHIVED by the IRB: its legal-concepts index ({page}, "
389
+ f"modified {ARCHIVED}) marks this paper archived -- kept for "
390
+ f"reference, not updated since. Paper dated {dated}.")
391
+
392
+
393
+ def chunks_for(paper, secs, dated):
394
+ """Corpus chunks for one paper's sections. Pure, for tests."""
395
+ out = []
396
+ for s in secs:
397
+ body = s["body"].strip()
398
+ if not body:
399
+ continue
400
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
401
+ total = len(pieces)
402
+ for k, piece in enumerate(pieces, start=1):
403
+ suffix = "" if total == 1 else f"-p{k}"
404
+ note = "" if total == 1 else f" (part {k} of {total})"
405
+ out.append({
406
+ "id": f"{paper['code'].lower()}-{s['num']}{suffix}",
407
+ "doc_type": "memorandum",
408
+ "act_code": paper["code"],
409
+ "act_short": paper["short"],
410
+ "act_name": paper["name"],
411
+ "section": f"{paper['label']} § {s['num']}",
412
+ "marginal_note": s["title"],
413
+ "part": paper["title"],
414
+ "division": s["division"],
415
+ "heading": "",
416
+ "text": piece,
417
+ "history": history(paper, dated),
418
+ "last_amended": dated,
419
+ "current_to": dated,
420
+ "status": "archived",
421
+ "archived": ARCHIVED,
422
+ "archive_index": paper["index"],
423
+ "citation": f"{paper['short']}, § {s['num']} "
424
+ f"({s['title']}){note}",
425
+ "source_url": _BASE + paper["file"],
426
+ })
427
+ return out
428
+
429
+
430
+ def build(allow_shrink=False, force=False):
431
+ chunks, failures = [], []
432
+ for paper in PAPERS:
433
+ try:
434
+ pdf = RAW / paper["file"]
435
+ fetch_cached(_BASE + paper["file"], pdf, pause=1.0, force=force)
436
+ its, date = items(pdf_pages(pdf))
437
+ except Exception as exc:
438
+ print(f" !! {paper['code']}: fetch/parse failed "
439
+ f"({type(exc).__name__}: {exc})")
440
+ failures.append(paper["code"])
441
+ continue
442
+ secs, unused = sections(its, paper.get("pins"))
443
+ if unused:
444
+ print(f" !! {paper['code']}: pinned headings not found "
445
+ f"(section numbers may have moved): "
446
+ f"{', '.join(t for t, _ in unused)}")
447
+ # The running head carries the paper's date; the PAPERS row is the
448
+ # fallback when the template changes.
449
+ dated = date or paper.get("dated", "")
450
+ got = chunks_for(paper, secs, dated)
451
+ if not got:
452
+ print(f" !! {paper['code']}: no content parsed; keeping stored copy")
453
+ failures.append(paper["code"])
454
+ continue
455
+ chunks += got
456
+ print(f" {paper['code']} ({paper['title']}): "
457
+ f"{sum(1 for s in secs if s['body'].strip())} sections, "
458
+ f"{len(got)} chunks, dated {dated or '?'}")
459
+ out = PROCESSED_DIR / "irb_papers.json"
460
+ chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
461
+ key=lambda c: c["act_code"],
462
+ only=set(failures))
463
+ if preserved:
464
+ print(f" kept the last-good copy of {len(preserved)} paper(s) "
465
+ f"that failed this run: {', '.join(preserved)}")
466
+ uniquify_ids(chunks)
467
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
468
+ return False
469
+ print(f"irb_papers: {len(chunks)} chunks -> {out.name}")
470
+ return chunks
471
+
472
+
473
+ def main():
474
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
475
+ force="--force" in sys.argv) is not False
476
+ sys.exit(0 if ok else 1)
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()
canlex/loganalyze.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mine the MCP call log: what people ask CanLex, and where it falls short.
2
+
3
+ Two record shapes are read:
4
+ calls.jsonl -- one record per tool call (canlex/calllog.py, 2026-09-16):
5
+ CANLEX_LOG_DIR/calls.jsonl on a server with CANLEX_LOG_DIR
6
+ set, and logs/YYYY-MM-DD/<uuid>.jsonl in the private dataset
7
+ the Space pushes to (CANLEX_LOG_DATASET).
8
+ queries.jsonl -- the older search-only log (server._log_query until
9
+ 2026-09-16), read as canlex_search_legislation calls.
10
+
11
+ py -m canlex.loganalyze # CANLEX_LOG_DIR: calls.jsonl, queries.jsonl
12
+ py -m canlex.loganalyze PATH [PATH ...] # files, or folders searched for *.jsonl
13
+ py -m canlex.loganalyze --dataset Beemer0/canlex-call-log
14
+ # token: CANLEX_LOG_TOKEN (this process's
15
+ # environment, else the Windows user
16
+ # environment variable of that name), else
17
+ # asked for with hidden input in a terminal
18
+ py -m canlex.loganalyze --days 7 ... # only the calls of the last 7 days (with
19
+ # --dataset, only those days are downloaded)
20
+
21
+ It reports calls per tool and per day, the most common question texts and
22
+ offence strings, no-result and weak-match searches (the only unbiased source
23
+ of corpus and synonym gaps, since the eval set is authored by the
24
+ maintainer), NOT SETTLED screening results, and errors.
25
+
26
+ Privacy: the arguments are what users typed -- offences, dates, places, and
27
+ sometimes personal facts about a traveller. Treat every log as sensitive: keep
28
+ the dataset private, never commit a log (data/logs/ is gitignored), and do not
29
+ paste this report anywhere public. A --dataset download goes to a temporary
30
+ folder that is deleted afterwards unless --out names one.
31
+ """
32
+ import argparse
33
+ import datetime
34
+ import json
35
+ import os
36
+ import sys
37
+ import tempfile
38
+ from collections import Counter
39
+ from pathlib import Path
40
+
41
+ # Mirror server.HEDGE_THRESHOLD's intent without importing the server: a query
42
+ # whose best result scored below this is a weak match worth reviewing.
43
+ _WEAK = 0.72
44
+
45
+ SEARCH_TOOL = "canlex_search_legislation"
46
+ # CANLEX_RESULT branch codes and rehabilitation statuses the screening tools
47
+ # print as NOT SETTLED (mirrored, not imported, like _WEAK above). 'unknown'
48
+ # is the server's no-pairing-matched branch (server.py, grouped with the
49
+ # open branches in screening.py); 'undetermined' is rehab's 'an open fact
50
+ # decides a limb' status, which the memo prints as NOT SETTLED. Both were
51
+ # counted as settled until 2026-09-21. tests/test_calllog.py pins this list
52
+ # against the codes the server and engine emit.
53
+ OPEN_BRANCHES = ("depends", "tie", "tran-open", "limb-open", "unknown")
54
+ NOT_SETTLED_STATUS = "not-settled"
55
+ NOT_SETTLED_STATUSES = (NOT_SETTLED_STATUS, "undetermined")
56
+ # canlex_transborder on a composed release without the IRPR s. 19(1)(c)-(g)
57
+ # table: printed 'NOT ASSESSED -- the ... table is held for review', its own
58
+ # label, so the report counts it on its own line, not under NOT SETTLED.
59
+ HELD_BRANCH = "table-held"
60
+ OFFENCE_KEYS = ("offense", "offence")
61
+ TOP = 20
62
+
63
+
64
+ def _load(path):
65
+ rows = []
66
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ rows.append(json.loads(line))
72
+ except ValueError:
73
+ continue
74
+ return rows
75
+
76
+
77
+ def log_files(paths):
78
+ """The .jsonl files named, or found (recursively) under the folders named."""
79
+ out = []
80
+ for p in map(Path, paths):
81
+ if p.is_dir():
82
+ out += sorted(f for f in p.rglob("*.jsonl") if f.is_file())
83
+ elif p.is_file():
84
+ out.append(p)
85
+ return out
86
+
87
+
88
+ def _stamp(ts):
89
+ """A record's ts as ISO 8601 text (a legacy record's is epoch seconds)."""
90
+ if isinstance(ts, (int, float)) and not isinstance(ts, bool):
91
+ return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
92
+ .isoformat(timespec="seconds").replace("+00:00", "Z"))
93
+ return str(ts or "")
94
+
95
+
96
+ def _day(ts):
97
+ return _stamp(ts)[:10] or "?"
98
+
99
+
100
+ def normalize(row):
101
+ """A call record, or None. A legacy queries.jsonl row (a 'query', no
102
+ 'tool') becomes a canlex_search_legislation call."""
103
+ if not isinstance(row, dict):
104
+ return None
105
+ if isinstance(row.get("tool"), str):
106
+ if not isinstance(row.get("arguments"), dict):
107
+ row = dict(row, arguments={})
108
+ return row
109
+ if "query" not in row:
110
+ return None
111
+ return {"ts": row.get("ts"), "tool": SEARCH_TOOL,
112
+ "arguments": {k: row.get(k) for k in ("query", "top_k", "act", "doc_type")},
113
+ "ok": True, "n_results": row.get("n_results"),
114
+ "confidence": row.get("confidence"),
115
+ "top_citations": row.get("top_citations") or [], "legacy": True}
116
+
117
+
118
+ def load_calls(paths):
119
+ """(calls, files read) from the files and folders named."""
120
+ files = log_files(paths)
121
+ calls = []
122
+ for f in files:
123
+ calls += [c for c in map(normalize, _load(f)) if c is not None]
124
+ calls.sort(key=lambda c: _stamp(c.get("ts")))
125
+ return calls, files
126
+
127
+
128
+ def _text(value):
129
+ return " ".join(str(value).split()).strip().lower() if value is not None else ""
130
+
131
+
132
+ def offences(call):
133
+ """The offence strings a screening call's arguments carry."""
134
+ args = call.get("arguments") or {}
135
+ out = [args[k] for k in OFFENCE_KEYS if isinstance(args.get(k), str)]
136
+ entries = args.get("entries")
137
+ if isinstance(args.get("entries_json"), str):
138
+ try:
139
+ entries = json.loads(args["entries_json"])
140
+ except ValueError:
141
+ entries = None
142
+ for e in entries if isinstance(entries, list) else []:
143
+ if isinstance(e, dict):
144
+ out += [e[k] for k in OFFENCE_KEYS if isinstance(e.get(k), str)]
145
+ return [t for t in map(_text, out) if t]
146
+
147
+
148
+ def not_settled(call):
149
+ """True when a screening result is NOT SETTLED: an open branch (a tie,
150
+ a fact-dependent branch, no pairing matched), or a rehabilitation status
151
+ that is not settled or undetermined."""
152
+ result = call.get("result")
153
+ if not isinstance(result, dict):
154
+ return False
155
+ if result.get("branch") in OPEN_BRANCHES:
156
+ return True
157
+ for key in ("rehab_status", "status"):
158
+ value = result.get(key)
159
+ values = value if isinstance(value, list) else [value]
160
+ if any(v in NOT_SETTLED_STATUSES for v in values):
161
+ return True
162
+ return False
163
+
164
+
165
+ def held_table(call):
166
+ """True when canlex_transborder answered NOT ASSESSED because the
167
+ composed release ships without the s. 19(1)(c)-(g) table."""
168
+ result = call.get("result")
169
+ return isinstance(result, dict) and result.get("branch") == HELD_BRANCH
170
+
171
+
172
+ def _summary(call, width=70):
173
+ args = call.get("arguments") or {}
174
+ offs = offences(call)
175
+ text = (args.get("query") if isinstance(args.get("query"), str)
176
+ else "; ".join(offs) if offs
177
+ else json.dumps(args, ensure_ascii=False, sort_keys=True))
178
+ text = " ".join(str(text).split())
179
+ return text if len(text) <= width else text[:width - 3] + "..."
180
+
181
+
182
+ def since(days, now=None):
183
+ """The ISO 8601 UTC cutoff for 'the last `days` days', or None for no cutoff."""
184
+ if not days:
185
+ return None
186
+ now = now or datetime.datetime.now(datetime.timezone.utc)
187
+ return (now - datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
188
+
189
+
190
+ def window_patterns(days, now=None):
191
+ """snapshot_download allow_patterns for the dataset's logs/YYYY-MM-DD/ folders
192
+ that can hold a call from the last `days` days (every folder with no cutoff)."""
193
+ if not days:
194
+ return ["logs/*"]
195
+ now = now or datetime.datetime.now(datetime.timezone.utc)
196
+ return [f"logs/{(now - datetime.timedelta(days=n)).strftime('%Y-%m-%d')}/*"
197
+ for n in range(days + 1)]
198
+
199
+
200
+ def report(paths, out=None, days=None, now=None):
201
+ out = out or sys.stdout
202
+ paths = list(paths) if isinstance(paths, (list, tuple)) else [paths]
203
+ calls, files = load_calls(paths)
204
+ cutoff = since(days, now)
205
+ if cutoff:
206
+ calls = [c for c in calls if _stamp(c.get("ts")) >= cutoff]
207
+
208
+ def say(line=""):
209
+ print(line, file=out)
210
+
211
+ where = ", ".join(str(f) for f in files[:3]) + (f" and {len(files) - 3} more"
212
+ if len(files) > 3 else "")
213
+ if not calls:
214
+ say(f"No logged tool calls in {where or ', '.join(map(str, paths))}"
215
+ + (f" in the last {days} day(s)." if cutoff else "."))
216
+ return calls
217
+ legacy = sum(1 for c in calls if c.get("legacy"))
218
+ if cutoff:
219
+ say(f"Window: the last {days} day(s), since {cutoff}Z.")
220
+ span = sorted({_day(c.get("ts")) for c in calls})
221
+ say(f"{len(calls)} logged tool calls, {span[0]} to {span[-1]}, from {len(files)} "
222
+ f"file(s): {where}" + (f" ({legacy} from the legacy search log)" if legacy else ""))
223
+ dropped = sum(c.get("dropped_before") or 0 for c in calls
224
+ if isinstance(c.get("dropped_before"), int))
225
+ if dropped:
226
+ say(f"{dropped} further call(s) were not recorded (the log's queue was full).")
227
+
228
+ per_tool = Counter(c.get("tool") for c in calls)
229
+ errors_by_tool = Counter(c.get("tool") for c in calls if c.get("ok") is False)
230
+ say("\n== Calls per tool ==")
231
+ for tool, n in per_tool.most_common():
232
+ errs = errors_by_tool.get(tool)
233
+ say(f" {n:>5} {tool}" + (f" ({errs} error(s))" if errs else ""))
234
+
235
+ say("\n== Calls per day ==")
236
+ for day, n in sorted(Counter(_day(c.get("ts")) for c in calls).items()):
237
+ say(f" {day} {n:>5}")
238
+
239
+ searches = [c for c in calls if c.get("tool") == SEARCH_TOOL]
240
+ questions = Counter(t for t in (_text((c.get("arguments") or {}).get("query"))
241
+ for c in searches) if t)
242
+ say(f"\n== Most common questions ({SEARCH_TOOL} query; {len(questions)} distinct) ==")
243
+ for q, n in questions.most_common(TOP):
244
+ say(f" {n:>3}x {q}")
245
+
246
+ offence_counts = Counter(o for c in calls for o in offences(c))
247
+ say(f"\n== Most common offence strings (screening tools; {len(offence_counts)} distinct) ==")
248
+ for o, n in offence_counts.most_common(TOP):
249
+ say(f" {n:>3}x {o}")
250
+
251
+ ok_searches = [c for c in searches if c.get("ok") is not False]
252
+ no_result = [c for c in ok_searches if c.get("n_results") == 0]
253
+ weak = sorted((c for c in ok_searches
254
+ if isinstance(c.get("confidence"), (int, float))
255
+ and c["confidence"] < _WEAK and c.get("n_results")),
256
+ key=lambda c: c["confidence"])
257
+ say(f"\n== No-result searches ({len(no_result)}) -- likely corpus or vocabulary gaps ==")
258
+ for c in no_result[:25]:
259
+ say(f" {_summary(c)}")
260
+ say(f"\n== Weak matches (confidence < {_WEAK}; {len(weak)}) -- candidate eval "
261
+ f"questions / synonyms ==")
262
+ for c in weak[:25]:
263
+ top = (c.get("top_citations") or ["?"])[0]
264
+ say(f" {c['confidence']:.3f} {_summary(c):70} -> {top}")
265
+
266
+ unsettled = [c for c in calls if not_settled(c)]
267
+ say(f"\n== NOT SETTLED screening results ({len(unsettled)}) ==")
268
+ for c in unsettled[-25:]:
269
+ result = c.get("result") or {}
270
+ what = ", ".join(f"{k}={result[k]}" for k in ("branch", "rehab_status", "status")
271
+ if result.get(k) is not None)
272
+ needs = result.get("needs") or []
273
+ say(f" {_day(c.get('ts'))} {c.get('tool')} {what} -- {_summary(c, 50)}")
274
+ if needs:
275
+ say(f" needs: {needs[0]}")
276
+
277
+ held = [c for c in calls if held_table(c)]
278
+ say(f"\n== NOT ASSESSED (held table) transborder results ({len(held)}) -- "
279
+ f"the s. 19(1)(c)-(g) table is held for review on the deployment ==")
280
+ for c in held[-25:]:
281
+ say(f" {_day(c.get('ts'))} {c.get('tool')} -- {_summary(c, 50)}")
282
+
283
+ errors = [c for c in calls if c.get("ok") is False]
284
+ say(f"\n== Errors ({len(errors)}) ==")
285
+ for (tool, err), n in Counter((c.get("tool"), c.get("error") or "?")
286
+ for c in errors).most_common():
287
+ say(f" {n:>5} {tool} {err}")
288
+ for c in errors[-10:]:
289
+ say(f" {_stamp(c.get('ts'))} {c.get('tool')} {c.get('error') or '?'} -- {_summary(c, 60)}")
290
+ return calls
291
+
292
+
293
+ def download_dataset(repo_id, token, dest, days=None, now=None):
294
+ """The dataset's logs/ folder (only the last `days` days' folders when
295
+ given), downloaded into `dest`; returns its path."""
296
+ from huggingface_hub import snapshot_download
297
+ return snapshot_download(repo_id=repo_id, repo_type="dataset", token=token,
298
+ allow_patterns=window_patterns(days, now), local_dir=str(dest))
299
+
300
+
301
+ def user_env_token():
302
+ """CANLEX_LOG_TOKEN as a Windows *user* environment variable, read from the
303
+ registry, or "". An unattended run (the weekly check) inherits its parent
304
+ app's environment, which predates a variable set later in Windows' own
305
+ 'Edit environment variables for your account' dialog; reading the registry
306
+ finds it without restarting anything. Deliberately NOT huggingface_hub's
307
+ saved login (`hf auth login` / HF_TOKEN): that token may be broad, and the
308
+ log should be read only with one scoped to the log dataset."""
309
+ try:
310
+ import winreg
311
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
312
+ value, _kind = winreg.QueryValueEx(key, "CANLEX_LOG_TOKEN")
313
+ return str(value or "").strip()
314
+ except Exception:
315
+ return ""
316
+
317
+
318
+ def main(argv=None):
319
+ ap = argparse.ArgumentParser(
320
+ prog="python -m canlex.loganalyze",
321
+ description="Report on the CanLex call log (calls.jsonl, legacy queries.jsonl, or "
322
+ "the private log dataset). The log may hold personal facts: keep it "
323
+ "private.")
324
+ ap.add_argument("paths", nargs="*",
325
+ help="log files, or folders searched for *.jsonl (default: CANLEX_LOG_DIR)")
326
+ ap.add_argument("--dataset", metavar="REPO",
327
+ help="download logs/ from this private Hugging Face dataset repo "
328
+ "(token from the CANLEX_LOG_TOKEN environment variable -- this "
329
+ "process's, else the Windows user variable -- else asked for with "
330
+ "hidden input in a terminal)")
331
+ ap.add_argument("--days", metavar="N", type=int,
332
+ help="report only the calls of the last N days (with --dataset, only "
333
+ "those days' folders are downloaded)")
334
+ ap.add_argument("--out", metavar="DIR",
335
+ help="with --dataset: keep the download in DIR (default: a temporary "
336
+ "folder, deleted afterwards)")
337
+ args = ap.parse_args(argv)
338
+
339
+ if args.dataset:
340
+ token = (os.environ.get("CANLEX_LOG_TOKEN") or "").strip() or user_env_token()
341
+ if not token and sys.stdin is not None and sys.stdin.isatty():
342
+ # asked for with hidden input, never typed on a command line: a
343
+ # shell's history file keeps what is typed there (PowerShell 5.1's
344
+ # PSReadLine 2.0 saves every line, in plain text)
345
+ import getpass
346
+ try:
347
+ token = getpass.getpass(f"Hugging Face token that can read {args.dataset} "
348
+ "(input hidden): ").strip()
349
+ except (EOFError, KeyboardInterrupt):
350
+ token = ""
351
+ if not token:
352
+ print("No token: run this in a terminal to be asked for a Hugging Face token "
353
+ f"that can read {args.dataset}, or set CANLEX_LOG_TOKEN as a Windows user "
354
+ "environment variable (Windows search: 'Edit environment variables for "
355
+ "your account'). Never type the token itself on a command line.",
356
+ file=sys.stderr)
357
+ sys.exit(2)
358
+ if args.out:
359
+ folder = download_dataset(args.dataset, token, Path(args.out), args.days)
360
+ report([folder] + list(args.paths), days=args.days)
361
+ return
362
+ with tempfile.TemporaryDirectory(prefix="canlex-call-log-",
363
+ ignore_cleanup_errors=True) as tmp:
364
+ folder = download_dataset(args.dataset, token, Path(tmp), args.days)
365
+ report([folder] + list(args.paths), days=args.days)
366
+ return
367
+
368
+ paths = list(args.paths)
369
+ if not paths:
370
+ log_dir = Path(os.environ.get("CANLEX_LOG_DIR", "."))
371
+ paths = [p for p in (log_dir / "calls.jsonl", log_dir / "queries.jsonl") if p.exists()]
372
+ if not log_files(paths):
373
+ print(f"No call log at {', '.join(map(str, paths)) or os.environ.get('CANLEX_LOG_DIR', '.')}."
374
+ " Set CANLEX_LOG_DIR on the server (and pass the path here), or use "
375
+ "--dataset.", file=sys.stderr)
376
+ sys.exit(1)
377
+ report(paths, days=args.days)
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
canlex/narrative.py ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The DRAFT s. 44(1) narrative skeleton in the ENF 5 s. 13.1 form, English
2
+ and French (roadmap T4.6; track A step A15, 2026-09-15 -- HELD FOR REVIEW).
3
+
4
+ ENF 5 s. 13.1 (2025-02-20) says that in applying A36(2)(b) 'it is not
5
+ sufficient to state that the person has been convicted of an offence', and
6
+ gives the narrative's form. This module renders that form from what the
7
+ screening chain holds, and from nothing else:
8
+
9
+ - ENF 5's frames, verbatim (EN_FRAME, FR_FRAME -- tests/test_narrative.py
10
+ pins every fragment to enf-5-13-1-p2 and enf-fr-5-13-1-p2/-p3);
11
+ - the IRPA paragraph the chain proposes, with its words verbatim (English:
12
+ the memo's _ALLEGATION, passed in; French: FR_ALLEGATION, pinned to
13
+ I-2.5-fr-s36-p1);
14
+ - the Canadian provision from the pairing's optional 'report_provision'
15
+ (curated JSON, HELD FOR REVIEW; every line verbatim in the corpus in both
16
+ languages), chosen by the matched limb and the maximum at commission;
17
+ - the maximum in the statute's own words, in each language -- ONE rule for
18
+ both: the words the provision's own line uses for the term ('not more than
19
+ 10 years' / 'de dix ans'; 'for life' / 'a perpetuite').
20
+
21
+ What the tool does not hold -- the place and date of issue, the addressee,
22
+ the name and aliases, the city, the conviction date unless given, the
23
+ status evidence, the signature -- stays [CONFIRM: ...]; a value the tool
24
+ proposes is bracketed with what to confirm. Where the chain is not settled
25
+ (a tie, the limb, the offence date, the conviction question) every reading
26
+ gets its own allegation, each with its condition: nothing open is printed
27
+ as settled. A maximum at commission that differs from the current
28
+ provision's (Tran: a curated history row, or a provision not in force at
29
+ commission) prints [CONFIRM], because the former text is not in the corpus.
30
+ A composed release on the approved curated JSON has no report_provision: the
31
+ provision and maximum then stay [CONFIRM], and the block says the provisions
32
+ are held for review.
33
+ """
34
+ import re
35
+
36
+ from canlex.screening import LIFE, _NUM_WORDS, limb_max
37
+
38
+ HEAD = "## DRAFT s. 44(1) narrative skeleton (ENF 5 s. 13.1): verify every bracket"
39
+ INTRO = (
40
+ "DRAFT -- not a report, and nothing in it is found for you. ENF 5 s. 13.1 (2025-02-20) "
41
+ "sets the elements of an A44(1) report: 'it is not sufficient to state that the person "
42
+ "has been convicted of an offence'. The frames below are ENF 5's words; '(drafted)' marks "
43
+ "CanLex's wording, for which ENF 5 gives no sample (the title, the allegation sentence, "
44
+ "the act-branch and two-offence narratives). Verify every bracket: [CONFIRM: ...] is a "
45
+ "fact CanLex does not hold, and [value -- ... CONFIRM] a value CanLex proposes from the "
46
+ "chain above. The maximum is the statute's own words for the maximum at commission, in "
47
+ "each language.")
48
+ MULTI = ("ENF 5 s. 13.4: 'it is generally recommended that the officer writes a separate "
49
+ "report for each allegation' -- one allegation per skeleton below; where a condition "
50
+ "is shown, the chain has not settled which applies.")
51
+ HELD_NOTE = ("The Canadian provisions (the pairings' 'report_provision') are HELD FOR REVIEW "
52
+ "and not in this deployment: the provision and the maximum stay [CONFIRM].")
53
+
54
+ # ENF 5 s. 13.1 (2025-02-20), verbatim: the English sample (enf-5-13-1-p2)
55
+ # and the French one (enf-fr-5-13-1-p2/-p3). The brackets the sample fills
56
+ # are added by the renderer.
57
+ EN_FRAME = {
58
+ "heading": "This report is based on the following information that the above-named individual:",
59
+ "aka": "Also known as",
60
+ "status_fn": ("-Is not a Canadian citizen; is not a permanent resident of Canada; is "
61
+ "not a registered Indian under the Indian Act;"),
62
+ "convicted": "…has been convicted of an offence; namely,",
63
+ "on_or_about": "on or about",
64
+ "at_or_near": "at or near",
65
+ "equivalence": ("This offence, if committed in Canada, would constitute an offence that "
66
+ "may be punishable by way of indictment under"),
67
+ "maximum": "and for which a maximum term of imprisonment",
68
+ "imposed": "may be imposed.",
69
+ }
70
+ FR_FRAME = {
71
+ "heading": ("Le présent rapport est fondé sur les renseignements suivants indiquant que "
72
+ "la personne susnommée :"),
73
+ "aka": "Aussi connu sous le ou les noms suivants :",
74
+ "status_fn": ("-n’est pas un citoyen canadien; n’est pas un résident permanent du Canada; "
75
+ "n’est pas un Indien inscrit en vertu de la Loi sur les Indiens;"),
76
+ "convicted": "… a été reconnu coupable d’une infraction; nommément",
77
+ "on_or_about": "le ou aux environs du",
78
+ "at_or_near": "à ou près de",
79
+ "equivalence": ("Cette infraction, commise au Canada, constituerait une infraction "
80
+ "punissable par mise en accusation en vertu"),
81
+ "maximum": "et pour laquelle une peine maximale d’emprisonnement",
82
+ "imposed": "peut être imposée.",
83
+ }
84
+ FRAME_SOURCE = {"en": ("enf-5-13-1-p2",), "fr": ("enf-fr-5-13-1-p2", "enf-fr-5-13-1-p3")}
85
+
86
+ # IRPA s. 36 in French, verbatim (I-2.5-fr-s36-p1): the words of each
87
+ # paragraph the allegation names -- the French twin of the memo's _ALLEGATION.
88
+ FR_ALLEGATION = {
89
+ "36(1)(b)": ("être déclaré coupable, à l’extérieur du Canada, d’une infraction qui, "
90
+ "commise au Canada, constituerait une infraction sous le régime d’une loi "
91
+ "fédérale punissable d’un emprisonnement maximal d’au moins dix ans",),
92
+ "36(1)(c)": ("commettre, à l’extérieur du Canada, une infraction qui, commise au Canada, "
93
+ "constituerait une infraction sous le régime d’une loi fédérale punissable "
94
+ "d’un emprisonnement maximal d’au moins dix ans",),
95
+ "36(2)(b)": ("être déclaré coupable, à l’extérieur du Canada, d’une infraction qui, "
96
+ "commise au Canada, constituerait une infraction sous le régime d’une loi "
97
+ "fédérale punissable par mise en accusation",),
98
+ "36(2)(b)-two": ("être déclaré coupable, à l’extérieur du Canada,",
99
+ "de deux infractions qui ne découlent pas des mêmes faits et qui, "
100
+ "commises au Canada, constitueraient des infractions sous le régime de "
101
+ "toute loi fédérale"),
102
+ "36(2)(c)": ("commettre, à l’extérieur du Canada, une infraction qui, commise au Canada, "
103
+ "constituerait une infraction sous le régime d’une loi fédérale punissable "
104
+ "par mise en accusation",),
105
+ }
106
+ FR_PARAGRAPH = {"36(1)(b)": "36(1)b)", "36(1)(c)": "36(1)c)", "36(2)(b)": "36(2)b)",
107
+ "36(2)(c)": "36(2)c)"}
108
+ IRPA_EN = "the Immigration and Refugee Protection Act"
109
+ IRPA_FR = "de la Loi sur l’immigration et la protection des réfugiés"
110
+
111
+ # The narratives ENF 5 gives no sample for: an act (s. 36(1)(c)/(2)(c)) and
112
+ # the two-offence limb of s. 36(2)(b). CanLex's wording, built on ENF 5's
113
+ # sentence and the statute's words; printed '(drafted)'.
114
+ EN_ACT = ("…has committed an act outside Canada; namely, [{act}] on or about [{date}] at or "
115
+ "near [{place}], that is an offence in the place where it was committed, namely "
116
+ "[CONFIRM: the foreign provision], and that, if committed in Canada, would "
117
+ "constitute an offence that may be punishable by way of indictment under [{prov}] "
118
+ "and for which a maximum term of imprisonment [{maxw}] may be imposed. (drafted)")
119
+ FR_ACT = ("… a commis, à l’extérieur du Canada, une infraction, nommément [{act}], le ou aux "
120
+ "environs du [{date}] à ou près de [{place}], qui constitue une infraction dans le "
121
+ "lieu où elle a été commise, nommément [CONFIRMER : la disposition étrangère], et "
122
+ "qui, commise au Canada, constituerait une infraction punissable par mise en "
123
+ "accusation en vertu [{prov}] et pour laquelle une peine maximale d’emprisonnement "
124
+ "[{maxw}] peut être imposée. (rédigé par CanLex)")
125
+ EN_TWO = ("…has been convicted of two offences not arising out of a single occurrence; "
126
+ "namely, {offences}. These offences, if committed in Canada, would constitute "
127
+ "offences under an Act of Parliament, namely {provisions}. (drafted)")
128
+ FR_TWO = ("… a été reconnu coupable de deux infractions qui ne découlent pas des mêmes faits; "
129
+ "nommément {offences}. Ces infractions, commises au Canada, constitueraient des "
130
+ "infractions sous le régime de toute loi fédérale, nommément {provisions}. "
131
+ "(rédigé par CanLex)")
132
+
133
+ # CanLex's labels and bracket notes, per language.
134
+ TEXT = {
135
+ "en": {
136
+ "block": "### English",
137
+ "place_date": "Place and date of issue: [CONFIRM: place of issue], [CONFIRM: date of issue]",
138
+ "addressee": ("To: [CONFIRM: the Minister of Public Safety or the Minister of IRCC -- "
139
+ "ENF 5 allows either]"),
140
+ "title": ("Report under subsection 44(1) of the Immigration and Refugee Protection Act "
141
+ "(drafted title -- not ENF 5 wording)"),
142
+ "name": "Name: [CONFIRM: the complete name, correctly spelled]",
143
+ "aka": "[CONFIRM: other names used, where applicable -- or delete the line]",
144
+ "signature": ("[CONFIRM: name and signature of the officer who conducted the "
145
+ "examination or is otherwise making the report]"),
146
+ "allegation": ("The above-named individual is, in my opinion, inadmissible under "
147
+ "paragraph [{para} -- proposed by CanLex from the chain; CONFIRM] of "
148
+ "{irpa}: '{text}'. (drafted)"),
149
+ "which": "Allegation {k} of {n}{cond}:",
150
+ "cond": " -- {cond}",
151
+ "entry": "entry #{n}, ",
152
+ "status_evidence": ("[CONFIRM: the evidence that the person holds none of these "
153
+ "statuses (ENF 5 s. 11.3)]"),
154
+ "status_assumed": "[CONFIRM: status not given -- CanLex assumed a foreign national]",
155
+ "status_pr": ("-[CONFIRM: a permanent resident's status line -- ENF 5 s. 13.1 gives "
156
+ "none (unsourced draft: 'Is a permanent resident of Canada; is not a "
157
+ "Canadian citizen; is not a registered Indian under the Indian Act;')]"),
158
+ "offence": ("{offence} -- the offence as recorded; CONFIRM the offence of conviction "
159
+ "(not the charge) against the certificate of conviction"),
160
+ "act": "CONFIRM: the act -- the record gives '{offence}'",
161
+ "date": ("CONFIRM: the date{given} -- ENF 5 s. 13.1 does not say whether it is the "
162
+ "conviction or the offence date; Appendix E asks for the Conviction Date"),
163
+ "act_date": "CONFIRM: the date of the act{given}",
164
+ "given_conv": "; conviction date given: {d}",
165
+ "given_off": "; offence date given: {d}",
166
+ "place": "CONFIRM: the city or town{given}",
167
+ "given_place": " -- the record gives {p}",
168
+ "proposed": "{cite} -- proposed from the curated pairing {pid} (statute line {chunk}); CONFIRM",
169
+ "others": "; or, only where the facts are: {alts}",
170
+ "alt": "{when}: {cite}",
171
+ "open": "CONFIRM which: {alts} -- the record's words do not say",
172
+ "open_alt": "{cite} ({when})",
173
+ "history": ("CONFIRM: the provision in force at commission -- the maximum then was "
174
+ "{at} (CanLex's reading, {basis}); its text is not in the corpus. Today's "
175
+ "provision, for reference: {cite}"),
176
+ "history_nif": ("CONFIRM: the provision in force at commission -- today's provision "
177
+ "came into force on {frm}, after the offence; the maximum at "
178
+ "commission on CanLex's reading: {at}; the former text is not in the "
179
+ "corpus. Today's provision, for reference only: {cite}"),
180
+ "history_higher": ("CONFIRM: the provision in force at commission -- the curated "
181
+ "history names it: {label}; its text is not in the corpus. Today's "
182
+ "provision, for reference only (not the provision at commission): "
183
+ "{cite}"),
184
+ "later": "; not listed, as not in force at commission: {alts}",
185
+ "later_alt": "{cite} (in force from {frm})",
186
+ "held": ("CONFIRM: the provision -- this deployment holds no report provision for "
187
+ "{pid} (HELD FOR REVIEW); the pairing names {canadian}"),
188
+ "inchoate": ("CONFIRM: the inchoate provision (Criminal Code ss. 463-465) with the "
189
+ "object offence's provision{ref}"),
190
+ "inchoate_ref": " -- the object offence: {cite}",
191
+ "none": ("CONFIRM: the provision -- no curated report provision of {pid} carries {at}; "
192
+ "the pairing names {canadian}"),
193
+ "derived": ("{cite} -- proposed from the curated pairing {pid} (statute line {chunk}); "
194
+ "CONFIRM, with the object offence's provision"),
195
+ "max_words": "{words} -- the statute's words ({chunk}); the maximum {basis}; CONFIRM",
196
+ "max_open": "CONFIRM: the provision decides -- {alts}",
197
+ "max_open_alt": "{words} ({cite})",
198
+ "max_history": ("CONFIRM: {at} -- the maximum at commission from the curated history; "
199
+ "the statute's words then are not in the corpus"),
200
+ "max_both": ("CONFIRM -- EACH READING, NOT SETTLED: {then} at commission (Tran read "
201
+ "strictly; {label}; the statute's words then are not in the corpus) | "
202
+ "{now} on methodology (b) (the lesser maximum in force now, under today's "
203
+ "provision)"),
204
+ "max_nif": ("CONFIRM: {at} -- CanLex's reading: the provision was not in force at "
205
+ "commission, and the statute's words then are not in the corpus"),
206
+ "max_reading": "CONFIRM: {at} -- CanLex's maximum {basis}",
207
+ "max_inchoate": "CONFIRM: {at} -- CanLex's reading of the inchoate rule, from the object offence",
208
+ "max_derived": "CONFIRM: the maximum is derived from the object offence's",
209
+ "max_unknown": "CONFIRM: the maximum at commission",
210
+ "basis_date": "at commission (the offence date given)",
211
+ "basis_current": ("now in force -- the offence date was not given, so the maximum at "
212
+ "commission is not confirmed"),
213
+ "basis_bound": "at commission, bounded by the {what} date given",
214
+ "basis_other": "at commission",
215
+ "two_offence": "[{offence} -- {entry}CONFIRM] on or about [{date}] at or near [{place}]",
216
+ "two_entry": "entry #{n}; ",
217
+ "two_other": "[CONFIRM: the other offence] on or about [CONFIRM: the date] at or near [CONFIRM: the place]",
218
+ "two_prov": "[{cite} -- proposed from the curated pairing {pid}; CONFIRM]",
219
+ "two_prov_confirm": "[CONFIRM: the provision for {what}]",
220
+ "and": " and ",
221
+ "life": "for life",
222
+ "years": "{n} years",
223
+ },
224
+ "fr": {
225
+ "block": "### Français",
226
+ "place_date": "Lieu et date : [CONFIRMER : lieu], [CONFIRMER : date]",
227
+ "addressee": ("Destinataire : [CONFIRMER : ministre de la Sécurité publique ou ministre "
228
+ "d’IRCC -- l’ENF 5 permet l’un ou l’autre]"),
229
+ "title": ("Rapport établi en vertu du paragraphe 44(1) de la Loi sur l’immigration et la "
230
+ "protection des réfugiés (titre rédigé par CanLex -- pas le libellé de l’ENF 5)"),
231
+ "name": "Nom : [CONFIRMER : nom complet, épelé correctement]",
232
+ "aka": "[CONFIRMER : autres noms utilisés, le cas échéant -- ou supprimer la ligne]",
233
+ "signature": ("[CONFIRMER : nom et signature de l’agent qui a mené l’examen ou qui a "
234
+ "rédigé le rapport]"),
235
+ "allegation": ("À mon avis, la personne susnommée est interdite de territoire au titre de "
236
+ "l’alinéa [{para} -- proposé par CanLex d’après la chaîne; CONFIRMER] "
237
+ "{irpa} : « {text} ». (rédigé par CanLex)"),
238
+ "which": "Allégation {k} sur {n}{cond} :",
239
+ "cond": " -- hypothèse (note de CanLex, en anglais) : {cond}",
240
+ "entry": "inscription no {n}, ",
241
+ "status_evidence": ("[CONFIRMER : la preuve que la personne n’a aucun de ces statuts "
242
+ "(ENF 5, section 11.3)]"),
243
+ "status_assumed": "[CONFIRMER : statut non fourni -- CanLex a supposé un étranger]",
244
+ "status_pr": ("-[CONFIRMER : ligne de statut d’un résident permanent -- l’ENF 5, section "
245
+ "13.1, n’en donne aucune (ébauche non sourcée : « est un résident permanent "
246
+ "du Canada; n’est pas un citoyen canadien; n’est pas un Indien inscrit en "
247
+ "vertu de la Loi sur les Indiens; »)]"),
248
+ "offence": ("{offence} -- infraction inscrite au dossier; CONFIRMER la déclaration de "
249
+ "culpabilité (et non l’accusation initiale) sur le certificat"),
250
+ "act": "CONFIRMER : l’acte -- le dossier indique « {offence} »",
251
+ "date": ("CONFIRMER : la date{given} -- l’ENF 5, section 13.1, ne précise pas s’il "
252
+ "s’agit de la date de la déclaration de culpabilité ou de celle de "
253
+ "l’infraction; l’annexe E demande la date de la déclaration de culpabilité"),
254
+ "act_date": "CONFIRMER : la date de l’acte{given}",
255
+ "given_conv": "; déclaration de culpabilité : {d}",
256
+ "given_off": "; infraction : {d}",
257
+ "place": "CONFIRMER : la ville{given}",
258
+ "given_place": " -- le dossier indique {p}",
259
+ "proposed": ("{cite} -- proposé d’après l’appariement organisé {pid} (ligne de la loi "
260
+ "{chunk}); CONFIRMER"),
261
+ "others": "; ou, seulement si les faits le montrent (note en anglais) : {alts}",
262
+ "alt": "{when} : {cite}",
263
+ "open": "CONFIRMER laquelle : {alts} -- les termes du dossier ne le disent pas",
264
+ "open_alt": "{cite} ({when})",
265
+ "history": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- la peine "
266
+ "maximale était alors de {at} (lecture de CanLex); son texte n’est pas dans "
267
+ "le corpus. La disposition actuelle, à titre indicatif : {cite}"),
268
+ "history_nif": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- la "
269
+ "disposition actuelle est entrée en vigueur le {frm}, après l’infraction; "
270
+ "peine maximale au moment de l’infraction selon la lecture de CanLex : "
271
+ "{at}; l’ancien texte n’est pas dans le corpus. La disposition actuelle, "
272
+ "à titre indicatif seulement : {cite}"),
273
+ "history_higher": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- "
274
+ "l’historique organisé la désigne (note en anglais) : {label}; son "
275
+ "texte n’est pas dans le corpus. La disposition actuelle, à titre "
276
+ "indicatif seulement (ce n’est pas celle en vigueur au moment de "
277
+ "l’infraction) : {cite}"),
278
+ "later": "; non retenue(s), car pas en vigueur au moment de l’infraction : {alts}",
279
+ "later_alt": "{cite} (en vigueur depuis le {frm})",
280
+ "held": ("CONFIRMER : la disposition -- ce déploiement ne contient aucune disposition de "
281
+ "rapport pour {pid} (EN ATTENTE D’EXAMEN)"),
282
+ "inchoate": ("CONFIRMER : la disposition relative à la tentative, à la complicité ou au "
283
+ "complot (Code criminel, art. 463 à 465), avec celle de l’infraction "
284
+ "visée{ref}"),
285
+ "inchoate_ref": " -- l’infraction visée : {cite}",
286
+ "none": ("CONFIRMER : la disposition -- aucune disposition de rapport organisée de "
287
+ "{pid} ne porte ce maximum ({at})"),
288
+ "derived": ("{cite} -- proposé d’après l’appariement organisé {pid} (ligne de la loi "
289
+ "{chunk}); CONFIRMER, avec la disposition de l’infraction visée"),
290
+ "max_words": ("{words} -- les mots de la loi ({chunk}); la peine maximale {basis}; "
291
+ "CONFIRMER"),
292
+ "max_open": "CONFIRMER : selon la disposition -- {alts}",
293
+ "max_open_alt": "{words} ({cite})",
294
+ "max_history": ("CONFIRMER : {at} -- la peine maximale au moment de l’infraction selon "
295
+ "l’historique organisé; le texte de la loi alors en vigueur n’est pas "
296
+ "dans le corpus"),
297
+ "max_both": ("CONFIRMER -- CHAQUE LECTURE, NON TRANCHÉ : {then} au moment de "
298
+ "l’infraction (arrêt Tran lu strictement; note en anglais : {label}; le "
299
+ "texte de la loi alors en vigueur n’est pas dans le corpus) | {now} selon "
300
+ "la méthode (b) (la peine maximale moins sévère actuellement en vigueur, "
301
+ "selon la disposition actuelle)"),
302
+ "max_nif": ("CONFIRMER : {at} -- lecture de CanLex : la disposition n’était pas en "
303
+ "vigueur au moment de l’infraction, et le texte de la loi alors en vigueur "
304
+ "n’est pas dans le corpus"),
305
+ "max_reading": "CONFIRMER : {at} -- la peine maximale selon CanLex, {basis}",
306
+ "max_inchoate": ("CONFIRMER : {at} -- lecture de CanLex de la règle applicable à la "
307
+ "tentative ou à la complicité, d’après l’infraction visée"),
308
+ "max_derived": "CONFIRMER : la peine maximale découle de celle de l’infraction visée",
309
+ "max_unknown": "CONFIRMER : la peine maximale au moment de l’infraction",
310
+ "basis_date": "au moment de l’infraction (date de l’infraction fournie)",
311
+ "basis_current": ("actuellement en vigueur -- la date de l’infraction n’a pas été "
312
+ "fournie; la peine maximale au moment de l’infraction n’est pas "
313
+ "confirmée"),
314
+ "basis_bound": "au moment de l’infraction, bornée par la date fournie ({what})",
315
+ "basis_other": "au moment de l’infraction",
316
+ "two_offence": ("[{offence} -- {entry}CONFIRMER] le ou aux environs du "
317
+ "[{date}] à ou près de [{place}]"),
318
+ "two_entry": "inscription no {n}; ",
319
+ "two_other": ("[CONFIRMER : l’autre infraction] le ou aux environs du [CONFIRMER : la "
320
+ "date] à ou près de [CONFIRMER : le lieu]"),
321
+ "two_prov": "[{cite} -- proposé d’après l’appariement organisé {pid}; CONFIRMER]",
322
+ "two_prov_confirm": "[CONFIRMER : la disposition pour {what}]",
323
+ "and": " et ",
324
+ "life": "à perpétuité",
325
+ "years": "{n} ans",
326
+ },
327
+ }
328
+ _BOUND_WHAT = {"en": {"conviction": "conviction", "completion": "sentence-completion"},
329
+ "fr": {"conviction": "déclaration de culpabilité",
330
+ "completion": "fin de la peine"}}
331
+ NO_SKELETON = {
332
+ "none": "No skeleton: no Canadian federal equivalent -- no s. 36 allegation on this matter.",
333
+ "pr": ("No skeleton: a permanent resident is not reached by s. 36(2), and this matter is "
334
+ "below the s. 36(1) threshold."),
335
+ "single-summary": ("No skeleton: a single summary-only equivalent grounds nothing -- "
336
+ "s. 36(2)(b) reaches two such offences not arising out of a single "
337
+ "occurrence."),
338
+ "not-settled": ("No skeleton yet: the Canadian equivalent is not settled ({why}) -- "
339
+ "settle it and re-run."),
340
+ "unknown": "No skeleton: no Canadian equivalent identified.",
341
+ "canada": ("No skeleton for entry #{n}: a conviction in Canada (s. 36(1)(a)/(2)(a)) -- ENF 5 "
342
+ "s. 13.1's sample is for a conviction outside Canada; draft from the "
343
+ "certificate of conviction."),
344
+ "canada_one": ("No skeleton: a conviction in Canada (s. 36(1)(a)/(2)(a)) -- ENF 5 s. 13.1's "
345
+ "sample is for a conviction outside Canada; draft from the certificate of "
346
+ "conviction."),
347
+ "occurrence": ("No skeleton yet for the summary-only entries: the single-occurrence "
348
+ "question (s. 36(2)(b)) is open -- both readings above."),
349
+ # the review of batch 1, round 4 (RP-04): the record's words reach content held for
350
+ # review and not in this deployment -- nothing is drafted from it
351
+ "held": ("No skeleton: the record's words reach CanLex content held for review and not in "
352
+ "this deployment -- no allegation is drafted until that content is approved or the "
353
+ "disposition is confirmed from the court record."),
354
+ # disposition roadmap T1.5 (HELD): the act branch after an acquittal
355
+ "act-not-settled": ("No skeleton for s. {para}: after an acquittal or a finding of not guilty, "
356
+ "whether the act branch may be used on the same conduct is NOT SETTLED -- "
357
+ "POLICY CAVEAT ({label}), both readings in step 1."),
358
+ # the review of batch 1, round 3 (MW-08): a routed programme that does not state its
359
+ # underlying termination (a CPL 160.50 sealing)
360
+ "act-not-settled-routed": ("No skeleton for s. {para}: if the underlying termination was an "
361
+ "acquittal ({question}), whether the act branch may be used on the same "
362
+ "conduct is NOT SETTLED -- POLICY CAVEAT ({label}), both readings in "
363
+ "step 1."),
364
+ }
365
+
366
+ # The words a provision's line uses for the term, per language (one rule).
367
+ _EN_TERM = re.compile(r"not (?:more than|exceeding) (?:\d+|[a-z]+) years(?: less a day)?"
368
+ r"|up to (?:\d+|[a-z]+) years|imprisonment for life|life imprisonment",
369
+ re.I)
370
+ _FR_TERM = re.compile(r"(?:maximale? |^\((?:[ivx]+|[a-z](?:\.\d)?)\) )de ((?:\d+|[a-zé]+) ans"
371
+ r"(?: moins un jour)?)|(à perpétuité)")
372
+ # one table for the English number words (audit follow-up 2026-09-21): the
373
+ # one maxima_stated reads, so the two parsers cannot drift apart
374
+ _EN_NUM = _NUM_WORDS
375
+ _FR_NUM = {"un": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7,
376
+ "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13,
377
+ "quatorze": 14}
378
+
379
+
380
+ def term_words(line, lang="en"):
381
+ """The statute's own words for the maximum term in one provision line
382
+ ('not more than 10 years'; 'de dix ans'; life: 'for life' / 'à
383
+ perpétuité'), or None when the line states no term (summary conviction,
384
+ a maximum derived from another offence). The first term the line states
385
+ is the provision's general one (s. 271(a): 10 years; 14 only for a
386
+ complainant under 16 -- the item's 'when' says so)."""
387
+ if lang == "fr":
388
+ m = _FR_TERM.search((line or "").strip())
389
+ if not m:
390
+ return None
391
+ return "à perpétuité" if m.group(2) else "de " + m.group(1)
392
+ m = _EN_TERM.search(line or "")
393
+ if not m:
394
+ return None
395
+ t = m.group(0)
396
+ return "for life" if "life" in t.lower() else t
397
+
398
+
399
+ def term_years(words, lang="en"):
400
+ """The years a term_words() phrase states (LIFE for life), or None."""
401
+ if not words:
402
+ return None
403
+ if "life" in words or "perpétuité" in words:
404
+ return LIFE
405
+ num = _FR_NUM if lang == "fr" else _EN_NUM
406
+ m = re.search(r"(\d+|[a-zé]+) (?:years|ans)", words)
407
+ if not m:
408
+ return None
409
+ g = m.group(1)
410
+ return int(g) if g.isdigit() else num.get(g)
411
+
412
+
413
+ def fr_cite(fr):
414
+ """The French citation after 'en vertu': 'de l’alinéa ...', 'du paragraphe ...'."""
415
+ first = (fr or "").split(" ", 1)[0]
416
+ return ("de l’" if first in ("alinéa", "article") else "du ") + fr
417
+
418
+
419
+ def provisions(pairing):
420
+ return [i for i in (pairing or {}).get("report_provision") or [] if isinstance(i, dict)]
421
+
422
+
423
+ def in_force_at(item, latest):
424
+ """False when a report provision came into force ('in_force_from') after
425
+ the last day the offence can have been committed on this reading --
426
+ 'latest', an ISO date, or None when no date bounds it (wave-4 verifier,
427
+ 2026-09-15: a provision not in force at commission is never proposed)."""
428
+ f = item.get("in_force_from")
429
+ return not (f and latest and str(latest) < f)
430
+
431
+
432
+ def choose(pairing, reading, limb_candidates=()):
433
+ """Which curated report provision(s) fit one reading of a pairing:
434
+ {"kind": ..., "proposed", "others", "items", "at", "now", "later",
435
+ "from", "higher"}. kind: 'proposed' (the provision whose line carries the
436
+ maximum at commission; a primary first), 'open' (candidate limbs with
437
+ different provisions -- the record's words do not say which), 'history'
438
+ (the maximum at commission differs from today's provision: a Tran history
439
+ row, or a provision not in force at commission -- 'from' is the date it
440
+ came into force, and today's provision is named for reference only,
441
+ never as the provision at commission), 'inchoate' (an attempt,
442
+ accessory, counselling or conspiracy reading), 'derived' (a provision
443
+ whose maximum is the object offence's), 'none' (no provision carries
444
+ this maximum), 'held' (the pairing has no report_provision in this
445
+ deployment). reading: the server's _narrative_reading -- 'latest' (the
446
+ last day the offence can have been committed on it) filters out the
447
+ provisions not yet in force ('later'); 'higher' is the curated history
448
+ row whose maximum at commission is HIGHER than the one the reading
449
+ applies (methodology (b)): the maximum then prints both readings."""
450
+ allp = provisions(pairing)
451
+ at = reading.get("max_years")
452
+ out = {"kind": "held", "proposed": None, "others": [], "items": [], "at": at,
453
+ "now": limb_max(pairing or {}, reading.get("limb")) if pairing else None,
454
+ "later": [], "from": reading.get("nif_from"), "higher": reading.get("higher")}
455
+ if not allp:
456
+ return out
457
+ items = [i for i in allp if i.get("role") != "two-plus-summary-only"]
458
+ limb, cands = reading.get("limb"), [c for c in limb_candidates or [] if c]
459
+ if limb:
460
+ pool = [i for i in items if not i.get("limbs") or limb in i["limbs"]]
461
+ elif cands:
462
+ pool = [i for i in items if not i.get("limbs") or set(i["limbs"]) & set(cands)]
463
+ else:
464
+ pool = items
465
+ latest = reading.get("latest")
466
+ later = [i for i in pool if not in_force_at(i, latest)]
467
+ pool = [i for i in pool if in_force_at(i, latest)]
468
+ out.update(items=pool, later=later)
469
+ if reading.get("inchoate"):
470
+ return dict(out, kind="inchoate")
471
+ if later and not pool:
472
+ # every provision of this reading came into force after the offence
473
+ ref = next((i for i in later if i.get("role") == "primary"), later[0])
474
+ return dict(out, kind="history", proposed=ref, later=[],
475
+ **{"from": out["from"] or min(i["in_force_from"] for i in later)})
476
+ groups = ({frozenset(set(i["limbs"]) & set(cands)) for i in pool if i.get("limbs")}
477
+ if cands and not limb else set())
478
+ if len(groups) > 1:
479
+ return dict(out, kind="open", items=[i for i in pool if i.get("limbs")])
480
+ derived = [i for i in pool if i.get("derived")]
481
+ if derived:
482
+ prop = next((i for i in derived if i.get("role") == "primary"), derived[0])
483
+ return dict(out, kind="derived", proposed=prop,
484
+ others=[i for i in derived if i is not prop])
485
+ if reading.get("not_in_force") or out["higher"] or (
486
+ at is not None and out["now"] is not None and at != out["now"]
487
+ and not reading.get("resolved")):
488
+ # the maximum at commission is not today's provision's (Tran)
489
+ same = [i for i in pool if term_years(term_words(i["line"])) == out["now"]]
490
+ ref = next((i for i in same if i.get("role") == "primary"), (same or pool or [None])[0])
491
+ return dict(out, kind="history", proposed=ref)
492
+ match = ([i for i in pool if term_years(term_words(i["line"])) == at]
493
+ if at is not None else [])
494
+ if match:
495
+ prop = next((i for i in match if i.get("role") == "primary"), match[0])
496
+ return dict(out, kind="proposed", proposed=prop,
497
+ others=[i for i in match if i is not prop])
498
+ return dict(out, kind="none")
499
+
500
+
501
+ def _years(mx, lang):
502
+ if mx is None:
503
+ return None
504
+ t = TEXT[lang]
505
+ if mx >= LIFE:
506
+ return t["life"]
507
+ n = int(mx) if float(mx).is_integer() else mx
508
+ return t["years"].format(n=n)
509
+
510
+
511
+ def _basis(a, lang):
512
+ t = TEXT[lang]
513
+ b = a.get("basis") or ""
514
+ if b == "offence-date":
515
+ return t["basis_date"]
516
+ if b == "current":
517
+ return t["basis_current"]
518
+ if b.startswith("inferred-from-"):
519
+ what = b[len("inferred-from-"):]
520
+ return t["basis_bound"].format(what=_BOUND_WHAT[lang].get(what, what))
521
+ return t["basis_other"]
522
+
523
+
524
+ def _cite(i, lang):
525
+ return i["en"] if lang == "en" else fr_cite(i["fr"])
526
+
527
+
528
+ def _chunk(i, lang):
529
+ return i.get("chunk") if lang == "en" else i.get("chunk_fr")
530
+
531
+
532
+ def provision_text(a, lang):
533
+ """The provision bracket's contents for one allegation."""
534
+ t, c, p = TEXT[lang], a["choice"], a.get("pairing") or {}
535
+ pid, canadian = p.get("id", "?"), p.get("canadian_offence", "?")
536
+ k = c["kind"]
537
+ later = ""
538
+ if c.get("later"):
539
+ later = t["later"].format(alts=" / ".join(
540
+ t["later_alt"].format(cite=_cite(i, lang), frm=i["in_force_from"])
541
+ for i in c["later"]))
542
+ if k in ("proposed", "derived"):
543
+ i = c["proposed"]
544
+ s = t[k].format(cite=_cite(i, lang), pid=pid, chunk=_chunk(i, lang))
545
+ if c["others"]:
546
+ s += t["others"].format(alts=" / ".join(
547
+ t["alt"].format(when=o["when"], cite=_cite(o, lang)) for o in c["others"]))
548
+ return s + later
549
+ if k == "open":
550
+ return t["open"].format(alts=" | ".join(
551
+ t["open_alt"].format(cite=_cite(i, lang), when=i["when"]) for i in c["items"])) + later
552
+ if k == "history":
553
+ ref = c["proposed"]
554
+ cite = _cite(ref, lang) if ref else canadian
555
+ if c.get("higher"):
556
+ return t["history_higher"].format(label=c["higher"].get("limb_label") or canadian,
557
+ cite=cite)
558
+ if c.get("from"):
559
+ return t["history_nif"].format(frm=c["from"], at=_years(c["at"], lang) or "?",
560
+ cite=cite)
561
+ return t["history"].format(at=_years(c["at"], lang) or "?", basis=_basis(a, lang),
562
+ cite=cite)
563
+ if k == "inchoate":
564
+ ref = next((i for i in c["items"] if i.get("role") == "primary"),
565
+ (c["items"] or [None])[0])
566
+ return t["inchoate"].format(ref=t["inchoate_ref"].format(cite=_cite(ref, lang))
567
+ if ref else "")
568
+ if k == "none":
569
+ return t["none"].format(pid=pid, at=_years(c["at"], lang) or "?", canadian=canadian)
570
+ return t["held"].format(pid=pid, canadian=canadian)
571
+
572
+
573
+ def maximum_text(a, lang):
574
+ """The maximum bracket's contents for one allegation."""
575
+ t, c = TEXT[lang], a["choice"]
576
+ k, at = c["kind"], _years(c["at"], lang)
577
+ if k == "proposed":
578
+ i = c["proposed"]
579
+ words = term_words(i["line_fr"] if lang == "fr" else i["line"], lang)
580
+ return t["max_words"].format(words=words, chunk=_chunk(i, lang), basis=_basis(a, lang))
581
+ if k == "open":
582
+ return t["max_open"].format(alts=" | ".join(
583
+ t["max_open_alt"].format(words=term_words(i["line_fr"] if lang == "fr"
584
+ else i["line"], lang) or "?",
585
+ cite=_cite(i, lang)) for i in c["items"]))
586
+ if k == "derived":
587
+ return t["max_derived"]
588
+ if k == "history":
589
+ h = c.get("higher")
590
+ if h:
591
+ # methodology (b) applied the lesser maximum now; Tran read strictly
592
+ # gives the history row's: both, bracketed (principle 5)
593
+ return t["max_both"].format(then=_years(h.get("at_commission"), lang) or "?",
594
+ now=at or "?", label=h.get("limb_label") or "")
595
+ if c.get("from") and c["at"] == c["now"]:
596
+ return t["max_nif"].format(at=at or "?")
597
+ return t["max_history"].format(at=at or "?")
598
+ if k == "inchoate":
599
+ return t["max_inchoate"].format(at=at or "?")
600
+ if at is not None:
601
+ return t["max_reading"].format(at=at, basis=_basis(a, lang))
602
+ return t["max_unknown"]
603
+
604
+
605
+ def _date_text(a, lang, act=False):
606
+ t = TEXT[lang]
607
+ given = ""
608
+ if a.get("conviction_date") and not act:
609
+ given += t["given_conv"].format(d=a["conviction_date"])
610
+ if a.get("offence_date"):
611
+ given += t["given_off"].format(d=a["offence_date"])
612
+ return t["act_date" if act else "date"].format(given=given)
613
+
614
+
615
+ def _place_text(a, lang):
616
+ t = TEXT[lang]
617
+ return t["place"].format(given=t["given_place"].format(p=a["place"]) if a.get("place") else "")
618
+
619
+
620
+ def _allegation_line(a, lang):
621
+ t = TEXT[lang]
622
+ key = "36(2)(b)-two" if a["kind"] == "two" else a["paragraph"]
623
+ if lang == "fr":
624
+ text, para = " ".join(FR_ALLEGATION[key]), FR_PARAGRAPH[a["paragraph"]]
625
+ return t["allegation"].format(para=para, irpa=IRPA_FR, text=text)
626
+ return t["allegation"].format(para=a["paragraph"], irpa=IRPA_EN, text=a["en_text"])
627
+
628
+
629
+ def _narrative(a, lang):
630
+ """The narrative sentence(s) for one allegation, in ENF 5's form."""
631
+ t = TEXT[lang]
632
+ f = FR_FRAME if lang == "fr" else EN_FRAME
633
+ if a["kind"] == "two":
634
+ offs = []
635
+ for e in a.get("entries") or []:
636
+ offs.append(t["two_offence"].format(
637
+ offence=e["offence"],
638
+ entry=t["two_entry"].format(n=e["entry"]) if e.get("entry") else "",
639
+ date=_date_text(e, lang), place=_place_text(e, lang)))
640
+ while len(offs) < 2:
641
+ offs.append(t["two_other"])
642
+ provs = []
643
+ for e in a.get("entries") or []:
644
+ s = next((i for i in provisions(e.get("pairing"))
645
+ if i.get("role") == "two-plus-summary-only"), None)
646
+ provs.append(t["two_prov"].format(cite=_cite(s, lang),
647
+ pid=(e.get("pairing") or {}).get("id", "?"))
648
+ if s else t["two_prov_confirm"].format(what=e["offence"]))
649
+ while len(provs) < 2:
650
+ provs.append(t["two_prov_confirm"].format(what="the other offence" if lang == "en"
651
+ else "l’autre infraction"))
652
+ tpl = FR_TWO if lang == "fr" else EN_TWO
653
+ return [tpl.format(offences=t["and"].join(offs), provisions=t["and"].join(provs))]
654
+ prov, maxw = provision_text(a, lang), maximum_text(a, lang)
655
+ if a["kind"] == "act":
656
+ tpl = FR_ACT if lang == "fr" else EN_ACT
657
+ return [tpl.format(act=t["act"].format(offence=a["offence"]),
658
+ date=_date_text(a, lang, act=True), place=_place_text(a, lang),
659
+ prov=prov, maxw=maxw)]
660
+ return [f"{f['convicted']} [{t['offence'].format(offence=a['offence'])}] "
661
+ f"{f['on_or_about']} [{_date_text(a, lang)}] {f['at_or_near']} "
662
+ f"[{_place_text(a, lang)}]. {f['equivalence']} [{prov}] {f['maximum']} "
663
+ f"[{maxw}] {f['imposed']}"]
664
+
665
+
666
+ def _status_line(person, status_given, lang):
667
+ t = TEXT[lang]
668
+ f = FR_FRAME if lang == "fr" else EN_FRAME
669
+ if person == "permanent-resident":
670
+ return t["status_pr"]
671
+ if not status_given:
672
+ return f"{t['status_assumed']} {f['status_fn']}"
673
+ return f"{f['status_fn']} {t['status_evidence']}"
674
+
675
+
676
+ def render(allegations, person, status_given, language="en", notes=(), held=False):
677
+ """The skeleton block's lines. allegations: from the chain (see the
678
+ server's _narrative_allegations); notes: 'No skeleton' lines for what
679
+ grounds no allegation; held: no report_provision in this deployment."""
680
+ langs = {"en": ("en",), "fr": ("fr",), "both": ("en", "fr")}.get(language, ("en",))
681
+ if not allegations:
682
+ return [HEAD] + list(notes)
683
+ out = [HEAD, INTRO]
684
+ if held:
685
+ out.append(HELD_NOTE)
686
+ if len(allegations) > 1:
687
+ out.append(MULTI)
688
+ out += list(notes)
689
+ n = len(allegations)
690
+ for lang in langs:
691
+ t = TEXT[lang]
692
+ f = FR_FRAME if lang == "fr" else EN_FRAME
693
+ out += ["", t["block"], t["place_date"], t["addressee"], t["title"], t["name"]]
694
+ for k, a in enumerate(allegations, 1):
695
+ if n > 1 or a.get("condition") or a.get("entry"):
696
+ cond = ((t["entry"].format(n=a["entry"]) if a.get("entry") else "")
697
+ + (a.get("condition") or "")).strip().rstrip(",")
698
+ out.append(t["which"].format(k=k, n=n,
699
+ cond=t["cond"].format(cond=cond) if cond else ""))
700
+ out.append(_allegation_line(a, lang))
701
+ out.append(f["heading"])
702
+ out.append(f"{f['aka']} {t['aka']}")
703
+ out.append(_status_line(person, status_given, lang))
704
+ out += _narrative(a, lang)
705
+ out.append(t["signature"])
706
+ return out
707
+
708
+
709
+ def result_field(allegations, language, held=False):
710
+ """CANLEX_RESULT 'narrative': each allegation's paragraph, pairing and
711
+ the provision proposed (None where it stays [CONFIRM])."""
712
+ rows = []
713
+ for a in allegations:
714
+ c = a.get("choice") or {}
715
+ prop = c.get("proposed") if c.get("kind") in ("proposed", "derived") else None
716
+ rows.append({"paragraph": a["paragraph"], "kind": a["kind"],
717
+ "pairing": (a.get("pairing") or {}).get("id")
718
+ if a["kind"] != "two" else [(e.get("pairing") or {}).get("id")
719
+ for e in a.get("entries") or []],
720
+ "entry": a.get("entry"), "condition": a.get("condition"),
721
+ "provision": prop["en"] if prop else None,
722
+ "provision_status": c.get("kind") if a["kind"] != "two" else "two-offence",
723
+ # wave-4 verifier: today's provision came into force after
724
+ # the offence; the maximum at commission is higher than
725
+ # the one applied (both are bracketed)
726
+ "not_in_force_from": c.get("from") if c.get("kind") == "history" else None,
727
+ "maximum_readings": ({"at_commission": c["higher"].get("at_commission"),
728
+ "methodology_b": c.get("at")}
729
+ if c.get("kind") == "history" and c.get("higher")
730
+ else None)})
731
+ return {"language": language, "allegations": rows, "report_provision_held": bool(held)}
canlex/pdi.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest scoped IRCC Program Delivery Instructions (PDI) pages.
2
+
3
+ The PDIs are the current-policy successor to the legacy chapter manuals.
4
+ This module ingests the specific PDI sets that replace retired ENF chapters
5
+ the corpus needs: the PR-card/status section (successor to ENF 27) and the
6
+ Tran serious-criminality page (partial successor to ENF 2). Each named
7
+ index page is fetched, its same-section sub-pages discovered, and every
8
+ page chunked by heading -- doc_type='memorandum', like all guidance.
9
+
10
+ python -m canlex.pdi [--allow-shrink]
11
+ """
12
+ import re
13
+ import sys
14
+
15
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
16
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
17
+ from .config import PROCESSED_DIR, RAW_DIR
18
+
19
+ RAW = RAW_DIR / "pdi"
20
+ 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
+ # 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
+ {"code": "pdi-pr",
71
+ "short": "PDI Permanent Residence",
72
+ "name": "PDI — Permanent residence (loss of status and residency "
73
+ "obligation, non-economic classes, legal references)",
74
+ "index": _OBM + "permanent-residence.html",
75
+ "scope": "/permanent-residence",
76
+ # the PR-card subtree is the pdi-prcard set; the economic classes
77
+ # (Express Entry, PNP, business) are not border work
78
+ "exclude": ("/permanent-residence/card", "/economic-classes")},
79
+ # ENF 3 s. 10.9 (2022) sends officers to "IRCC's Program Delivery
80
+ # Instructions (PDI) on Criminal Rehabilitation". No such page is
81
+ # published (checked 2026-09-12): the OBM index and the
82
+ # standard-requirements / temporary-residents trees link none, canada.ca
83
+ # search finds only the applicant pages (Guide 5312, IMM 1444, 'Deemed
84
+ # rehabilitation'), and .../operational-bulletins-manuals/
85
+ # criminal-rehabilitation.html and .../standard-requirements/
86
+ # criminal-rehabilitation.html return 404. The one public PDI text on
87
+ # rehabilitation is this fees-tree page (R309(a) / A36(3)(c), dated
88
+ # 2013-06-17 -- it still names the National Parole Board and ENF 2/OP 18).
89
+ # A single page, so no crawl scope.
90
+ {"code": "pdi-rehab",
91
+ "short": "PDI Rehabilitation",
92
+ "name": "PDI — Determination of rehabilitation (R309(a) and A36(3)(c))",
93
+ "index": _OBM + ("fees/immigration/general/"
94
+ "determination-rehabilitation-r309-a36-3.html"),
95
+ "scope": ""},
96
+ ]
97
+
98
+ # canada.ca records a page's modification date in <meta name="dcterms.modified"
99
+ # content="YYYY-MM-DD">; the stored OBM pages carry no <time> element, so the
100
+ # old <time>-only pattern left every PDI chunk undated. <time> stays as the
101
+ # fallback for pages that do have one.
102
+ _MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
103
+ _META = re.compile(r"<meta\b[^>]*>", re.I)
104
+ _META_MODIFIED = re.compile(r"""name=["']dcterms\.modified["']""", re.I)
105
+ _META_CONTENT = re.compile(r"""content=["'](\d{4}-\d{2}-\d{2})""", re.I)
106
+
107
+
108
+ def page_modified(html, today=None):
109
+ """The page's dcterms.modified date (else its <time> date), ignoring any
110
+ date later than `today`: the OBM translation page carries 2027-01-10,
111
+ which is a CMS artefact, not a date the text was current to. '' when no
112
+ usable date is found. Pure, for tests."""
113
+ from datetime import date
114
+ today = today or date.today().isoformat()
115
+ candidates = []
116
+ for tag in _META.findall(html):
117
+ if _META_MODIFIED.search(tag):
118
+ m = _META_CONTENT.search(tag)
119
+ if m:
120
+ candidates.append(m.group(1))
121
+ candidates.extend(_MODIFIED.findall(html))
122
+ for found in candidates:
123
+ if found <= today:
124
+ return found
125
+ return ""
126
+ _TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
127
+ _LINK = re.compile(r'href="([^"#?]*?\.html)"')
128
+
129
+
130
+ def _strip_tags(html):
131
+ html = re.sub(r"</t[dh]>", " | ", html)
132
+ html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
133
+ html = re.sub(r"<[^>]+>", " ", html)
134
+ return [l for l in (_norm(x).strip("| ").strip()
135
+ for x in html.split("\n")) if l]
136
+
137
+
138
+ def _main(html):
139
+ m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
140
+ return m.group(1) if m else html
141
+
142
+
143
+ def _page_chunks(src, page_url, html, page_no):
144
+ content = _main(html)
145
+ title_m = _TITLE.search(content)
146
+ title = (_norm(re.sub(r"<[^>]+>", " ", title_m.group(1)))
147
+ if title_m else src["short"])
148
+ modified = page_modified(html)
149
+ # Split on h2 headings; each becomes a section-like unit.
150
+ parts = re.split(r"<h2[^>]*>", content[title_m.end():] if title_m else content)
151
+ chunks = []
152
+ for i, part in enumerate(parts):
153
+ if i == 0:
154
+ heading, body_html = title, part
155
+ else:
156
+ head_end = part.find("</h2>")
157
+ heading = _norm(re.sub(r"<[^>]+>", " ", part[:head_end]))
158
+ body_html = part[head_end:]
159
+ body = "\n".join(_strip_tags(body_html))
160
+ if len(body) < 120:
161
+ continue
162
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
163
+ total = len(pieces)
164
+ for k, piece in enumerate(pieces, start=1):
165
+ suffix = "" if total == 1 else f"-p{k}"
166
+ note = "" if total == 1 else f" (part {k} of {total})"
167
+ chunks.append({
168
+ "id": f"{src['code']}-{page_no}-{i}{suffix}",
169
+ "doc_type": "memorandum",
170
+ "act_code": src["code"].upper(),
171
+ "act_short": src["short"],
172
+ "act_name": src["name"],
173
+ "section": f"{title[:60]} — {heading[:60]}" if i else title[:80],
174
+ "marginal_note": heading,
175
+ "part": src["name"],
176
+ "division": "",
177
+ "heading": "",
178
+ "text": piece,
179
+ "history": "",
180
+ "last_amended": modified,
181
+ "current_to": modified,
182
+ "citation": f"{src['short']}: {heading}{note}",
183
+ "source_url": page_url,
184
+ })
185
+ return chunks
186
+
187
+
188
+ def preserve_failed(chunks, failures, stored):
189
+ """Re-attach the last-good chunks of sub-pages this run could not fetch.
190
+
191
+ Ingestion is a full rebuild, so a sub-page that 404s or drops its
192
+ connection silently vanishes from the corpus; serving the copy we already
193
+ had beats losing instructions that are still in force. Keyed on source_url,
194
+ the only per-page identity a PDI chunk carries that survives pages coming
195
+ and going -- the chunk id embeds the page's position in the index listing,
196
+ which shifts whenever one page drops out. Restricted to pages that actually
197
+ errored, so a sub-page IRCC retired on purpose is not resurrected.
198
+ Returns (chunks, preserved page URLs).
199
+ """
200
+ return preserve_dropped(chunks, stored, key=lambda c: c["source_url"],
201
+ only={url for url, _why in failures})
202
+
203
+
204
+ def cache_name(src, url):
205
+ """The raw-cache file name for one crawled page: the set's code plus the
206
+ page's path below the OBM root, so sibling pages that share a file name
207
+ -- the five .../non-economic-classes/<pathway>/admissibility.html pages
208
+ of the PR tree -- never share one cache file. Until 2026-09-21 the name
209
+ was the URL's last path component, and a plain (no --force) run served
210
+ one pathway's cached page under all five URLs. The index page keeps
211
+ '{code}-index.html': refresh.check_pdi reads it by that name. Pure."""
212
+ if url == src["index"]:
213
+ return f"{src['code']}-index.html"
214
+ rel = url.split(_OBM, 1)[1] if _OBM in url else re.sub(r"^https?://", "", url)
215
+ rel = re.sub(r"\.html$", "", rel)
216
+ return f"{src['code']}-{re.sub(r'[^A-Za-z0-9._-]+', '-', rel)}.html"
217
+
218
+
219
+ def _crawl(src, failures, force=False):
220
+ """Breadth-first crawl of one PDI category: index -> in-scope links ->
221
+ their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
222
+ Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
223
+ failures land in `failures` so preserve_failed keeps their stored
224
+ copies."""
225
+ index_html = fetch_cached(
226
+ src["index"], RAW / cache_name(src, src["index"]),
227
+ powershell=True, pause=0.8, force=force).decode("utf-8", "replace")
228
+ pages = [(src["index"], index_html)]
229
+ if not src["scope"]:
230
+ return pages
231
+ seen = {src["index"]}
232
+ frontier = [(src["index"], index_html)]
233
+ dropped = 0
234
+ for _depth in range(_MAX_DEPTH):
235
+ next_frontier = []
236
+ for _url, html in frontier:
237
+ for href in _LINK.findall(_main(html)):
238
+ if src["scope"] not in href:
239
+ continue
240
+ if any(x in href for x in src.get("exclude", ())):
241
+ continue
242
+ url = href if href.startswith("http") else \
243
+ "https://www.canada.ca" + href
244
+ if url in seen:
245
+ continue
246
+ seen.add(url)
247
+ if len(pages) >= _MAX_PAGES:
248
+ dropped += 1
249
+ continue
250
+ name = url.rsplit("/", 1)[1].replace(".html", "")
251
+ try:
252
+ page = fetch_cached(
253
+ url, RAW / cache_name(src, url),
254
+ powershell=True, pause=0.8,
255
+ force=force).decode("utf-8", "replace")
256
+ except Exception as exc:
257
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
258
+ print(f" !! {name}: {type(exc).__name__}: {exc}")
259
+ continue
260
+ pages.append((url, page))
261
+ next_frontier.append((url, page))
262
+ frontier = next_frontier
263
+ if dropped:
264
+ print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
265
+ f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
266
+ return pages
267
+
268
+
269
+ def build(allow_shrink=False, force=False):
270
+ """Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
271
+ all_chunks, failures = [], []
272
+ for src in SETS:
273
+ try:
274
+ pages = _crawl(src, failures, force=force)
275
+ except Exception as exc:
276
+ failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
277
+ print(f" !! {src['short']}: index fetch failed "
278
+ f"({type(exc).__name__}: {exc})")
279
+ continue
280
+ n = 0
281
+ for page_no, (url, html) in enumerate(pages):
282
+ chunks = _page_chunks(src, url, html, page_no)
283
+ if not chunks:
284
+ # Downloaded fine, parsed to nothing: the page template moved
285
+ # under us. That is exactly how the D-memo corpus was lost, so
286
+ # it counts as a failure and the stored page is kept.
287
+ print(f" !! {url.rsplit('/', 1)[-1]}: no content parsed; "
288
+ f"keeping stored copy")
289
+ failures.append((url, "no content parsed"))
290
+ continue
291
+ all_chunks.extend(chunks)
292
+ n += len(chunks)
293
+ print(f" {src['short']}: {len(pages)} pages, {n} chunks")
294
+
295
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
296
+ stored_chunks(OUT_FILE))
297
+ if preserved:
298
+ names = ", ".join(u.rsplit("/", 1)[-1] for u in preserved)
299
+ print(f" kept the last-good copy of {len(preserved)} unfetchable "
300
+ f"page(s): {names}")
301
+ # After preservation, not before: a preserved page brings back the ids it
302
+ # was written with, and this run may have handed those ids to other pages
303
+ # (an id embeds the page's position in the index listing).
304
+ uniquify_ids(all_chunks)
305
+ if not write_corpus(OUT_FILE, all_chunks, indent=2,
306
+ allow_shrink=allow_shrink):
307
+ return False
308
+ print(f"pdi: {len(all_chunks)} chunks -> {OUT_FILE.name}")
309
+ return all_chunks
310
+
311
+
312
+ def main():
313
+ # `is not False` rather than truthiness: a first-ever run that writes an
314
+ # empty corpus succeeded, a refused write did not.
315
+ ok = build(allow_shrink="--allow-shrink" in sys.argv,
316
+ force="--force" in sys.argv) is not False
317
+ sys.exit(0 if ok else 1)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
canlex/ratelimit.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-client limits for the HTTP transport (audit follow-up 2026-09-21,
2
+ missing 14; verification fixes 2026-09-22, SEC-1 and SEC-2).
3
+
4
+ The public Space is unauthenticated. The request-body cap, the session idle
5
+ timeout and the off-loop tools bound what one request can cost, but a steady
6
+ stream of 500-character searches (2.5-7 s each, one at a time) still holds the
7
+ single search slot, and a stream of `initialize` requests opens sessions that
8
+ each live until the idle timeout. Two small ASGI middlewares, wrapped around
9
+ _Utf8Body by server.http_app, give each client a token bucket:
10
+
11
+ - SessionOpenLimit (ON by default over HTTP): a POST with no mcp-session-id
12
+ header is the only request that can open an MCP session; each client may
13
+ send SESSION_OPEN_LIMIT (60) of them per SESSION_OPEN_WINDOW (10 minutes),
14
+ in bursts of up to 60, and then one each 10 s. CANLEX_SESSION_OPEN_LIMIT
15
+ sets the number (0 turns the cap off). An officer's client opens one
16
+ session per connection, so it never meets the cap; one client can no
17
+ longer fill the session table (SEC-1: mcp 1.30.0's global cap of 10,000
18
+ let a single client lock every other one out with 503 for 30 minutes;
19
+ server.py now builds the manager with no global cap).
20
+ - RateLimit (OFF unless CANLEX_RATE_LIMIT_PER_MIN is set to a positive
21
+ integer): `per_minute` requests a minute on average and bursts of up to
22
+ `burst` (CANLEX_RATE_LIMIT_BURST; default a full minute's allowance, so an
23
+ officer's own client -- a few requests per tool call -- never meets it).
24
+
25
+ A request over either limit gets 429 with a plain-text body and Retry-After;
26
+ it never reaches the MCP app. GET / (the notice page and health probes) and
27
+ anything that is not an HTTP request (lifespan) pass through uncounted. The
28
+ local stdio server uses neither.
29
+
30
+ The client (client_key) is the address the last proxy saw: the rightmost
31
+ X-Forwarded-For entry when the header is present -- repeated header lines
32
+ read as one comma-separated list, in order -- else the socket peer, cut to
33
+ MAX_KEY_CHARS characters. This ASSUMES AN APPENDING PROXY in front: Hugging
34
+ Face's ingress appends the address it saw (and merges any lines the client
35
+ sent), so the rightmost entry is not the client's to choose. Served with no
36
+ such proxy (plain Docker, or a proxy that passes the header through
37
+ untouched), the header is whatever the client writes: it can dodge its own
38
+ limit by rotating it, or spend another address's bucket by naming it. At
39
+ most MAX_CLIENTS buckets are kept per limit; idle ones are dropped first.
40
+ """
41
+ import math
42
+ import os
43
+ import sys
44
+ import threading
45
+ import time
46
+
47
+ ENV_PER_MIN = "CANLEX_RATE_LIMIT_PER_MIN"
48
+ ENV_BURST = "CANLEX_RATE_LIMIT_BURST"
49
+ ENV_SESSIONS = "CANLEX_SESSION_OPEN_LIMIT"
50
+ MAX_CLIENTS = 10000
51
+ MAX_KEY_CHARS = 64 # an IPv6 address with a zone is under 64 characters
52
+ SESSION_OPEN_LIMIT = 60 # new sessions per client per SESSION_OPEN_WINDOW
53
+ SESSION_OPEN_WINDOW = 600.0 # seconds
54
+
55
+
56
+ class TokenBuckets:
57
+ """One token bucket per client key: `rate` tokens a second, at most
58
+ `burst` held. take(key) spends one token and returns 0.0, or returns the
59
+ seconds until one is available (nothing spent)."""
60
+
61
+ def __init__(self, per_minute, burst=None, clock=time.monotonic,
62
+ max_clients=MAX_CLIENTS):
63
+ self.rate = per_minute / 60.0
64
+ self.burst = float(burst if burst else per_minute)
65
+ self.clock = clock
66
+ self.max_clients = max_clients
67
+ self._buckets = {} # key -> (tokens, last refill time)
68
+ self._lock = threading.Lock()
69
+
70
+ def __len__(self):
71
+ return len(self._buckets)
72
+
73
+ def take(self, key):
74
+ with self._lock:
75
+ now = self.clock()
76
+ tokens, last = self._buckets.get(key, (self.burst, now))
77
+ tokens = min(self.burst, tokens + (now - last) * self.rate)
78
+ if tokens >= 1.0:
79
+ self._buckets[key] = (tokens - 1.0, now)
80
+ self._prune(now)
81
+ return 0.0
82
+ self._buckets[key] = (tokens, now)
83
+ return (1.0 - tokens) / self.rate
84
+
85
+ def _prune(self, now):
86
+ """Keep at most max_clients buckets: drop the full ones (idle long
87
+ enough to refill), then the least recently seen."""
88
+ if len(self._buckets) <= self.max_clients:
89
+ return
90
+ full = [k for k, (t, last) in self._buckets.items()
91
+ if t + (now - last) * self.rate >= self.burst]
92
+ for k in full:
93
+ del self._buckets[k]
94
+ if len(self._buckets) > self.max_clients:
95
+ by_age = sorted(self._buckets, key=lambda k: self._buckets[k][1])
96
+ for k in by_age[:len(self._buckets) - self.max_clients]:
97
+ del self._buckets[k]
98
+
99
+
100
+ def client_key(scope):
101
+ """The address the last proxy saw: the rightmost X-Forwarded-For entry
102
+ (every header line joined in order, so a proxy that adds its own line is
103
+ read, not the client's first one), else the socket peer's host, else
104
+ '?'; at most MAX_KEY_CHARS characters. Trusts the header from any peer:
105
+ it assumes an appending proxy in front (see the module docstring)."""
106
+ lines = [value.decode("latin-1") for name, value in scope.get("headers") or ()
107
+ if name.lower() == b"x-forwarded-for"]
108
+ if lines:
109
+ last = ",".join(lines).split(",")[-1].strip()
110
+ if last:
111
+ return last[:MAX_KEY_CHARS]
112
+ client = scope.get("client")
113
+ return str(client[0])[:MAX_KEY_CHARS] if client else "?"
114
+
115
+
116
+ def _exempt(scope):
117
+ return scope.get("type") != "http" or (scope.get("method") == "GET"
118
+ and scope.get("path") == "/")
119
+
120
+
121
+ def _opens_session(scope):
122
+ """A POST with no mcp-session-id header: the only request the session
123
+ manager answers by opening a session (a request naming an unknown id gets
124
+ 404 and opens nothing)."""
125
+ return (scope.get("type") == "http" and scope.get("method") == "POST"
126
+ and not any(name.lower() == b"mcp-session-id"
127
+ for name, _ in scope.get("headers") or ()))
128
+
129
+
130
+ async def _too_many(send, wait, text):
131
+ """429 with `text` and the whole seconds to wait, as a plain-text body
132
+ and Retry-After."""
133
+ retry = max(1, math.ceil(wait))
134
+ body = f"{text} Retry after {retry} s.\n".encode("utf-8")
135
+ await send({"type": "http.response.start", "status": 429,
136
+ "headers": [(b"content-type", b"text/plain; charset=utf-8"),
137
+ (b"content-length", str(len(body)).encode("ascii")),
138
+ (b"retry-after", str(retry).encode("ascii"))]})
139
+ await send({"type": "http.response.body", "body": body})
140
+
141
+
142
+ class RateLimit:
143
+ """ASGI middleware: each client's requests through a TokenBuckets."""
144
+
145
+ def __init__(self, app, per_minute, burst=None, clock=time.monotonic,
146
+ max_clients=MAX_CLIENTS):
147
+ self.app = app
148
+ self.per_minute = per_minute
149
+ self.buckets = TokenBuckets(per_minute, burst, clock, max_clients)
150
+
151
+ async def __call__(self, scope, receive, send):
152
+ if _exempt(scope):
153
+ await self.app(scope, receive, send)
154
+ return
155
+ wait = self.buckets.take(client_key(scope))
156
+ if not wait:
157
+ await self.app(scope, receive, send)
158
+ return
159
+ await _too_many(send, wait, f"Too many requests: this CanLex endpoint allows "
160
+ f"{self.per_minute} requests a minute per client.")
161
+
162
+
163
+ class SessionOpenLimit:
164
+ """ASGI middleware: each client may send `limit` session-opening
165
+ requests (_opens_session) per `window` seconds, in bursts of up to
166
+ `limit`; every other request passes through uncounted."""
167
+
168
+ def __init__(self, app, limit=SESSION_OPEN_LIMIT, window=SESSION_OPEN_WINDOW,
169
+ clock=time.monotonic, max_clients=MAX_CLIENTS):
170
+ self.app = app
171
+ self.limit, self.window = limit, window
172
+ self.buckets = TokenBuckets(limit * 60.0 / window, limit, clock, max_clients)
173
+
174
+ async def __call__(self, scope, receive, send):
175
+ if not _opens_session(scope):
176
+ await self.app(scope, receive, send)
177
+ return
178
+ wait = self.buckets.take(client_key(scope))
179
+ if not wait:
180
+ await self.app(scope, receive, send)
181
+ return
182
+ await _too_many(send, wait, f"Too many new sessions: this CanLex endpoint lets one "
183
+ f"client open {self.limit} MCP sessions every "
184
+ f"{self.window / 60:g} minutes.")
185
+
186
+
187
+ def _positive_int(environ, name):
188
+ raw = (environ.get(name) or "").strip()
189
+ if not raw:
190
+ return None
191
+ try:
192
+ value = int(raw)
193
+ except ValueError:
194
+ value = 0
195
+ if value <= 0:
196
+ print(f"CanLex MCP: {name}={raw!r} is not a positive integer; ignored",
197
+ file=sys.stderr)
198
+ return None
199
+ return value
200
+
201
+
202
+ def from_env(app, environ=None):
203
+ """`app` wrapped in RateLimit when CANLEX_RATE_LIMIT_PER_MIN is a positive
204
+ integer (one stderr line says so), else `app` itself."""
205
+ environ = os.environ if environ is None else environ
206
+ per_minute = _positive_int(environ, ENV_PER_MIN)
207
+ if per_minute is None:
208
+ return app
209
+ burst = _positive_int(environ, ENV_BURST) or per_minute
210
+ print(f"CanLex MCP: rate limit {per_minute} requests a minute per client "
211
+ f"(bursts of {burst}); GET / exempt.", file=sys.stderr)
212
+ return RateLimit(app, per_minute, burst)
213
+
214
+
215
+ def sessions_from_env(app, environ=None):
216
+ """`app` wrapped in SessionOpenLimit -- on by default: SESSION_OPEN_LIMIT
217
+ new sessions per client per SESSION_OPEN_WINDOW; CANLEX_SESSION_OPEN_LIMIT
218
+ sets the number, and 0 turns the cap off (`app` itself). A value that is
219
+ not a whole number keeps the default. One stderr line says which."""
220
+ environ = os.environ if environ is None else environ
221
+ raw = (environ.get(ENV_SESSIONS) or "").strip()
222
+ limit = SESSION_OPEN_LIMIT
223
+ if raw:
224
+ try:
225
+ limit = int(raw)
226
+ except ValueError:
227
+ limit = -1
228
+ if limit == 0:
229
+ print(f"CanLex MCP: no per-client cap on new sessions ({ENV_SESSIONS}=0).",
230
+ file=sys.stderr)
231
+ return app
232
+ if limit < 0:
233
+ print(f"CanLex MCP: {ENV_SESSIONS}={raw!r} is not a whole number; the default "
234
+ f"{SESSION_OPEN_LIMIT} applies", file=sys.stderr)
235
+ limit = SESSION_OPEN_LIMIT
236
+ print(f"CanLex MCP: at most {limit} new sessions per client every "
237
+ f"{SESSION_OPEN_WINDOW / 60:g} minutes.", file=sys.stderr)
238
+ return SessionOpenLimit(app, limit)
canlex/readnext.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """'Read next': the official guidance and statute a curated entry names (roadmap T3.6).
2
+
3
+ A curated disposition entry, methodology item, equivalency pairing or transborder
4
+ row may carry `see_also` (HELD FOR REVIEW when added; optional -- the approved
5
+ baseline predates it): an ordered list of items, each a STABLE key --
6
+ '<act_code>|s. <section>' for a statute section, '<act_code>|<ENF section>' for a
7
+ numbered ENF section, '<act_code>|<marginal note>' for an IRB paper, PDI page or
8
+ IRCC guide section, '<act_code>|ENF 1 App. A|<case name>' for an ENF 1 App. A
9
+ note -- with the chunk it quotes ('chunk') and, optionally, a verbatim passage
10
+ of that chunk ('quote', at most 150 characters; tests/test_read_next.py pins
11
+ each one). This is the shape canlex/index.py's loader reads for co-surfacing
12
+ (curated_see_also, _see_also_spec, _see_also_match); the rule that resolves a
13
+ key is that module's, imported here, so the tools and the search index read a
14
+ key alike.
15
+
16
+ The tools print, for the entry the record matched, one line per item: the
17
+ corpus's own label for the chunk (act, section, marginal note), its chunk id,
18
+ the source's status (an unofficial ATIP copy of a withdrawn chapter is flagged;
19
+ guidance shows its date) and the quote. No prose of CanLex's own is printed:
20
+ the 'why' is the source's own words. Official sources come first; an ATIP copy
21
+ follows them. A key the loaded corpus cannot resolve is printed as such, never
22
+ dropped silently.
23
+ """
24
+ import functools
25
+ import json
26
+ import re
27
+
28
+ from .config import PROCESSED_DIR
29
+ from .index import ATIP_STATUS, _see_also_match, _see_also_spec
30
+
31
+ HEADING = ("READ NEXT -- the official guidance and statute on this point, from the corpus "
32
+ "(chunk id in brackets):")
33
+ COMPACT_PREFIX = "Read next"
34
+ ATIP_FLAG = "UNOFFICIAL ATIP COPY of a withdrawn chapter -- historical guidance"
35
+ ATIP_SHORT = "unofficial ATIP copy"
36
+ UNRESOLVED = "not in this deployment's corpus"
37
+
38
+
39
+ def items(obj):
40
+ """The see_also items of a curated object ([] when it has none)."""
41
+ raw = obj.get("see_also") if isinstance(obj, dict) else None
42
+ return [i for i in raw or [] if isinstance(i, (dict, str))]
43
+
44
+
45
+ def _file_for_act(act_code):
46
+ """The data/processed file holding an act_code's chunks."""
47
+ code = act_code or ""
48
+ if code.startswith("ENF-"):
49
+ return "enf.json"
50
+ if code.startswith("IRB-LS-"):
51
+ return "irb_papers.json"
52
+ if code.startswith("IRB-"):
53
+ return "irb_guidelines.json"
54
+ if code.startswith("PDI-"):
55
+ return "pdi.json"
56
+ if code.startswith(("IMM-", "IRCC-")):
57
+ return "policy.json"
58
+ return f"{code}.json"
59
+
60
+
61
+ @functools.lru_cache(maxsize=48)
62
+ def _file_chunks(name):
63
+ """{act_code: [English chunk, ...]} of one processed file ({} when absent)."""
64
+ try:
65
+ data = json.loads((PROCESSED_DIR / name).read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ return {}
68
+ if isinstance(data, dict):
69
+ data = data.get("chunks") or []
70
+ out = {}
71
+ for c in data:
72
+ if isinstance(c, dict) and c.get("lang", "en") != "fr":
73
+ out.setdefault(c.get("act_code"), []).append(c)
74
+ return out
75
+
76
+
77
+ _INDEX_MAP = [] # [(the loaded index's chunk list, {act_code: [chunk, ...]})]
78
+
79
+
80
+ def _pool(act_code, index_chunks=None):
81
+ """The English chunks of one act_code: from the loaded index when there is
82
+ one (it holds the whole corpus), else from data/processed."""
83
+ if index_chunks is not None:
84
+ # read once into a local and publish whole (audit follow-up 2026-09-21):
85
+ # a second screening thread never reads a map another thread replaced
86
+ entry = _INDEX_MAP[0] if _INDEX_MAP else None
87
+ if entry is None or entry[0] is not index_chunks:
88
+ by_act = {}
89
+ for c in index_chunks:
90
+ if c.get("lang", "en") != "fr":
91
+ by_act.setdefault(c.get("act_code"), []).append(c)
92
+ entry = (index_chunks, by_act)
93
+ _INDEX_MAP[:] = [entry]
94
+ got = entry[1].get(act_code)
95
+ if got:
96
+ return got
97
+ return _file_chunks(_file_for_act(act_code)).get(act_code, [])
98
+
99
+
100
+ def resolve(item, index_chunks=None):
101
+ """{key, id, chunk, quote}: the chunk an item names -- its pinned chunk when
102
+ that still fits the key, else the key's first English piece (the rule
103
+ canlex/index.py co-surfaces by); chunk None when nothing resolves. The
104
+ quote is kept only with the chunk it was taken from."""
105
+ spec, pin = _see_also_spec(item, {})
106
+ key = item.get("key") if isinstance(item, dict) else item
107
+ quote = item.get("quote") if isinstance(item, dict) else None
108
+ chunk = None
109
+ if spec:
110
+ pool = _pool(spec.get("act_code"), index_chunks)
111
+ if pin:
112
+ chunk = next((c for c in pool if c.get("id") == pin), None)
113
+ if chunk is not None and not _see_also_match(chunk, spec):
114
+ chunk = None
115
+ if chunk is None:
116
+ chunk = next((c for c in pool if _see_also_match(c, spec)), None)
117
+ return {"key": key, "id": chunk.get("id") if chunk else None, "chunk": chunk,
118
+ "quote": quote if chunk is not None and chunk.get("id") == pin else None}
119
+
120
+
121
+ def is_atip(chunk):
122
+ return bool(chunk and chunk.get("source_status") == ATIP_STATUS)
123
+
124
+
125
+ def is_guidance(chunk):
126
+ return bool(chunk and chunk.get("doc_type") == "memorandum")
127
+
128
+
129
+ def resolved(item_list, index_chunks=None):
130
+ """The items resolved, one per chunk (the first naming it), official
131
+ sources first and unofficial ATIP copies after them, each group in curated
132
+ order; an unresolved item keeps its place in the official group."""
133
+ out, seen = [], set()
134
+ for it in item_list or ():
135
+ r = resolve(it, index_chunks)
136
+ if r["id"] is not None:
137
+ if r["id"] in seen:
138
+ continue
139
+ seen.add(r["id"])
140
+ out.append(r)
141
+ return [r for r in out if not is_atip(r["chunk"])] + [r for r in out if is_atip(r["chunk"])]
142
+
143
+
144
+ def label(chunk):
145
+ """The corpus's own name for a chunk: 'Criminal Code s. 320.14 (Operation
146
+ while impaired)', 'ENF 3 s. 10.9 (Foreign pardons)', 'IRB Legal Services,
147
+ ROA ch. 8 § 18 (Effect of a Pardon)', 'PDI Standard Requirements (Where the
148
+ ruling applies)'."""
149
+ short = chunk.get("act_short") or ""
150
+ sec = chunk.get("section") or ""
151
+ note = chunk.get("marginal_note") or ""
152
+ if re.match(r"\d", sec):
153
+ base = f"{short} s. {sec}"
154
+ elif short and sec.startswith(short):
155
+ base = sec
156
+ elif short.startswith("IRB Legal Services") and sec:
157
+ base = f"IRB Legal Services, {sec}"
158
+ else:
159
+ base = short or sec
160
+ if note and note.lower() not in base.lower():
161
+ base += f" ({note})"
162
+ return base
163
+
164
+
165
+ def flags(chunk):
166
+ """The source's status: the ATIP flag, an archived paper, a guidance date."""
167
+ out = []
168
+ if is_atip(chunk):
169
+ out.append(ATIP_FLAG)
170
+ if "archived" in (chunk.get("act_name") or "").lower():
171
+ out.append("archived")
172
+ if is_guidance(chunk) and chunk.get("current_to"):
173
+ out.append(f"as of {chunk['current_to']}")
174
+ return out
175
+
176
+
177
+ def lines(item_list, index_chunks=None, heading=HEADING):
178
+ """The READ NEXT block: the heading and one line per item ([] when the
179
+ entry names none)."""
180
+ res = resolved(item_list, index_chunks)
181
+ if not res:
182
+ return []
183
+ out = [heading]
184
+ for r in res:
185
+ if r["chunk"] is None:
186
+ out.append(f"- {r['key']}: {UNRESOLVED}")
187
+ continue
188
+ fl = flags(r["chunk"])
189
+ text = f"- {label(r['chunk'])} [{r['id']}]" + (f" -- {'; '.join(fl)}" if fl else "")
190
+ if r["quote"]:
191
+ text += f': "{r["quote"]}"'
192
+ out.append(text)
193
+ return out
194
+
195
+
196
+ def compact_line(item_list, index_chunks=None, prefix=COMPACT_PREFIX):
197
+ """One line for a memo: 'Read next: <label> [<id>]; ...' (ATIP copies
198
+ marked), or None when the entry names nothing."""
199
+ res = resolved(item_list, index_chunks)
200
+ if not res:
201
+ return None
202
+ parts = []
203
+ for r in res:
204
+ if r["chunk"] is None:
205
+ parts.append(f"{r['key']} ({UNRESOLVED})")
206
+ else:
207
+ parts.append(f"{label(r['chunk'])} [{r['id']}]"
208
+ + (f" ({ATIP_SHORT})" if is_atip(r["chunk"]) else ""))
209
+ return f"{prefix}: " + "; ".join(parts)
210
+
211
+
212
+ def chunk_ids(item_list, index_chunks=None):
213
+ """The resolved chunk ids, in printed order (CANLEX_RESULT 'read_next')."""
214
+ return [r["id"] for r in resolved(item_list, index_chunks) if r["id"]]
215
+
216
+
217
+ def with_fallback(primary, fallback, index_chunks=None):
218
+ """A pairing's items, followed by the equivalency framework's when none of
219
+ the pairing's own resolves to guidance (the draft's rule: most pairings
220
+ name only their Canadian statute sections)."""
221
+ primary = list(primary or [])
222
+ if not primary or any(is_guidance(r["chunk"]) for r in resolved(primary, index_chunks)):
223
+ return primary
224
+ return primary + list(fallback or [])
canlex/refresh.py ADDED
@@ -0,0 +1,1653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detect when CanLex's ingested sources have gone stale upstream.
2
+
3
+ py -m canlex.refresh --check
4
+
5
+ For every Justice Laws instrument in config.SOURCES, this fetches just the
6
+ opening of the XML (the root <Statute>/<Regulation> element carries the
7
+ consolidation date in its lims:current-date attribute, so a few KB is enough)
8
+ and compares that upstream date against the current_to stored in the matching
9
+ data/processed/<code>.json. Anything where upstream is newer is reported as
10
+ drift, and the process exits non-zero -- so this can gate a deploy or run on a
11
+ schedule.
12
+
13
+ It also checks the two delegation instruments against upstream (check_delegation):
14
+ the CBSA IRPA delegation -- by diffing the dated 'irpa-lipr-YYYY-MM-DD' instruments
15
+ the CBSA legislation index publishes against those delegation.SOURCES ingests, so
16
+ a newly-published amendment is flagged -- and the IRCC IL3 instrument, by comparing
17
+ the version stamp in the live PDF against the stored one. The NJC directives get
18
+ the same treatment (check_directives): the effective date each chunk stores in
19
+ current_to comes from the NJC directive index (the span.date beside each entry),
20
+ so one fresh index fetch re-reads it for nearly every directive, with the
21
+ 'effective <Month D, YYYY>' prose on the directive's own page as fallback for
22
+ entries the index leaves undated (the Foreign Service Directives) -- exactly the
23
+ two signals ingestion uses. Finally it prints the stored currency of the
24
+ remaining non-XML sources (D-memos, case law, agreements, benefits, tariff
25
+ schedule), which expose no comparable upstream signal, with the command to
26
+ refresh each; and flags any tracked pending bill whose coming-into-force date
27
+ has passed, saying whether the stored consolidation of each affected Act
28
+ already postdates it (pending_consolidation).
29
+
30
+ Two further watches, both added 2026-09-13:
31
+
32
+ - Curated verify phrases (check_curated_sections, roadmap T4.3). Every statute
33
+ phrase the curated tables pin is checked against the corpus now. BROKEN
34
+ means the phrase or section is gone. AMENDED means the section's
35
+ last_amended is later than in the corpus of the approved commit; when the
36
+ maximum a quoted subsection states changed and no penalty_history row
37
+ covers it, a DRAFT row goes to data/curated/drafts/. The curated JSON
38
+ itself is never edited here (see canlex.curated_checks).
39
+ - Guidance currency (check_guidance, roadmap T4.8):
40
+ - the official ENF chapters, by an HTTP HEAD of each PDF against the
41
+ last-seen marker (ENF 2 and 14, withdrawn, are skipped);
42
+ - each PDI category index, diffed against the index cached at ingest,
43
+ plus the Tran page;
44
+ - the IRB legal-concepts index pages, for their ARCHIVED marker.
45
+ IMM 1444 and Guide 5312 are read from the employer-policy check above,
46
+ which already stamps their page dateModified.
47
+
48
+ This is the staleness detection the corpus previously lacked: legislation was
49
+ refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
50
+
51
+ py -m canlex.refresh --check # human-readable; exit 1 on drift
52
+ py -m canlex.refresh --check --json # machine-readable for CI
53
+ """
54
+ import datetime
55
+ import json
56
+ import os
57
+ import re
58
+ import sys
59
+ import tempfile
60
+ import time
61
+ import urllib.error
62
+ import urllib.request
63
+ from pathlib import Path
64
+
65
+ from bs4 import BeautifulSoup
66
+
67
+ from . import delegation, directive, dmemo
68
+ from ._common import BROWSER_UA, MONTHS, atomic_write_text
69
+ from .config import SOURCES, PROCESSED_DIR
70
+
71
+ _UA = "CanLex-refresh/0.1"
72
+ _HEAD_BYTES = 16384 # the root element's attributes sit at the very top
73
+ _CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
74
+
75
+ # Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
76
+ # command, description). These carry no single upstream consolidation date, so
77
+ # the check reports their STORED currency (newest item) and how to refresh them
78
+ # rather than diffing against upstream. (Delegation and the NJC directives are
79
+ # checked against upstream separately, by check_delegation and check_directives,
80
+ # because their sources DO expose a comparable signal -- the CBSA instrument
81
+ # index and IL3 version string, and the NJC directive index dates.)
82
+ _NON_XML = [
83
+ ("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
84
+ ("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
85
+ ("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
86
+ ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
87
+ ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
88
+ "Customs Tariff Schedule ch. 98-99"),
89
+ ("enf", "enf.json", "py -m canlex.enf", "ENF operational manuals"),
90
+ ("amps", "amps.json", "py -m canlex.amps", "AMPS Master Penalty Document"),
91
+ ("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
92
+ ("commentary", "commentary.json", "py -m canlex.commentary",
93
+ "Curated commentary chunks"),
94
+ ("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
95
+ ("pdi", "pdi.json", "py -m canlex.pdi",
96
+ "IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
97
+ ("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
98
+ "Mémorandums D (français)"),
99
+ ("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
100
+ "Directives du CNM (français)"),
101
+ ("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
102
+ "IRB Chairperson's Guidelines"),
103
+ ("irb-papers", "irb_papers.json", "py -m canlex.irb_papers",
104
+ "IRB Legal Services papers (ROA ch. 7-9, SPO ch. 2; archived by the IRB)"),
105
+ ("policy", "policy.json", "py -m canlex.conduct",
106
+ "Policy pages (employer instruments EN/FR, CBSA detention policy, STCA, "
107
+ "IRCC rehabilitation pages)"),
108
+ ("agreement-fr", "agreements-fr.json", "py -m canlex.agreement --fr",
109
+ "Convention collective FB (français)"),
110
+ ("enf-fr", "enf-fr.json", "py -m canlex.enf --fr",
111
+ "Guides ENF (édition française)"),
112
+ ]
113
+
114
+
115
+ def _fetch_powershell(url, timeout=180):
116
+ """The .NET HTTP stack, which canada.ca accepts while blocking Python's
117
+ client at the TLS layer (the same helper _common.fetch_cached uses, so
118
+ the two quote a URL the same way)."""
119
+ from ._common import powershell_download
120
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
121
+ dest = tmp.name
122
+ try:
123
+ powershell_download(url, dest, ua=BROWSER_UA, timeout=timeout)
124
+ with open(dest, "rb") as fh:
125
+ return fh.read().decode("utf-8", "replace")
126
+ finally:
127
+ try:
128
+ os.unlink(dest)
129
+ except OSError:
130
+ pass
131
+
132
+
133
+ def check_policy():
134
+ """Upstream dateModified of each employer-policy page vs the stored
135
+ currency -- both canada.ca-family pages stamp one, and the CBSA Code is
136
+ re-issued in place at the same URL (2024 edition, 2012 predecessor)."""
137
+ from .conduct import DOCS, _MODIFIED
138
+ # Keyed by (code, lang): the ten French twins share their English page's
139
+ # act_code, and a French page re-issued on its own date (the CBSA Code and
140
+ # the TBS instruments are re-issued per language) must be compared with
141
+ # ITS stored date, not the English one's -- until 2026-09-21 the FR row
142
+ # read STALE that --force could never clear, or the reverse silently.
143
+ stored_by_code = {}
144
+ path = PROCESSED_DIR / "policy.json"
145
+ if path.exists():
146
+ for c in json.loads(path.read_text(encoding="utf-8")):
147
+ stored_by_code.setdefault((c["act_code"], c.get("lang", "en")),
148
+ c.get("current_to", ""))
149
+ rows = []
150
+ for doc in DOCS:
151
+ stored = stored_by_code.get((doc["code"], doc.get("lang", "en")), "")
152
+ # Fetch the way the ingester does: a document with several pages
153
+ # ('urls') is stamped on its first page, and the canada.ca pages that
154
+ # refuse Python's HTTP client need the PowerShell path -- without
155
+ # this, the detention standards, the STCA texts and the rehabilitation
156
+ # guide could never clear, and three permanent ERROR rows teach the
157
+ # reader to ignore the watch.
158
+ url = doc.get("url") or doc["urls"][0]
159
+ try:
160
+ if doc.get("powershell"):
161
+ html = _fetch_powershell(url)
162
+ else:
163
+ req = urllib.request.Request(
164
+ url, headers={"User-Agent": BROWSER_UA})
165
+ with urllib.request.urlopen(req, timeout=60) as resp:
166
+ html = resp.read().decode("utf-8", "replace")
167
+ m = _MODIFIED.search(html)
168
+ remote = ((m.group(1) or m.group(2)) if m else "")
169
+ except Exception as exc:
170
+ rows.append({"short": doc["short"], "lang": doc.get("lang", "en"),
171
+ "stored": stored, "remote": "", "status": "error",
172
+ "error": f"{type(exc).__name__}: {exc}"})
173
+ continue
174
+ if not stored:
175
+ status = "missing-local"
176
+ elif not remote:
177
+ status = "no-date"
178
+ elif remote > stored:
179
+ status = "stale"
180
+ else:
181
+ status = "ok"
182
+ # lang: ten documents have a French twin under the same short label,
183
+ # so the summary tags the twin '(fr)' instead of printing each twice.
184
+ rows.append({"short": doc["short"], "lang": doc.get("lang", "en"),
185
+ "stored": stored, "remote": remote, "status": status, "error": ""})
186
+ time.sleep(0.5)
187
+ return rows
188
+
189
+
190
+ # ---- guidance currency: ENF, PDI, IRB papers, forms (roadmap T4.8) -----------
191
+ # The non-XML guidance sources expose different upstream signals, so each is
192
+ # read the cheapest way it offers:
193
+ # - official ENF chapters (canada.ca PDFs): the HTTP Last-Modified of a HEAD
194
+ # request, against the last-seen marker -- the cached PDF's fetch time, or a
195
+ # date acknowledged with --ack-guidance, whichever is later. A 404 on a
196
+ # chapter the corpus holds means IRCC retired it. ENF 2 and 14 are skipped:
197
+ # withdrawn, and ingested from third-party ATIP copies (enf.UNOFFICIAL).
198
+ # - PDI: each category's index page only, not all ~300 pages. The in-scope
199
+ # links it lists are diffed against the index cached at the last ingest, and
200
+ # its dcterms.modified is compared; plus the Tran serious-criminality page.
201
+ # - IRB Legal Services papers: the ARCHIVED marker and the date modified on
202
+ # the legal-concepts index pages (RoaAmr.aspx, SpoPar.aspx).
203
+ # - IMM 1444 and Guide 5312: canlex.conduct ingests both, so check_policy
204
+ # already reads their page dateModified; check_guidance reports those rows
205
+ # and does not fetch them again.
206
+ _GUIDANCE_MARKER = PROCESSED_DIR.parent / "raw" / "guidance" / "last-seen.json"
207
+ _GUIDANCE_FORMS = ("IMM-1444", "IMM-5312")
208
+ _PDI_KEY_PAGES = [("pdi-standard", "PDI Tran serious-criminality page",
209
+ "standard-requirements/tran.html")]
210
+ _GUIDANCE_FLAGS = {"enf": ("check", "retired"),
211
+ "pdi": ("changed", "updated", "retired"),
212
+ "irb": ("updated", "unarchived")}
213
+
214
+ # One PowerShell process for a batch of URLs (canada.ca refuses Python's TLS
215
+ # client; one process instead of one per URL keeps ~50 requests to a minute
216
+ # or two). Passed with -EncodedCommand, so no quoting survives the Windows
217
+ # command line to mangle it.
218
+ _PS_PROBE = r"""
219
+ $ProgressPreference = 'SilentlyContinue'
220
+ $urls = @(Get-Content -LiteralPath '{urls}' -Encoding UTF8 | Where-Object {{ $_ }})
221
+ $rows = @()
222
+ $i = 0
223
+ foreach ($u in $urls) {{
224
+ $i++
225
+ # [string]: a Get-Content line carries PSPath/ReadCount note properties
226
+ # that ConvertTo-Json would serialise as an object
227
+ $row = [ordered]@{{url = [string]$u; status = 0; last_modified = ''; path = ''; error = ''}}
228
+ try {{
229
+ if ('{method}' -eq 'HEAD') {{
230
+ $r = Invoke-WebRequest -Uri $u -Method Head -UseBasicParsing -TimeoutSec 60 -UserAgent '{ua}'
231
+ }} else {{
232
+ $p = Join-Path '{out}' ("$i.html")
233
+ $r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 90 -UserAgent '{ua}' -OutFile $p -PassThru
234
+ $row.path = [string]$p
235
+ }}
236
+ $row.status = [int]$r.StatusCode
237
+ $row.last_modified = [string]$r.Headers['Last-Modified']
238
+ }} catch {{
239
+ if ($_.Exception.Response) {{ $row.status = [int]$_.Exception.Response.StatusCode }}
240
+ $row.error = [string]$_.Exception.Message
241
+ }}
242
+ $rows += [pscustomobject]$row
243
+ Start-Sleep -Milliseconds {pause}
244
+ }}
245
+ ConvertTo-Json -InputObject $rows -Depth 3 | Set-Content -LiteralPath '{result}' -Encoding UTF8
246
+ """
247
+
248
+
249
+ def _ps_probe_script(urls_file, method, out_dir, result_file, pause_ms=300):
250
+ return _PS_PROBE.format(urls=urls_file, method=method, out=out_dir,
251
+ ua=BROWSER_UA, pause=pause_ms, result=result_file)
252
+
253
+
254
+ def _powershell_probe(urls, method="HEAD", pause_ms=300, timeout=1800):
255
+ """{url: {status, last_modified, html, error}} for every URL, fetched by a
256
+ single PowerShell process. HEAD returns headers only; GET also returns
257
+ the body. status is 0 when no HTTP status came back."""
258
+ import base64
259
+ import subprocess
260
+ urls = list(dict.fromkeys(urls))
261
+ if not urls:
262
+ return {}
263
+ with tempfile.TemporaryDirectory() as td:
264
+ td = Path(td)
265
+ (td / "urls.txt").write_text("\n".join(urls), encoding="utf-8")
266
+ script = _ps_probe_script(td / "urls.txt", method, td, td / "result.json",
267
+ pause_ms)
268
+ encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
269
+ try:
270
+ subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
271
+ "-EncodedCommand", encoded],
272
+ check=True, capture_output=True, timeout=timeout)
273
+ rows = json.loads((td / "result.json").read_text(encoding="utf-8-sig"))
274
+ except Exception as exc:
275
+ err = f"{type(exc).__name__}: {exc}"
276
+ return {u: {"status": 0, "last_modified": "", "html": "", "error": err}
277
+ for u in urls}
278
+ out = {}
279
+ for r in rows if isinstance(rows, list) else [rows]:
280
+ path = Path(r["path"]) if r.get("path") else None
281
+ html = (path.read_bytes().decode("utf-8", "replace")
282
+ if path and path.exists() else "")
283
+ out[r["url"]] = {"status": int(r.get("status") or 0),
284
+ "last_modified": r.get("last_modified") or "",
285
+ "html": html, "error": r.get("error") or ""}
286
+ return out
287
+
288
+
289
+ def _guidance_marker(path=None):
290
+ try:
291
+ return json.loads(Path(path or _GUIDANCE_MARKER).read_text(encoding="utf-8"))
292
+ except (OSError, ValueError):
293
+ return {}
294
+
295
+
296
+ def _write_guidance_marker(marker, path=None):
297
+ # Atomic (2026-09-22): a run killed mid-write left truncated JSON, which
298
+ # _guidance_marker reads as {} -- every acknowledged date silently gone
299
+ # and the ENF baseline re-seeded from the cached PDFs' fetch times.
300
+ atomic_write_text(Path(path or _GUIDANCE_MARKER),
301
+ json.dumps(marker, indent=1, sort_keys=True) + "\n")
302
+
303
+
304
+ def _http_ts(value):
305
+ """POSIX time of an HTTP date ('Mon, 17 Feb 2025 16:38:16 GMT'), else None."""
306
+ import email.utils
307
+ try:
308
+ return email.utils.parsedate_to_datetime(value).timestamp()
309
+ except (TypeError, ValueError, IndexError):
310
+ return None
311
+
312
+
313
+ def _iso_ts(ts):
314
+ return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
315
+ .strftime("%Y-%m-%d %H:%M UTC") if ts is not None else "")
316
+
317
+
318
+ def enf_targets(processed_dir=None):
319
+ """The official ENF chapters the corpus holds, one row per URL:
320
+ {chapter, lang, url, cache}. Only canada.ca editions; ENF 2 and 14 are
321
+ skipped (enf.UNOFFICIAL: withdrawn, known, no upstream to watch)."""
322
+ from . import enf
323
+ processed_dir = Path(processed_dir) if processed_dir else PROCESSED_DIR
324
+ out = []
325
+ for fname, lang in (("enf.json", "en"), ("enf-fr.json", "fr")):
326
+ path = processed_dir / fname
327
+ if not path.exists():
328
+ continue
329
+ seen = set()
330
+ for c in json.loads(path.read_text(encoding="utf-8")):
331
+ m = re.fullmatch(r"ENF-(\d+)", c.get("act_code", ""))
332
+ url = c.get("source_url", "")
333
+ if not m or url in seen or "canada.ca" not in url:
334
+ continue
335
+ n = int(m.group(1))
336
+ if n in enf.UNOFFICIAL:
337
+ continue
338
+ seen.add(url)
339
+ out.append({"chapter": n, "lang": lang, "url": url,
340
+ "cache": enf.RAW / (f"enf{n:02d}-fra.pdf" if lang == "fr"
341
+ else f"enf{n:02d}.pdf")})
342
+ return sorted(out, key=lambda t: (t["lang"], t["chapter"]))
343
+
344
+
345
+ def enf_status(probe, seen_ts):
346
+ """One ENF chapter's watch status from its HEAD probe. Pure. The values:
347
+ 'retired' (a 404 on a chapter the corpus holds), 'error', 'no-date',
348
+ 'baseline' (nothing seen yet), 'check' (Last-Modified later than last
349
+ seen), or 'ok'."""
350
+ if probe.get("status") == 404:
351
+ return "retired"
352
+ if probe.get("status") != 200:
353
+ return "error"
354
+ lm = _http_ts(probe.get("last_modified"))
355
+ if lm is None:
356
+ return "no-date"
357
+ if seen_ts is None:
358
+ return "baseline"
359
+ return "check" if lm > seen_ts + 1 else "ok"
360
+
361
+
362
+ def check_enf(probe=None, targets=None, marker_path=None):
363
+ """HEAD every official ENF chapter. The last-seen time is the later of
364
+ the cached PDF's mtime (re-ingesting with --force moves it) and a date
365
+ recorded by --ack-guidance. When neither exists, the first run records
366
+ the upstream date as the baseline instead of alarming."""
367
+ probe = probe or _powershell_probe
368
+ targets = enf_targets() if targets is None else targets
369
+ got = probe([t["url"] for t in targets], "HEAD") if targets else {}
370
+ marker = _guidance_marker(marker_path)
371
+ seeded, rows = False, []
372
+ for t in targets:
373
+ p = got.get(t["url"]) or {"status": 0, "last_modified": "", "error": "not probed"}
374
+ cache = Path(t["cache"]) if t.get("cache") else None
375
+ seen = [x for x in ((cache.stat().st_mtime if cache and cache.exists() else None),
376
+ _http_ts(marker.get(t["url"]))) if x is not None]
377
+ seen_ts = max(seen) if seen else None
378
+ status = enf_status(p, seen_ts)
379
+ if status == "baseline":
380
+ marker[t["url"]] = p["last_modified"]
381
+ seeded, status, seen_ts = True, "ok", _http_ts(p["last_modified"])
382
+ rows.append({"chapter": t["chapter"], "lang": t["lang"], "url": t["url"],
383
+ "status": status, "remote": p.get("last_modified", ""),
384
+ "seen": _iso_ts(seen_ts), "error": (p.get("error") or "")[:160]})
385
+ if seeded:
386
+ _write_guidance_marker(marker, marker_path)
387
+ return rows
388
+
389
+
390
+ def pdi_index_links(src, html):
391
+ """The in-scope links a PDI category index lists (canlex.pdi's crawl rule
392
+ at depth 1). An empty set for a single-page set (no scope)."""
393
+ from . import pdi
394
+ if not src.get("scope"):
395
+ return set()
396
+ out = set()
397
+ for href in pdi._LINK.findall(pdi._main(html or "")):
398
+ if src["scope"] not in href or any(x in href for x in src.get("exclude", ())):
399
+ continue
400
+ out.add(href if href.startswith("http") else "https://www.canada.ca" + href)
401
+ return out
402
+
403
+
404
+ def pdi_index_diff(src, live_html, seen_html="", stored_urls=(), stored_date="",
405
+ today=None):
406
+ """One PDI category index, live against last seen. Pure. The last-seen
407
+ index is the one cached at ingest; without it, the stored page URLs and
408
+ the index chunk's date. Returns {status, new, removed, remote, seen}, where
409
+ status is 'changed' (pages listed or dropped), 'updated' (dcterms.modified
410
+ later), 'no-date' or 'ok'. 'removed' needs the cached index: stored URLs
411
+ include depth-2 pages that the index never lists."""
412
+ from . import pdi
413
+ live = pdi_index_links(src, live_html)
414
+ if seen_html:
415
+ base, seen = pdi_index_links(src, seen_html), pdi.page_modified(seen_html, today)
416
+ else:
417
+ base, seen = set(stored_urls), stored_date or ""
418
+ new = sorted(live - base)
419
+ removed = sorted(base - live) if seen_html else []
420
+ remote = pdi.page_modified(live_html or "", today)
421
+ if new or removed:
422
+ status = "changed"
423
+ elif not remote:
424
+ status = "no-date"
425
+ elif seen and remote > seen:
426
+ status = "updated"
427
+ else:
428
+ status = "ok"
429
+ return {"status": status, "new": new, "removed": removed, "remote": remote,
430
+ "seen": seen}
431
+
432
+
433
+ def _pdi_urls():
434
+ from . import pdi
435
+ return ([s["index"] for s in pdi.SETS]
436
+ + [pdi._OBM + rel for _, _, rel in _PDI_KEY_PAGES])
437
+
438
+
439
+ def check_pdi(got, today=None):
440
+ """Rows for each PDI category index and each key page, from probed GETs."""
441
+ from . import pdi
442
+ stored_urls, stored_dates = {}, {}
443
+ if pdi.OUT_FILE.exists():
444
+ for c in json.loads(pdi.OUT_FILE.read_text(encoding="utf-8")):
445
+ stored_urls.setdefault(c.get("act_code", "").lower(), set()).add(c["source_url"])
446
+ stored_dates.setdefault(c["source_url"], c.get("current_to", ""))
447
+ rows = []
448
+
449
+ def failed(name, code, url, p):
450
+ rows.append({"name": name, "code": code, "url": url,
451
+ "status": "retired" if p.get("status") == 404 else "error",
452
+ "new": [], "removed": [], "remote": "", "seen": "",
453
+ "error": (p.get("error") or "no page")[:160]})
454
+
455
+ for s in pdi.SETS:
456
+ p = got.get(s["index"]) or {}
457
+ if p.get("status") != 200 or not p.get("html"):
458
+ failed(s["short"], s["code"], s["index"], p)
459
+ continue
460
+ cache = pdi.RAW / f"{s['code']}-index.html"
461
+ seen_html = cache.read_bytes().decode("utf-8", "replace") if cache.exists() else ""
462
+ d = pdi_index_diff(s, p["html"], seen_html, stored_urls.get(s["code"], ()),
463
+ stored_dates.get(s["index"], ""), today)
464
+ rows.append(dict(d, name=s["short"], code=s["code"], url=s["index"], error=""))
465
+ for code, name, rel in _PDI_KEY_PAGES:
466
+ url = pdi._OBM + rel
467
+ p = got.get(url) or {}
468
+ if p.get("status") != 200 or not p.get("html"):
469
+ failed(name, code, url, p)
470
+ continue
471
+ stored = stored_dates.get(url, "")
472
+ remote = pdi.page_modified(p["html"], today)
473
+ status = ("no-date" if not remote else
474
+ "updated" if stored and remote > stored else "ok")
475
+ rows.append({"name": name, "code": code, "url": url, "status": status,
476
+ "new": [], "removed": [], "remote": remote, "seen": stored,
477
+ "error": ""})
478
+ return rows
479
+
480
+
481
+ _IRB_TITLE_ARCHIVED = re.compile(
482
+ r'dcterms\.title"[^>]*content="\s*ARCHIVED\b|<h1[^>]*>\s*(?:<[^>]+>\s*)*ARCHIVED\b',
483
+ re.I)
484
+ _IRB_BANNER = re.compile(r'id="archived"', re.I)
485
+ _PAGE_MODIFIED = re.compile(
486
+ r'dcterms\.modified"[^>]*content="(\d{4}-\d{2}-\d{2})"|'
487
+ r'dateModified"[^>]*>(\d{4}-\d{2}-\d{2})<')
488
+
489
+
490
+ def irb_archive_status(html, seen):
491
+ """(status, modified, marker) for an IRB legal-concepts index page. Pure.
492
+ status: 'unarchived' (neither the 'ARCHIVED' title nor the archived-
493
+ content banner is left, so the chunks' 'archived' label may now be
494
+ wrong), 'updated' (date modified later than `seen`), 'no-date' or 'ok'."""
495
+ marker = {"title": bool(_IRB_TITLE_ARCHIVED.search(html or "")),
496
+ "banner": bool(_IRB_BANNER.search(html or ""))}
497
+ m = _PAGE_MODIFIED.search(html or "")
498
+ modified = (m.group(1) or m.group(2)) if m else ""
499
+ if not (marker["title"] or marker["banner"]):
500
+ status = "unarchived"
501
+ elif not modified:
502
+ status = "no-date"
503
+ elif seen and modified > seen:
504
+ status = "updated"
505
+ else:
506
+ status = "ok"
507
+ return status, modified, marker
508
+
509
+
510
+ def _irb_urls():
511
+ from . import irb_papers
512
+ return sorted({p["index"] for p in irb_papers.PAPERS})
513
+
514
+
515
+ def check_irb(got, marker_path=None):
516
+ """Rows for the IRB legal-concepts index pages, from probed GETs. Last
517
+ seen: a date recorded by --ack-guidance, else irb_papers.ARCHIVED (the
518
+ 'Date modified' both pages carried when the papers were ingested)."""
519
+ from . import irb_papers
520
+ marker = _guidance_marker(marker_path)
521
+ rows = []
522
+ for url in _irb_urls():
523
+ papers = [p["label"] for p in irb_papers.PAPERS if p["index"] == url]
524
+ seen = marker.get("irb:" + url) or irb_papers.ARCHIVED
525
+ p = got.get(url) or {}
526
+ page = url.rsplit("/", 1)[-1]
527
+ if p.get("status") != 200 or not p.get("html"):
528
+ rows.append({"page": page, "url": url, "papers": papers,
529
+ "status": "error", "modified": "", "seen": seen,
530
+ "marker": {}, "error": (p.get("error") or "no page")[:160]})
531
+ continue
532
+ status, modified, marker_found = irb_archive_status(p["html"], seen)
533
+ rows.append({"page": page, "url": url, "papers": papers, "status": status,
534
+ "modified": modified, "seen": seen, "marker": marker_found,
535
+ "error": ""})
536
+ return rows
537
+
538
+
539
+ def guidance_forms(policy_rows):
540
+ """The IMM 1444 and Guide 5312 rows of check_policy (their page
541
+ dateModified), so the guidance section names them without a second fetch."""
542
+ from .conduct import DOCS
543
+ shorts = {d["short"] for d in DOCS
544
+ if d["code"] in _GUIDANCE_FORMS and d.get("lang", "en") == "en"}
545
+ return [dict(r) for r in policy_rows or []
546
+ if r.get("short") in shorts and r.get("lang", "en") == "en"]
547
+
548
+
549
+ def check_guidance(policy_rows=None, probe=None, today=None, marker_path=None,
550
+ enf=None):
551
+ """The guidance-currency watch (roadmap T4.8): {enf, pdi, irb, forms}.
552
+ Two PowerShell processes run, one for the ENF HEADs and one for the PDI
553
+ and IRB page GETs. `enf` may pass pre-built chapter targets (tests)."""
554
+ probe = probe or _powershell_probe
555
+ today = today or datetime.date.today().isoformat()
556
+ enf_rows = check_enf(probe, targets=enf, marker_path=marker_path)
557
+ got = probe(_pdi_urls() + _irb_urls(), "GET")
558
+ return {"enf": enf_rows, "pdi": check_pdi(got, today),
559
+ "irb": check_irb(got, marker_path), "forms": guidance_forms(policy_rows)}
560
+
561
+
562
+ def guidance_flags(guidance):
563
+ """[(kind, row)] of the rows that fail the check. The forms rows are not
564
+ counted here: check_policy's own STALE rows already count them."""
565
+ return [(kind, r) for kind, statuses in _GUIDANCE_FLAGS.items()
566
+ for r in (guidance or {}).get(kind) or [] if r["status"] in statuses]
567
+
568
+
569
+ def ack_guidance(probe=None, marker_path=None, enf=None):
570
+ """Record the current upstream date of every ENF chapter flagged CHECK
571
+ and every IRB index page flagged UPDATED as seen. Run it after someone
572
+ has looked and found nothing to re-ingest. An UNARCHIVED page cannot be
573
+ acknowledged: the chunks' archived label has to change in
574
+ canlex.irb_papers."""
575
+ probe = probe or _powershell_probe
576
+ enf_rows = check_enf(probe, targets=enf, marker_path=marker_path)
577
+ irb_rows = check_irb(probe(_irb_urls(), "GET"), marker_path)
578
+ marker = _guidance_marker(marker_path)
579
+ acked = []
580
+ for r in enf_rows:
581
+ if r["status"] == "check" and r["remote"]:
582
+ marker[r["url"]] = r["remote"]
583
+ acked.append(f"ENF {r['chapter']} ({r['lang']}): {r['remote']}")
584
+ for r in irb_rows:
585
+ if r["status"] == "updated" and r["modified"]:
586
+ marker["irb:" + r["url"]] = r["modified"]
587
+ acked.append(f"IRB {r['page']}: {r['modified']}")
588
+ if acked:
589
+ _write_guidance_marker(marker, marker_path)
590
+ print("acknowledged: " + ("; ".join(acked) if acked else "nothing flagged"))
591
+ return 0
592
+
593
+
594
+ _CIU_DECISIONS = "https://ciu-sdi.ca/en/important-decisions/"
595
+ _CIU_DEC_MARKER = PROCESSED_DIR.parent / "raw" / "ciu" / "decisions-page-date.txt"
596
+
597
+
598
+ def check_ciu_decisions():
599
+ """New-case-law tripwire: the CIU curates its Important Decisions page,
600
+ so its dateModified moving is a cheap signal that a new decision worth
601
+ ingesting has issued. This is the only automated freshness signal the
602
+ case-law corpus has -- courts publish no 'new leading case' feed -- and
603
+ it complements the quarterly gap-audit ritual rather than replacing it."""
604
+ try:
605
+ req = urllib.request.Request(
606
+ _CIU_DECISIONS, headers={"User-Agent": BROWSER_UA})
607
+ with urllib.request.urlopen(req, timeout=60) as resp:
608
+ html = resp.read().decode("utf-8", "replace")
609
+ m = re.search(r'"dateModified":"(\d{4}-\d{2}-\d{2})', html)
610
+ remote = m.group(1) if m else ""
611
+ except Exception as exc:
612
+ return {"stored": "", "remote": "", "status": "error",
613
+ "error": f"{type(exc).__name__}: {exc}"}
614
+ if not remote:
615
+ return {"stored": "", "remote": "", "status": "no-date", "error": ""}
616
+ stored = (_CIU_DEC_MARKER.read_text(encoding="utf-8").strip()
617
+ if _CIU_DEC_MARKER.exists() else "")
618
+ if not stored:
619
+ # First run: record the baseline rather than alarming (atomically: a
620
+ # truncated marker would read as a different, older date).
621
+ atomic_write_text(_CIU_DEC_MARKER, remote)
622
+ stored = remote
623
+ status = "stale" if remote > stored else "ok"
624
+ return {"stored": stored, "remote": remote, "status": status, "error": ""}
625
+
626
+
627
+ def check_union():
628
+ """Upstream drift for the CIU/PSAC documents (incl. every site VSSA).
629
+
630
+ The union reposts amended documents at the same URLs, so the drift
631
+ signal is each PDF's Last-Modified header against our cached copy's
632
+ fetch time. New/removed VSSAs surface as fetch errors or new links on
633
+ the next canlex.ciu run; this check covers content changes to what we
634
+ already hold.
635
+
636
+ Clearing a stale row needs `canlex.ciu --force`: a plain run reuses
637
+ the cached PDF, so it would rebuild identical chunks and leave the
638
+ cache mtime -- and therefore this comparison -- unchanged."""
639
+ import email.utils
640
+ from . import ciu
641
+ rows = []
642
+ targets = [(short, lang, ciu.RAW / f"{code}{'-fr' if lang == 'fr' else ''}.pdf", url)
643
+ for code, short, name, url, lang in ciu.DOCS]
644
+ try:
645
+ targets += [(f"VSSA {stem}", "en",
646
+ ciu.RAW / f"vssa-{stem.lower()}.pdf", url)
647
+ for url, stem in ciu._vssa_links()]
648
+ except Exception as exc:
649
+ rows.append({"short": "VSSA index", "lang": "en", "status": "error",
650
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
651
+ for short, lang, cache, url in targets:
652
+ try:
653
+ req = urllib.request.Request(url, method="HEAD",
654
+ headers={"User-Agent": BROWSER_UA})
655
+ with urllib.request.urlopen(req, timeout=30) as resp:
656
+ lm = resp.headers.get("Last-Modified", "")
657
+ except Exception as exc:
658
+ rows.append({"short": short, "lang": lang, "status": "error",
659
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
660
+ continue
661
+ if not lm:
662
+ rows.append({"short": short, "lang": lang, "status": "no-date",
663
+ "remote": "", "error": ""})
664
+ continue
665
+ remote_ts = email.utils.parsedate_to_datetime(lm).timestamp()
666
+ local_ts = cache.stat().st_mtime if cache.exists() else 0
667
+ rows.append({"short": short, "lang": lang,
668
+ "status": "stale" if remote_ts > local_ts else "ok",
669
+ "remote": lm[:25], "error": ""})
670
+ time.sleep(0.3)
671
+ return rows
672
+
673
+ # Curated datasets age: state legislatures amend deferred-adjudication
674
+ # statutes every session, so a reviewed date older than this many days earns
675
+ # a warning even though nothing upstream can be diffed automatically.
676
+ _CURATED_MAX_AGE_DAYS = 180
677
+
678
+
679
+ def check_curated(today):
680
+ """Age-check the curated datasets and detect edit-without-regen drift.
681
+
682
+ Two signals: (1) each data/curated/*.json 'reviewed' date older than
683
+ _CURATED_MAX_AGE_DAYS -> aging (re-verify against the state sources);
684
+ (2) the newest commentary.json current_to differing from the newest
685
+ curated 'reviewed' -> the curated file was edited without rerunning
686
+ canlex.commentary + canlex.embed, so production serves stale chunks."""
687
+ from .commentary import CURATED, EQUIV
688
+ from .config import DATA_DIR
689
+ # Only the files canlex.commentary renders can put commentary.json out
690
+ # of date; a curated file the renderer never reads (transborder.json, the
691
+ # IRPR s. 19(1)(c)-(g) table read at run time) is age-checked only.
692
+ rendered = {CURATED.name, EQUIV.name}
693
+ rows = []
694
+ reviewed_by_file = {}
695
+ for path in sorted((DATA_DIR / "curated").glob("*.json")):
696
+ try:
697
+ reviewed = json.loads(
698
+ path.read_text(encoding="utf-8")).get("reviewed", "")
699
+ except Exception as exc:
700
+ rows.append({"file": path.name, "reviewed": "",
701
+ "status": "error", "error": f"{type(exc).__name__}: {exc}"})
702
+ continue
703
+ if path.name in rendered:
704
+ reviewed_by_file[path.name] = reviewed or ""
705
+ status = "ok"
706
+ if reviewed:
707
+ age = (datetime.date.fromisoformat(today)
708
+ - datetime.date.fromisoformat(reviewed)).days
709
+ if age > _CURATED_MAX_AGE_DAYS:
710
+ status = "aging"
711
+ rows.append({"file": path.name, "reviewed": reviewed,
712
+ "status": status, "error": ""})
713
+ commentary = PROCESSED_DIR / "commentary.json"
714
+ newest_reviewed = max(reviewed_by_file.values(), default="")
715
+ if newest_reviewed and commentary.exists():
716
+ chunks = json.loads(commentary.read_text(encoding="utf-8"))
717
+ # Per rendered file, by chunk id prefix (canlex.commentary stamps
718
+ # every chunk with its source file's 'reviewed' as current_to):
719
+ # us_dispositions.json renders commentary-disp-* and commentary-state-*,
720
+ # us_equivalency.json commentary-equiv-*; the commentary-method-* chunks
721
+ # come from both and are left to the newest-vs-newest row below. Until
722
+ # 2026-09-21 only the two maxima were compared, so editing the older
723
+ # file (its reviewed still below the other's) without re-rendering
724
+ # printed no DRIFT row while commentary.json kept the pre-edit chunks.
725
+ for name, prefixes in commentary_groups().items():
726
+ reviewed = reviewed_by_file.get(name)
727
+ dates = {c.get("current_to", "") for c in chunks
728
+ if str(c.get("id", "")).startswith(prefixes)}
729
+ if reviewed is None or not dates:
730
+ continue
731
+ newest = max(dates)
732
+ if newest != reviewed or len(dates) > 1:
733
+ rows.append({"file": "commentary.json",
734
+ "reviewed": newest, "status": "drift",
735
+ "error": f"{name} reviewed {reviewed} but its chunks "
736
+ f"say {', '.join(sorted(dates)) or '?'} -- rerun "
737
+ f"canlex.commentary && canlex.embed"})
738
+ newest_chunk = max((c.get("current_to", "") for c in chunks), default="")
739
+ if newest_chunk != newest_reviewed:
740
+ rows.append({"file": "commentary.json",
741
+ "reviewed": newest_chunk, "status": "drift",
742
+ "error": f"curated reviewed {newest_reviewed} but "
743
+ f"chunks say {newest_chunk or '?'} -- rerun "
744
+ f"canlex.commentary && canlex.embed"})
745
+ return rows
746
+
747
+
748
+ def commentary_groups():
749
+ """{rendered curated file name: the id prefixes of the commentary chunks
750
+ it renders} -- the grouping check_curated's per-file drift row relies on
751
+ (tests/test_refresh.py pins it against the renderer's ids)."""
752
+ from .commentary import CURATED, EQUIV
753
+ return {CURATED.name: ("commentary-disp-", "commentary-state-"),
754
+ EQUIV.name: ("commentary-equiv-",)}
755
+
756
+
757
+ def check_curated_sections(today, write_drafts=True, draft_dir=None):
758
+ """Every curated verify phrase against the corpus now (roadmap T4.3).
759
+ The rows report BROKEN (the phrase or section is gone) and AMENDED (the
760
+ section's last_amended is later than last_amended_seen, the date in the
761
+ corpus of the approved commit). A DRAFT penalty_history row goes to
762
+ data/curated/drafts/ when an amendment changed a quoted maximum that no
763
+ history row covers. The result is canlex.curated_checks.run_checks's
764
+ dict: {baseline, rows, drafts, draft_file, drift, error}."""
765
+ from . import curated_checks
766
+ from .config import DATA_DIR
767
+ return curated_checks.run_checks(curated_dir=DATA_DIR / "curated", today=today,
768
+ draft_dir=draft_dir, write=write_drafts)
769
+
770
+
771
+ def curated_sections_flags(result):
772
+ """What fails the check: a broken phrase, a drafted (uncovered) history
773
+ row, a maximum the table does not quote (drift), or an unreadable table.
774
+ An amendment that left the quoted maximum alone is printed as RE-READ
775
+ and does not count."""
776
+ if result.get("error"):
777
+ return [{"error": result["error"]}]
778
+ return ([r for r in result.get("rows") or [] if r["status"] == "broken"]
779
+ + list(result.get("drafts") or []) + list(result.get("drift") or []))
780
+
781
+
782
+ def _years(m):
783
+ return "life" if m is not None and m >= 99 else f"{m} years"
784
+
785
+
786
+ def _print_curated_sections(res):
787
+ from .curated_checks import where
788
+ rows = res.get("rows") or []
789
+ base = res.get("baseline") or ""
790
+ print(f"\nCurated verify phrases ({len(rows)} rows, us_equivalency.json + "
791
+ f"transborder.json; last seen = the corpus of the approved commit "
792
+ f"{base[:7] or '(unreadable: amendments not checked)'}):")
793
+ if res.get("error"):
794
+ print(f" ERROR {res['error'][:70]}")
795
+ return
796
+ broken = [r for r in rows if r["status"] == "broken"]
797
+ for r in broken:
798
+ print(f" BROKEN {where(r)}: {r['act']} s. {r['section']} -- {r['reason']}: "
799
+ f"{r['contains'][:60]!r}")
800
+ for pid, act, sec, new in res.get("drift") or []:
801
+ print(f" NOT QUOTED {pid}: {act} s. {sec} now states "
802
+ f"{' and '.join(_years(m) for m in new)}, which the approved corpus did "
803
+ f"not state and the table does not quote")
804
+ for d in res.get("drafts") or []:
805
+ pr = d["proposed_row"]
806
+ print(f" AMENDED {d['pairing']}{' limb ' + d['limb'] if d['limb'] else ''}: "
807
+ f"{d['act']} s. {d['section']} amended {d['amended']} "
808
+ f"({d['amended_by'] or '?'}; last seen {d['last_amended_seen']}): "
809
+ f"maximum {', '.join(_years(m) for m in d['maxima_before']) or 'none'} -> "
810
+ f"{', '.join(_years(m) for m in d['maxima_after']) or 'none'}; DRAFT "
811
+ f"history row until {pr['until']} at "
812
+ f"{_years(pr['max_years']) if pr['max_years'] is not None else '? (read the old version)'}")
813
+ for c in d.get("cautions") or []:
814
+ print(f" CAUTION {c}")
815
+ if d.get("prose_names_date"):
816
+ print(f" (the curated prose already names this amendment; only the "
817
+ f"structured row is missing)")
818
+ covered = sorted({(r["owner"], r["act"], r["section"], ", ".join(r["covered_by"]))
819
+ for r in rows if r.get("covered_by")})
820
+ for pid, act, sec, until in covered:
821
+ print(f" ok {pid}: {act} s. {sec} amended; the change to its maximum is "
822
+ f"covered by the history row until {until}")
823
+ answered = {(r["act"], r["section"]) for r in rows if r.get("subsections")}
824
+ reread = sorted({(r["act"], r["section"]) for r in rows
825
+ if r.get("amended") and not r.get("subsections")} - answered)
826
+ if reread:
827
+ print(f" RE-READ {len(reread)} section(s) amended since the approved corpus "
828
+ f"with no change to the maximum they are quoted for: "
829
+ + ", ".join(f"{a} s. {s}" for a, s in reread))
830
+ created = sorted({(r["act"], r["section"], l) for r in rows
831
+ for l in r.get("provisions_added") or []})
832
+ repealed = sorted({(r["act"], r["section"], l) for r in rows
833
+ for l in r.get("provisions_removed") or []})
834
+ if created or repealed:
835
+ print(" RE-READ provisions created / repealed where a verify phrase occurs: "
836
+ + ", ".join([f"{a} s. {s}({l}) new" for a, s, l in created]
837
+ + [f"{a} s. {s}({l}) repealed" for a, s, l in repealed]))
838
+ if res.get("draft_file"):
839
+ print(f" -> drafts for review: {res['draft_file']}")
840
+ if broken or res.get("drafts") or res.get("drift"):
841
+ print(" -> the curated JSON is HELD FOR REVIEW: put these rows to the "
842
+ "maintainer; do not edit data/curated/*.json from this check")
843
+ else:
844
+ print(" every phrase is in its section; no uncovered change to a quoted maximum")
845
+
846
+
847
+ def _print_guidance(g):
848
+ print("\nGuidance currency (ENF chapters, PDI indexes, IRB papers, "
849
+ "rehabilitation forms):")
850
+ enf = g.get("enf") or []
851
+ for r in enf:
852
+ tag = f"ENF {r['chapter']}{' (fr)' if r['lang'] == 'fr' else ''}"
853
+ fr = " --fr" if r["lang"] == "fr" else ""
854
+ if r["status"] == "check":
855
+ print(f" CHECK {tag}: upstream Last-Modified {r['remote']} after last "
856
+ f"seen {r['seen']} -- run py -m canlex.enf{fr} --force && py -m "
857
+ f"canlex.embed (or, when nothing changed, py -m canlex.refresh "
858
+ f"--ack-guidance)")
859
+ elif r["status"] == "retired":
860
+ print(f" RETIRED {tag}: 404 upstream -- IRCC withdrew it; run py -m "
861
+ f"canlex.enf{fr} (a 404 chapter is dropped) and report what "
862
+ f"replaced it")
863
+ elif r["status"] in ("error", "no-date"):
864
+ print(f" {'ERROR ' if r['status'] == 'error' else '? '} {tag}: "
865
+ f"{r['error'][:55] or 'no Last-Modified header'}")
866
+ n_ok = sum(1 for r in enf if r["status"] == "ok")
867
+ print(f" ENF: {n_ok} of {len(enf)} official chapter editions unchanged since "
868
+ f"last seen (ENF 2 and 14 skipped: withdrawn, unofficial ATIP copies)")
869
+ pdi_rows = g.get("pdi") or []
870
+ for r in pdi_rows:
871
+ if r["status"] == "changed":
872
+ names = [u.rsplit("/", 1)[-1] for u in r["new"]][:4]
873
+ gone = [u.rsplit("/", 1)[-1] for u in r["removed"]][:4]
874
+ print(f" CHANGED {r['name']}: index lists {len(r['new'])} new page(s)"
875
+ f"{' ' + ', '.join(names) if names else ''} and dropped "
876
+ f"{len(r['removed'])}{' ' + ', '.join(gone) if gone else ''} -- run "
877
+ f"py -m canlex.pdi --force && py -m canlex.embed")
878
+ elif r["status"] == "updated":
879
+ print(f" UPDATED {r['name']}: modified {r['remote']} > last seen "
880
+ f"{r['seen']} -- run py -m canlex.pdi --force && py -m canlex.embed")
881
+ elif r["status"] == "retired":
882
+ print(f" RETIRED {r['name']}: 404 upstream ({r['url']})")
883
+ elif r["status"] in ("error", "no-date"):
884
+ print(f" {'ERROR ' if r['status'] == 'error' else '? '} {r['name']}: "
885
+ f"{r['error'][:55] or 'no dcterms.modified'}")
886
+ n_ok = sum(1 for r in pdi_rows if r["status"] == "ok")
887
+ print(f" PDI: {n_ok} of {len(pdi_rows)} index/key pages unchanged")
888
+ for r in g.get("irb") or []:
889
+ label = f"IRB {r['page']} ({', '.join(r['papers'])})"
890
+ if r["status"] == "unarchived":
891
+ print(f" UNARCHIVED {label}: the ARCHIVED marker is gone -- the chunks "
892
+ f"still say 'archived' (canlex.irb_papers); re-read the page and "
893
+ f"report it (not a refresh)")
894
+ elif r["status"] == "updated":
895
+ print(f" UPDATED {label}: date modified {r['modified']} > last seen "
896
+ f"{r['seen']} -- re-read; py -m canlex.irb_papers --force && py -m "
897
+ f"canlex.embed if a paper changed, then py -m canlex.refresh "
898
+ f"--ack-guidance")
899
+ elif r["status"] == "error":
900
+ print(f" ERROR {label}: {r['error'][:55]}")
901
+ else:
902
+ print(f" ok {label}: still archived (modified "
903
+ f"{r['modified'] or '?'})")
904
+ for r in g.get("forms") or []:
905
+ print(f" {'STALE ' if r['status'] == 'stale' else r['status'][:6]:6} "
906
+ f"{r['short']}: page dateModified {r.get('remote') or r.get('stored') or '?'}"
907
+ f" (read by the employer-policy check above)")
908
+
909
+
910
+ def check_tariff_edition(today):
911
+ """Probe whether CBSA has published a Customs Tariff edition newer than
912
+ the ingested one. The tariff pages live under a year-stamped path
913
+ (.../tariff-tarif/<year>/html/...), so probing the ingested-edition-plus-
914
+ one URL (and the current calendar year's) is a reliable new-edition
915
+ signal without parsing anything."""
916
+ from . import tariff_schedule
917
+ src = next(iter(tariff_schedule.SOURCES.values()))
918
+ have = int(src["edition"])
919
+ candidates = sorted({have + 1, int(today[:4])} - {have})
920
+ for year in candidates:
921
+ if year <= have:
922
+ continue
923
+ probe = src["url"].replace(f"/{src['edition']}/", f"/{year}/")
924
+ try:
925
+ req = urllib.request.Request(
926
+ probe, method="HEAD", headers={"User-Agent": BROWSER_UA})
927
+ with urllib.request.urlopen(req, timeout=30) as resp:
928
+ if resp.status == 200:
929
+ return {"stored": str(have), "remote": str(year),
930
+ "status": "stale", "error": ""}
931
+ except urllib.error.HTTPError as exc:
932
+ if exc.code == 404:
933
+ continue # 404 = that edition doesn't exist yet
934
+ return {"stored": str(have), "remote": "", "status": "error",
935
+ "error": f"HTTP {exc.code} probing {probe}"}
936
+ except Exception as exc:
937
+ # DNS down, a timeout, a TLS refusal: not 'ok' -- until 2026-09-21
938
+ # every exception read as 'no newer edition'.
939
+ return {"stored": str(have), "remote": "", "status": "error",
940
+ "error": f"{type(exc).__name__}: {exc}"}
941
+ return {"stored": str(have), "remote": "", "status": "ok", "error": ""}
942
+
943
+
944
+ def dmemo_drift(raw, stored):
945
+ """(newest active LAST_UPDATED, memos newer than `stored`) from the CBSA
946
+ D-memo datasource JSON. Pure, for testability."""
947
+ rows = [r for r in json.loads(raw).get("data", [])
948
+ if str(r.get("STATUS", "")).strip().lower() != "cancelled"]
949
+ remote = max((r.get("LAST_UPDATED", "") for r in rows), default="")
950
+ drifted = sorted(_dmemo_number(r) for r in rows
951
+ if stored and r.get("LAST_UPDATED", "") > stored)
952
+ return remote, drifted
953
+
954
+
955
+ def _dmemo_number(row):
956
+ """'D1-2-1' out of a datasource NUMBER cell (an anchor, or bare text)."""
957
+ cell = str(row.get("NUMBER", ""))
958
+ inner = re.search(r">([^<]+)<", cell)
959
+ return (inner.group(1) if inner else re.sub(r"<[^>]+>", "", cell)).strip()
960
+
961
+
962
+ def check_dmemo_index():
963
+ """Newest per-memo 'Last updated' upstream vs stored currency.
964
+
965
+ This reads the JSON datasource behind the index table, NOT dates in the
966
+ index HTML: since CBSA rebuilt the page (2026-07-10) the newest date in the
967
+ markup is the page's own 'Date modified', which moves when the page is
968
+ rebuilt and no memo has changed -- that would report stale forever. The
969
+ datasource carries a LAST_UPDATED per memo, so drift is both true and named.
970
+ """
971
+ stored = ""
972
+ path = PROCESSED_DIR / "dmemos.json"
973
+ if path.exists():
974
+ chunks = json.loads(path.read_text(encoding="utf-8"))
975
+ stored = max((c.get("current_to", "") for c in chunks), default="")
976
+ try:
977
+ req = urllib.request.Request(
978
+ dmemo.DATASOURCE_URL, headers={"User-Agent": BROWSER_UA})
979
+ with urllib.request.urlopen(req, timeout=60) as resp:
980
+ remote, drifted = dmemo_drift(resp.read(), stored)
981
+ except Exception as exc:
982
+ return {"stored": stored, "remote": "", "drifted": [], "status": "error",
983
+ "error": f"{type(exc).__name__}: {exc}"}
984
+ status = "stale" if (remote and stored and remote > stored) else \
985
+ ("ok" if remote else "no-date")
986
+ return {"stored": stored, "remote": remote, "drifted": drifted,
987
+ "status": status, "error": ""}
988
+
989
+ # NJC directive dates as published: the index prints prose ('June 1, 1993' --
990
+ # double spaces occur in the raw markup) while chunks may store either that
991
+ # prose or the ISO fallback directive.py derives from page text, so both sides
992
+ # are normalized to ISO before comparing.
993
+ _NJC_PROSE_DATE = re.compile(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})")
994
+ _ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
995
+
996
+ # CBSA publishes its IRPA delegation as a base instrument plus incremental
997
+ # amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
998
+ # linked from the CBSA legislation index. A date on the index that we have not
999
+ # ingested means a new amendment to add to delegation.SOURCES.
1000
+ _CBSA_INDEX_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
1001
+ "legislation-eng.html")
1002
+ _CBSA_INSTRUMENT_RE = re.compile(r"irpa-lipr-(\d{4}-\d{2}-\d{2})-eng\.html")
1003
+ # The IRCC IL3 instrument is reissued with a seasonal version stamp on page 1.
1004
+ _IL3_VERSION_RE = re.compile(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}")
1005
+
1006
+ # Bills known to be heading into force that affect the corpus. Surfaced once
1007
+ # their in-force date has passed, with the codes to re-ingest. (The legislation
1008
+ # drift check above will independently flag the affected Act once Justice Laws
1009
+ # consolidates the amendment; this list just names the cause.)
1010
+ _PENDING_BILLS = [
1011
+ {"bill": "C-16", "name": "Protecting Victims Act",
1012
+ "in_force": "2026-07-18", "affects": ["C-46"],
1013
+ "note": "Criminal Code: coercive control, child-protection reporting "
1014
+ "(coercive-control provisions come into force later)."},
1015
+ ]
1016
+
1017
+
1018
+ def _extract_current_date(raw):
1019
+ """Pull the lims:current-date (YYYY-MM-DD) from the head of a Justice Laws
1020
+ XML document. Pure and offline, for testability. Returns '' if absent."""
1021
+ if raw[:3] == b"\xef\xbb\xbf":
1022
+ raw = raw[3:]
1023
+ m = _CURRENT_DATE.search(raw)
1024
+ return m.group(1).decode("ascii") if m else ""
1025
+
1026
+
1027
+ def _stored_current_to(fname):
1028
+ """The current_to stored in data/processed/<fname> (first chunk), '' when
1029
+ the file is empty, None when it does not exist."""
1030
+ path = PROCESSED_DIR / fname
1031
+ if not path.exists():
1032
+ return None
1033
+ chunks = json.loads(path.read_text(encoding="utf-8"))
1034
+ return chunks[0].get("current_to", "") if chunks else ""
1035
+
1036
+
1037
+ def _local_current_to(code):
1038
+ """The current_to stored in data/processed/<code>.json (first chunk), or ''."""
1039
+ return _stored_current_to(f"{code}.json")
1040
+
1041
+
1042
+ def check_bilingual(codes=None, reader=_stored_current_to):
1043
+ """Every Justice Laws instrument must carry its French twin at the SAME
1044
+ consolidation date. The legislation check only reads the English file,
1045
+ so an instrument ingested in one language -- or refreshed in one
1046
+ language only -- would pass it silently. Pure given `reader`, for tests."""
1047
+ rows = []
1048
+ for code in (codes or list(SOURCES)):
1049
+ en = reader(f"{code}.json")
1050
+ fr = reader(f"{code}-fr.json")
1051
+ if en is None:
1052
+ status = "missing-en" # check_legislation reports this too
1053
+ elif fr is None:
1054
+ status = "missing-fr"
1055
+ elif (fr or "") != (en or ""):
1056
+ status = "fr-drift"
1057
+ else:
1058
+ status = "ok"
1059
+ rows.append({"code": code, "en": en or "", "fr": fr or "",
1060
+ "status": status})
1061
+ return rows
1062
+
1063
+
1064
+ # Processed files no watch above is responsible for. Every corpus family must
1065
+ # be claimed by SOURCES (legislation, EN + FR), _NON_XML, or a dedicated
1066
+ # upstream check -- otherwise a new family can be added to the corpus and
1067
+ # never be looked at again. Pure, for tests.
1068
+ _DEDICATED = {"directives.json": "check_directives",
1069
+ "delegation.json": "check_delegation"}
1070
+
1071
+
1072
+ def unwatched_files(files, sources=None, non_xml=None, dedicated=None):
1073
+ sources = list(SOURCES) if sources is None else sources
1074
+ non_xml = _NON_XML if non_xml is None else non_xml
1075
+ dedicated = _DEDICATED if dedicated is None else dedicated
1076
+ claimed = set(dedicated)
1077
+ for code in sources:
1078
+ claimed.add(f"{code}.json")
1079
+ claimed.add(f"{code}-fr.json")
1080
+ claimed.update(fname for _, fname, _, _ in non_xml)
1081
+ return sorted(f for f in files if f.endswith(".json") and f not in claimed)
1082
+
1083
+
1084
+ def _remote_current_date(url):
1085
+ """Fetch only the head of the XML and extract its consolidation date.
1086
+ Returns (date, error): date is '' on a clean fetch that lacked the
1087
+ attribute, error is a short string when the fetch itself failed."""
1088
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
1089
+ try:
1090
+ with urllib.request.urlopen(req, timeout=60) as resp:
1091
+ head = resp.read(_HEAD_BYTES)
1092
+ return _extract_current_date(head), ""
1093
+ except Exception as exc: # network / HTTP / timeout
1094
+ return "", f"{type(exc).__name__}: {exc}"
1095
+
1096
+
1097
+ def check_legislation():
1098
+ """Compare each Justice Laws source's upstream date to the stored corpus.
1099
+ Returns a list of row dicts."""
1100
+ rows = []
1101
+ for code, src in SOURCES.items():
1102
+ local = _local_current_to(code)
1103
+ remote, error = _remote_current_date(src["xml_url"])
1104
+ if error:
1105
+ status = "error"
1106
+ elif local is None:
1107
+ status = "missing-local" # configured but never ingested
1108
+ elif remote and remote > local:
1109
+ status = "stale" # upstream newer than our corpus
1110
+ elif not remote:
1111
+ status = "no-date" # fetched, but no date found (unusual)
1112
+ else:
1113
+ status = "ok"
1114
+ rows.append({"code": code, "short": src["short"], "local": local or "",
1115
+ "remote": remote, "status": status, "error": error})
1116
+ time.sleep(0.3) # be polite to Justice Laws
1117
+ return rows
1118
+
1119
+
1120
+ def non_xml_currency():
1121
+ """Stored currency (newest item) of the aggregate sources."""
1122
+ rows = []
1123
+ for label, fname, cmd, desc in _NON_XML:
1124
+ path = PROCESSED_DIR / fname
1125
+ if not path.exists():
1126
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
1127
+ "newest": "", "chunks": 0, "present": False})
1128
+ continue
1129
+ chunks = json.loads(path.read_text(encoding="utf-8"))
1130
+ newest = max((c.get("current_to", "") for c in chunks), default="")
1131
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
1132
+ "newest": newest, "chunks": len(chunks), "present": True})
1133
+ return rows
1134
+
1135
+
1136
+ def _cbsa_ingested_dates():
1137
+ """The effective dates of the CBSA dated delegation instruments we ingest."""
1138
+ return sorted({s["effective"] for s in delegation.SOURCES.values()
1139
+ if s.get("kind") == "html-cbsa" and s.get("effective")})
1140
+
1141
+
1142
+ def _ircc_stored_version():
1143
+ """The IL3 version string recorded in the ingested corpus, or ''."""
1144
+ path = PROCESSED_DIR / "delegation.json"
1145
+ if not path.exists():
1146
+ return ""
1147
+ chunks = json.loads(path.read_text(encoding="utf-8"))
1148
+ versions = [c.get("current_to", "") for c in chunks
1149
+ if c.get("act_short") == "IRCC IL3" and c.get("current_to")]
1150
+ return max(versions) if versions else ""
1151
+
1152
+
1153
+ def check_delegation():
1154
+ """Compare the delegation instruments against upstream. Returns row dicts.
1155
+
1156
+ CBSA: diff the dated instruments published on the legislation index against
1157
+ those we ingest -- a new date means an un-ingested amendment. IRCC: compare
1158
+ the IL3 version stamp published in the live PDF against the stored one."""
1159
+ rows = []
1160
+
1161
+ # --- CBSA: index diff (cheap; one HTML fetch) ---
1162
+ have = _cbsa_ingested_dates()
1163
+ try:
1164
+ req = urllib.request.Request(_CBSA_INDEX_URL,
1165
+ headers={"User-Agent": BROWSER_UA})
1166
+ with urllib.request.urlopen(req, timeout=60) as resp:
1167
+ html = resp.read().decode("utf-8", "replace")
1168
+ published = sorted(set(_CBSA_INSTRUMENT_RE.findall(html)))
1169
+ new = sorted(set(published) - set(have))
1170
+ rows.append({"name": "CBSA IRPA delegation (+ amendments)",
1171
+ "status": "stale" if new else "ok",
1172
+ "have": have, "published": published, "new": new,
1173
+ "error": ""})
1174
+ except Exception as exc:
1175
+ rows.append({"name": "CBSA IRPA delegation (+ amendments)",
1176
+ "status": "error", "have": have, "published": [],
1177
+ "new": [], "error": f"{type(exc).__name__}: {exc}"})
1178
+
1179
+ # --- IRCC IL3: version-stamp compare (re-fetches the PDF via PowerShell) ---
1180
+ stored = _ircc_stored_version()
1181
+ try:
1182
+ with tempfile.TemporaryDirectory() as td:
1183
+ data = delegation._fetch(delegation.SOURCES["ircc"]["url"],
1184
+ Path(td) / "il3.pdf", powershell=True)
1185
+ pages = delegation._pdf_pages(data)
1186
+ m = _IL3_VERSION_RE.search(pages[0]) if pages else None
1187
+ published = m.group(0) if m else ""
1188
+ if not published:
1189
+ status = "no-version"
1190
+ elif published != stored:
1191
+ status = "stale"
1192
+ else:
1193
+ status = "ok"
1194
+ rows.append({"name": "IRCC IL3 instrument", "status": status,
1195
+ "have": [stored], "published": [published], "new": [],
1196
+ "error": ""})
1197
+ except Exception as exc:
1198
+ rows.append({"name": "IRCC IL3 instrument", "status": "error",
1199
+ "have": [stored], "published": [], "new": [],
1200
+ "error": f"{type(exc).__name__}: {exc}"})
1201
+ return rows
1202
+
1203
+
1204
+ def _stored_directives():
1205
+ """Per-directive stored currency from data/processed/directives.json:
1206
+ {code: (title, current_to, source_url)}, one entry per act_code."""
1207
+ path = PROCESSED_DIR / "directives.json"
1208
+ if not path.exists():
1209
+ return {}
1210
+ chunks = json.loads(path.read_text(encoding="utf-8"))
1211
+ out = {}
1212
+ for c in chunks:
1213
+ code = c.get("act_code")
1214
+ if code and code not in out:
1215
+ out[code] = (c.get("act_short", ""), c.get("current_to", ""),
1216
+ c.get("source_url", ""))
1217
+ return out
1218
+
1219
+
1220
+ def _directive_iso(text):
1221
+ """Normalize an NJC directive date ('June 1, 1993' or already ISO) to
1222
+ YYYY-MM-DD. Pure, for testability. Returns '' when not a recognizable date."""
1223
+ text = (text or "").strip()
1224
+ if _ISO_DATE.fullmatch(text):
1225
+ return text
1226
+ m = _NJC_PROSE_DATE.fullmatch(text)
1227
+ if m and m.group(1).lower() in MONTHS:
1228
+ return (f"{int(m.group(3)):04d}-{MONTHS[m.group(1).lower()]:02d}-"
1229
+ f"{int(m.group(2)):02d}")
1230
+ return ""
1231
+
1232
+
1233
+ def _njc_index_dates(html):
1234
+ """{code: date text} parsed from the NJC directive index, mirroring
1235
+ directive.directive_links: the first <a> in each top-level li of
1236
+ ul.directive-list is the current directive (nested lis are archived
1237
+ versions), and the sibling span.date its effective date ('' if absent)."""
1238
+ soup = BeautifulSoup(html, "html.parser")
1239
+ ul = soup.find("ul", class_="directive-list")
1240
+ out = {}
1241
+ if ul is None:
1242
+ return out
1243
+ for li in ul.find_all("li", recursive=False):
1244
+ a = li.find("a", href=True)
1245
+ if not a:
1246
+ continue
1247
+ m = directive._CODE.search(a["href"])
1248
+ if not m:
1249
+ continue
1250
+ span = li.find("span", class_="date")
1251
+ out[m.group(1)] = span.get_text(strip=True) if span else ""
1252
+ return out
1253
+
1254
+
1255
+ def _njc_page_effective(url, td):
1256
+ """Fresh-fetch a directive's own page and pull its 'effective <Month D,
1257
+ YYYY>' prose (directive._page_effective_date), following the 'Print Full
1258
+ Directive' link when the landing page is only a table of contents (the
1259
+ FSDs). Returns (iso_date, error)."""
1260
+ try:
1261
+ html = directive._fetch(url, td / "probe.html", force=True)
1262
+ soup = BeautifulSoup(html, "html.parser")
1263
+ main = soup.find("main")
1264
+ date = directive._page_effective_date(main) if main else ""
1265
+ if not date:
1266
+ print_url = directive._print_link(html)
1267
+ if print_url:
1268
+ full = directive._fetch(print_url, td / "probe-full.html",
1269
+ force=True)
1270
+ fmain = BeautifulSoup(full, "html.parser").find("main")
1271
+ date = directive._page_effective_date(fmain) if fmain else ""
1272
+ return date, ""
1273
+ except Exception as exc:
1274
+ return "", f"{type(exc).__name__}: {exc}"
1275
+
1276
+
1277
+ def check_directives():
1278
+ """Compare each ingested NJC directive's effective date against upstream.
1279
+ Returns row dicts.
1280
+
1281
+ The date directive.py stores in current_to comes from the NJC directive
1282
+ index (the span.date beside each entry), so one fresh index fetch covers
1283
+ nearly every directive; entries the index leaves undated (the Foreign
1284
+ Service Directives) fall back to the 'effective <Month D, YYYY>' prose on
1285
+ the directive's own page -- the same two signals ingestion uses. Probe
1286
+ fetches go to a temp dir, never data/raw, so a stale probe can't mask a
1287
+ later real refresh; directive._fetch already sleeps 0.5s after each
1288
+ download, so the probes stay polite without extra pauses here."""
1289
+ stored = _stored_directives()
1290
+ rows = []
1291
+ if not stored:
1292
+ return rows
1293
+ with tempfile.TemporaryDirectory() as td:
1294
+ td = Path(td)
1295
+ try:
1296
+ html = directive._fetch(directive.INDEX_URL, td / "_index.html",
1297
+ force=True)
1298
+ index_dates, index_error = _njc_index_dates(html), ""
1299
+ except Exception as exc:
1300
+ index_dates, index_error = {}, f"{type(exc).__name__}: {exc}"
1301
+ for code, (title, stored_date, url) in sorted(stored.items()):
1302
+ upstream, error = index_dates.get(code, ""), index_error
1303
+ if not upstream and not error:
1304
+ # Undated on the index: read the date off the page itself.
1305
+ upstream, error = _njc_page_effective(url, td)
1306
+ local, remote = _directive_iso(stored_date), _directive_iso(upstream)
1307
+ if error:
1308
+ status = "error"
1309
+ elif not remote:
1310
+ status = "no-date"
1311
+ elif remote != local:
1312
+ status = "stale" # NJC reissues in place: any drift counts
1313
+ else:
1314
+ status = "ok"
1315
+ rows.append({"code": code, "title": title, "stored": stored_date,
1316
+ "local": local, "remote": remote, "status": status,
1317
+ "error": error})
1318
+ return rows
1319
+
1320
+
1321
+ def pending_in_force(today):
1322
+ """Pending bills whose in-force date has passed as of `today` (ISO str)."""
1323
+ return [b for b in _PENDING_BILLS if b["in_force"] <= today]
1324
+
1325
+
1326
+ def pending_consolidation(bill, reader=None):
1327
+ """[{code, stored, status}] for each Act `bill` affects: the consolidation
1328
+ date stored for it and whether that already covers the bill's in-force
1329
+ date -- 'consolidated' (stored current_to on or after it: a Justice Laws
1330
+ consolidation current to a date includes what came into force by then),
1331
+ 'predates' (the re-ingest is still needed, once Justice Laws has
1332
+ consolidated it) or 'absent' (not ingested). Until 2026-09-22 the notice
1333
+ printed 'run: py -m canlex.ingest --force C-46' either way, for a Criminal
1334
+ Code already stored current to 2026-07-21 against C-16's 2026-07-18.
1335
+ `reader` maps a processed filename to its stored current_to
1336
+ (_stored_current_to); pure given it, for tests."""
1337
+ reader = reader or _stored_current_to
1338
+ rows = []
1339
+ for code in bill["affects"]:
1340
+ stored = reader(f"{code}.json") or ""
1341
+ status = ("absent" if not stored
1342
+ else "consolidated" if stored >= bill["in_force"]
1343
+ else "predates")
1344
+ rows.append({"code": code, "stored": stored, "status": status})
1345
+ return rows
1346
+
1347
+
1348
+ def pending_lines(bill, rows):
1349
+ """The staleness report's lines for one pending bill now in force."""
1350
+ lines = [f" Bill {bill['bill']} ({bill['name']}) in force {bill['in_force']} "
1351
+ f"-> {', '.join(bill['affects'])}",
1352
+ f" {bill['note']}"]
1353
+ for r in rows:
1354
+ if r["status"] == "consolidated":
1355
+ lines.append(f" {r['code']}: stored consolidation current to {r['stored']}, "
1356
+ f"on or after {bill['in_force']} -- what came into force that "
1357
+ f"day is in the corpus; no re-ingest needed for it")
1358
+ elif r["status"] == "predates":
1359
+ lines.append(f" {r['code']}: stored consolidation current to {r['stored']} "
1360
+ f"predates {bill['in_force']} -- re-ingest once Justice Laws "
1361
+ f"has consolidated it (the legislation rows above say STALE)")
1362
+ lines.append(f" run: py -m canlex.ingest --force {r['code']} && "
1363
+ f"py -m canlex.ingest --fr --force {r['code']}")
1364
+ else:
1365
+ lines.append(f" {r['code']}: not ingested -- run: py -m canlex.ingest "
1366
+ f"{r['code']}")
1367
+ return lines
1368
+
1369
+
1370
+ def run(as_json=False):
1371
+ today = datetime.date.today().isoformat()
1372
+ leg = check_legislation()
1373
+ deleg = check_delegation()
1374
+ dirs = check_directives()
1375
+ nonxml = non_xml_currency()
1376
+ curated = check_curated(today)
1377
+ dmemo_idx = check_dmemo_index()
1378
+ tariff = check_tariff_edition(today)
1379
+ union = check_union()
1380
+ ciu_dec = check_ciu_decisions()
1381
+ policy = check_policy()
1382
+ policy_stale = [r for r in policy if r["status"] in ("stale", "missing-local")]
1383
+ guidance = check_guidance(policy, today=today)
1384
+ guidance_flag = guidance_flags(guidance)
1385
+ sections = check_curated_sections(today)
1386
+ sections_flag = curated_sections_flags(sections)
1387
+ pending = [dict(b, consolidation=pending_consolidation(b))
1388
+ for b in pending_in_force(today)]
1389
+ twins = check_bilingual()
1390
+ twins_bad = [r for r in twins if r["status"] in ("missing-fr", "fr-drift")]
1391
+ unwatched = unwatched_files(p.name for p in PROCESSED_DIR.glob("*.json"))
1392
+ stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
1393
+ errors = [r for r in leg if r["status"] == "error"]
1394
+ deleg_stale = [r for r in deleg if r["status"] == "stale"]
1395
+ dirs_stale = [r for r in dirs if r["status"] == "stale"]
1396
+ curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
1397
+ dmemo_stale = dmemo_idx["status"] == "stale"
1398
+ tariff_stale = tariff["status"] == "stale"
1399
+ union_stale = [r for r in union if r["status"] == "stale"]
1400
+ ciu_dec_stale = ciu_dec["status"] == "stale"
1401
+ errors_other = probe_errors(deleg, dirs, curated, union, policy, guidance,
1402
+ dmemo_idx, tariff, ciu_dec)
1403
+
1404
+ if as_json:
1405
+ print(json.dumps({"checked": today, "legislation": leg,
1406
+ "delegation": deleg, "directives": dirs,
1407
+ "non_xml": nonxml, "curated": curated,
1408
+ "dmemo_index": dmemo_idx, "tariff": tariff,
1409
+ "union": union, "ciu_decisions": ciu_dec,
1410
+ "pending_in_force": pending,
1411
+ "bilingual": twins, "unwatched": unwatched,
1412
+ "policy": policy,
1413
+ "guidance": guidance,
1414
+ "curated_sections": sections,
1415
+ "stale_count": (len(stale) + len(deleg_stale)
1416
+ + len(dirs_stale)
1417
+ + len(curated_flag)
1418
+ + len(union_stale)
1419
+ + len(twins_bad) + len(unwatched)
1420
+ + len(policy_stale)
1421
+ + len(guidance_flag)
1422
+ + len(sections_flag)
1423
+ + (1 if dmemo_stale else 0)
1424
+ + (1 if tariff_stale else 0)),
1425
+ "error_count": len(errors) + len(errors_other)}, indent=2))
1426
+ else:
1427
+ print(f"CanLex corpus staleness check — {today}\n")
1428
+ print(f"Justice Laws legislation ({len(leg)} instruments):")
1429
+ for r in sorted(leg, key=lambda x: (x["status"] != "stale", x["short"])):
1430
+ if r["status"] == "stale":
1431
+ print(f" STALE {r['short']:34} local {r['local']} "
1432
+ f"upstream {r['remote']}")
1433
+ elif r["status"] == "missing-local":
1434
+ print(f" ABSENT {r['short']:34} configured but not ingested "
1435
+ f"(upstream {r['remote']})")
1436
+ elif r["status"] == "error":
1437
+ print(f" ERROR {r['short']:34} {r['error'][:50]}")
1438
+ elif r["status"] == "no-date":
1439
+ print(f" ? {r['short']:34} no current-date found upstream")
1440
+ if not stale and not errors:
1441
+ print(f" all {len(leg)} current (local matches or exceeds upstream)")
1442
+ if stale:
1443
+ codes = " ".join(r["code"] for r in stale)
1444
+ print(f"\n -> {len(stale)} drifted. Refresh: "
1445
+ f"py -m canlex.ingest --force {codes}\n"
1446
+ f" and the French twins: py -m canlex.ingest --fr "
1447
+ f"--force {codes}\n"
1448
+ f" then: py -m canlex.embed && py -m canlex.eval")
1449
+
1450
+ print(f"\nFrench twins ({len(twins)} instruments):")
1451
+ for r in twins_bad:
1452
+ if r["status"] == "missing-fr":
1453
+ print(f" ABSENT {r['code']:34} no French edition ingested")
1454
+ else:
1455
+ print(f" DRIFT {r['code']:34} fr {r['fr'] or '?'} != en "
1456
+ f"{r['en'] or '?'}")
1457
+ if twins_bad:
1458
+ codes = " ".join(r["code"] for r in twins_bad)
1459
+ print(f" -> py -m canlex.ingest --fr --force {codes} && "
1460
+ f"py -m canlex.embed")
1461
+ else:
1462
+ print(f" all {len(twins)} present at the English consolidation "
1463
+ f"date")
1464
+
1465
+ if unwatched:
1466
+ print("\nUNWATCHED corpus files (no staleness watch claims them --"
1467
+ " add the family to refresh._NON_XML, config.SOURCES or a"
1468
+ " dedicated check before the next refresh):")
1469
+ for f in unwatched:
1470
+ print(f" !! {f}")
1471
+
1472
+ print("\nDelegation instruments (upstream check):")
1473
+ for r in deleg:
1474
+ if r["status"] == "stale" and r["new"]:
1475
+ print(f" STALE {r['name']}: new upstream {', '.join(r['new'])} "
1476
+ f"not ingested")
1477
+ elif r["status"] == "stale":
1478
+ print(f" STALE {r['name']}: upstream {r['published'][0] or '?'} "
1479
+ f"!= stored {r['have'][0] or '?'}")
1480
+ elif r["status"] == "error":
1481
+ print(f" ERROR {r['name']}: {r['error'][:50]}")
1482
+ elif r["status"] == "no-version":
1483
+ print(f" ? {r['name']}: no version stamp found upstream")
1484
+ elif len(r["published"]) > 1: # CBSA: a set of dated instruments
1485
+ print(f" ok {r['name']}: current "
1486
+ f"({len(r['published'])} instruments, latest "
1487
+ f"{max(r['published'])})")
1488
+ else: # IRCC: a single version stamp
1489
+ shown = r["published"][0] if r["published"] else "current"
1490
+ print(f" ok {r['name']}: current ({shown})")
1491
+ if deleg_stale:
1492
+ print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
1493
+ "then py -m canlex.delegation && py -m canlex.embed")
1494
+
1495
+ print(f"\nNJC directives (upstream check, {len(dirs)} ingested):")
1496
+ for r in dirs:
1497
+ if r["status"] == "stale":
1498
+ print(f" STALE {r['title']}: upstream {r['remote']} "
1499
+ f"!= stored {r['local'] or r['stored'] or '?'}")
1500
+ elif r["status"] == "error":
1501
+ print(f" ERROR {r['title']}: {r['error'][:50]}")
1502
+ elif r["status"] == "no-date":
1503
+ print(f" ? {r['title']}: no effective date found upstream")
1504
+ else:
1505
+ print(f" ok {r['title']}: current ({r['remote']})")
1506
+ if dirs_stale:
1507
+ print(" -> re-ingest: py -m canlex.directive --force && "
1508
+ "py -m canlex.embed")
1509
+
1510
+ print("\nOther sources (stored currency; re-run the ingester to refresh):")
1511
+ for r in nonxml:
1512
+ mark = " " if r["present"] else "!"
1513
+ print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
1514
+ f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
1515
+
1516
+ print("\nCurated datasets (age + regen-drift check):")
1517
+ for r in curated:
1518
+ if r["status"] == "aging":
1519
+ print(f" AGING {r['file']}: reviewed {r['reviewed']} — older "
1520
+ f"than {_CURATED_MAX_AGE_DAYS} days; re-verify the state "
1521
+ f"rows against their statutes and bump 'reviewed'")
1522
+ elif r["status"] == "drift":
1523
+ print(f" DRIFT {r['file']}: {r['error']}")
1524
+ elif r["status"] == "error":
1525
+ print(f" ERROR {r['file']}: {r['error'][:60]}")
1526
+ else:
1527
+ print(f" ok {r['file']}: reviewed {r['reviewed'] or '?'}")
1528
+
1529
+ _print_curated_sections(sections)
1530
+
1531
+ if dmemo_idx["status"] == "stale":
1532
+ drifted = dmemo_idx.get("drifted") or []
1533
+ named = (f" ({', '.join(drifted[:6])}"
1534
+ f"{', …' if len(drifted) > 6 else ''})" if drifted else "")
1535
+ print(f"\nD-memo index: STALE — newest upstream 'Last updated' "
1536
+ f"{dmemo_idx['remote']} > stored {dmemo_idx['stored']}"
1537
+ f"{named}; "
1538
+ f"run py -m canlex.dmemo --force && py -m canlex.embed")
1539
+ elif dmemo_idx["status"] == "error":
1540
+ print(f"\nD-memo index: ERROR {dmemo_idx['error'][:60]}")
1541
+ else:
1542
+ print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
1543
+ f"<= stored {dmemo_idx['stored'] or '?'})")
1544
+
1545
+ if tariff["status"] == "stale":
1546
+ print(f"\nCustoms Tariff edition: STALE — CBSA has published the "
1547
+ f"{tariff['remote']} edition (ingested: {tariff['stored']}); "
1548
+ f"bump the year in tariff_schedule.SOURCES and re-ingest")
1549
+ else:
1550
+ print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
1551
+ f"current)")
1552
+
1553
+ n_union_ok = sum(1 for r in union if r["status"] == "ok")
1554
+ print(f"\nCIU/PSAC union documents (upstream Last-Modified check, "
1555
+ f"{len(union)} documents):")
1556
+ for r in union:
1557
+ if r["status"] == "stale":
1558
+ print(f" STALE {r['short']} ({r['lang']}): upstream "
1559
+ f"modified {r['remote']} after our fetch — run "
1560
+ f"py -m canlex.ciu --force && py -m canlex.embed")
1561
+ elif r["status"] == "error":
1562
+ print(f" ERROR {r['short']}: {r['error'][:55]}")
1563
+ if not union_stale and not any(r["status"] == "error" for r in union):
1564
+ print(f" all {n_union_ok} current (upstream unchanged since fetch)")
1565
+
1566
+ print(f"\nEmployer policy pages (upstream date-modified check):")
1567
+ for r in policy:
1568
+ tag = r["short"] + (" (fr)" if r.get("lang", "en") == "fr" else "")
1569
+ if r["status"] == "stale":
1570
+ print(f" STALE {tag}: upstream modified {r['remote']} "
1571
+ f"> stored {r['stored']} -- run py -m canlex.conduct "
1572
+ f"--force && py -m canlex.embed")
1573
+ elif r["status"] == "missing-local":
1574
+ print(f" ABSENT {tag}: configured but not ingested")
1575
+ elif r["status"] == "error":
1576
+ print(f" ERROR {tag}: {r['error'][:55]}")
1577
+ else:
1578
+ print(f" ok {tag}: current "
1579
+ f"({r['remote'] or r['stored'] or '?'})")
1580
+
1581
+ _print_guidance(guidance)
1582
+
1583
+ if ciu_dec["status"] == "stale":
1584
+ print(f"\nCIU Important-Decisions page: UPDATED "
1585
+ f"({ciu_dec['remote']} > {ciu_dec['stored']}) — the union "
1586
+ f"may have flagged a new decision; review "
1587
+ f"{_CIU_DECISIONS} and, after ingesting anything new, "
1588
+ f"update {_CIU_DEC_MARKER.name} to {ciu_dec['remote']}")
1589
+ elif ciu_dec["status"] == "error":
1590
+ print(f"\nCIU Important-Decisions page: ERROR "
1591
+ f"{ciu_dec['error'][:55]}")
1592
+ else:
1593
+ print(f"\nCIU Important-Decisions page: ok (last modified "
1594
+ f"{ciu_dec['remote'] or '?'})")
1595
+
1596
+ if pending:
1597
+ print("\nPending bills now in force (is the stored consolidation "
1598
+ "on or after the in-force date?):")
1599
+ for b in pending:
1600
+ print("\n".join(pending_lines(b, b["consolidation"])))
1601
+
1602
+ if errors_other and not as_json:
1603
+ print(f"\n{len(errors_other)} probe error(s) in the other watches "
1604
+ f"(exit 1: a watch that cannot reach its source has not checked it):")
1605
+ for kind, what, err in errors_other[:12]:
1606
+ print(f" ERROR {kind}: {what}: {err[:60]}")
1607
+ if len(errors_other) > 12:
1608
+ print(f" ... and {len(errors_other) - 12} more")
1609
+
1610
+ return 1 if (stale or errors or errors_other or deleg_stale or dirs_stale
1611
+ or curated_flag or dmemo_stale or tariff_stale
1612
+ or union_stale or ciu_dec_stale or twins_bad
1613
+ or unwatched or policy_stale or guidance_flag
1614
+ or sections_flag) else 0
1615
+
1616
+
1617
+ def probe_errors(deleg, dirs, curated, union, policy, guidance,
1618
+ dmemo_idx, tariff, ciu_dec):
1619
+ """[(watch, item, error)] for every row of the non-legislation watches
1620
+ whose status is 'error'. Until 2026-09-21 only the legislation rows'
1621
+ errors reached the exit status: a broken PowerShell probe printed 52
1622
+ ERROR rows in the guidance watch and the check passed, and
1623
+ check_tariff_edition read a network failure as 'ok'."""
1624
+ out = []
1625
+ for kind, rows, label in (("delegation", deleg, "name"), ("directives", dirs, "title"),
1626
+ ("curated", curated, "file"), ("union", union, "short"),
1627
+ ("policy", policy, "short")):
1628
+ out += [(kind, str(r.get(label, "?")), r.get("error", "")) for r in rows or []
1629
+ if r.get("status") == "error"]
1630
+ for kind in ("enf", "pdi", "irb"):
1631
+ out += [(f"guidance/{kind}", str(r.get("url") or r.get("chapter") or r.get("page") or "?"),
1632
+ r.get("error", "")) for r in (guidance or {}).get(kind) or []
1633
+ if r.get("status") == "error"]
1634
+ for kind, row in (("dmemo-index", dmemo_idx), ("tariff-edition", tariff),
1635
+ ("ciu-decisions", ciu_dec)):
1636
+ if (row or {}).get("status") == "error":
1637
+ out.append((kind, "probe", row.get("error", "")))
1638
+ return out
1639
+
1640
+
1641
+ def main():
1642
+ args = sys.argv[1:]
1643
+ as_json = "--json" in args
1644
+ if "--ack-guidance" in args:
1645
+ # After someone has looked at a CHECK / UPDATED guidance row and found
1646
+ # nothing to re-ingest: record the upstream date as seen.
1647
+ sys.exit(ack_guidance())
1648
+ # --check is the default mode; accepted for forward-compatibility.
1649
+ sys.exit(run(as_json=as_json))
1650
+
1651
+
1652
+ if __name__ == "__main__":
1653
+ main()
canlex/rehab.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/rerank.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-encoder reranker over ONNX Runtime (local, key-free)."""
2
+ import os
3
+
4
+ import numpy as np
5
+ import onnxruntime as ort
6
+ from huggingface_hub import hf_hub_download
7
+ from huggingface_hub.errors import LocalEntryNotFoundError
8
+ from tokenizers import Tokenizer
9
+
10
+ RERANK_REPO = "Xenova/bge-reranker-base"
11
+ RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
12
+ MAX_TOKENS = 512
13
+ _MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "2000"))
14
+ # 1000 -> 2000 adopted 2026-07-23: the doubled reading
15
+ # window held every 203-Q eval metric and lifted Hit@5
16
+ # +0.01; the cross-encoder now judges a full-size piece
17
+ # rather than 60% of one.
18
+ # cap doc text before tokenizing. Cross-encoder cost is
19
+ # ~linear in tokens per doc, so halving this ~halves the
20
+ # dominant query cost; env-sweepable, eval-gated (a
21
+ # tighter cap scores long sections on less of their text
22
+ # -- fine in practice: the median section is ~520 chars,
23
+ # and sub-chunked pieces cap at 1800). Swept 2026-06:
24
+ # 3000 -> 1000 (with pool 50 -> 16) held the eval exactly.
25
+ _MAX_QUERY_TOKENS = 256
26
+ # The query's token budget in a (query, document) pair.
27
+ # only_second truncation keeps the query whole and RAISES
28
+ # when the query alone overflows MAX_TOKENS (about 509
29
+ # tokens, 400 words of English), which took the whole
30
+ # search() down (code audit 2026-09-21). A query past the
31
+ # budget is cut to its first _MAX_QUERY_TOKENS tokens, on
32
+ # a token boundary, before pairing, so the document keeps
33
+ # at least MAX_TOKENS - _MAX_QUERY_TOKENS - 4 tokens. A
34
+ # query within the budget (the MCP tool's 500-character
35
+ # cap is about 130 tokens of English) is scored exactly
36
+ # as before.
37
+
38
+
39
+ def hub_file(repo, filename):
40
+ """The local path of one model file: the Hugging Face cache first, the
41
+ network only when the file is not cached (LocalEntryNotFoundError).
42
+
43
+ A plain hf_hub_download revalidates every file against huggingface.co
44
+ even when it is cached: 6-7 HEAD requests per cold start, the first tool
45
+ call 12.5-13.3 s online, 24-48 s with the network refused and 106 s
46
+ black-holed (code audit 2026-09-21, perf-07). A cached file is now read
47
+ with no request at all; a fresh clone still downloads once. Under
48
+ HF_HUB_OFFLINE=1 a missing file fails as before."""
49
+ try:
50
+ return hf_hub_download(repo, filename, local_files_only=True)
51
+ except LocalEntryNotFoundError:
52
+ return hf_hub_download(repo, filename)
53
+
54
+
55
+ # ORT keeps the high-water mark of its CPU memory arena for the life of the
56
+ # session: the first search after load grew the process by +490 MB RSS /
57
+ # +1,237 MB private (two 16-document reranker batches), and it never came
58
+ # back. Shrinking the arena at the end of every run returns what a run
59
+ # allocated; outputs are bit-identical and the per-batch time is within
60
+ # noise (983-1160 vs 985-1079 ms; code audit 2026-09-21, perf-04b).
61
+ _ARENA_SHRINK = ("memory.enable_memory_arena_shrinkage", "cpu:0")
62
+
63
+
64
+ def make_run_options():
65
+ """The RunOptions every session.run() of the reranker and the embedder
66
+ passes: shrink the CPU arena after each run (see _ARENA_SHRINK)."""
67
+ options = ort.RunOptions()
68
+ options.add_run_config_entry(*_ARENA_SHRINK)
69
+ return options
70
+
71
+
72
+ def make_ort_session(model_path):
73
+ """A CPU InferenceSession tuned for shared, small-vCPU hosts.
74
+
75
+ Two deviations from onnxruntime defaults: (1) spin-waiting between requests
76
+ is disabled -- by default ORT worker threads busy-spin after an inference
77
+ for a microsecond-scale latency win, which on the Space's shared 2 vCPUs
78
+ burns quota between queries; (2) the intra-op thread count is
79
+ env-configurable (CANLEX_ORT_THREADS) so concurrent sessions can be capped
80
+ below core count if contention is ever observed. Unset, ORT's default
81
+ (all cores) is kept, so single-query numerics and speed are unchanged."""
82
+ opts = ort.SessionOptions()
83
+ threads = int(os.environ.get("CANLEX_ORT_THREADS", "0"))
84
+ if threads:
85
+ opts.intra_op_num_threads = threads
86
+ opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
87
+ opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
88
+ return ort.InferenceSession(model_path, sess_options=opts,
89
+ providers=["CPUExecutionProvider"])
90
+
91
+
92
+ class Reranker:
93
+ """Cross-encoder that scores (query, section) pairs for relevance.
94
+
95
+ Loads a BGE reranker as ONNX and runs it on CPU -- no API key; the model is
96
+ downloaded once and cached. A cross-encoder reads the query and section
97
+ jointly, so it judges true relevance far better than the BM25/embedding
98
+ similarities used to build the candidate pool.
99
+ """
100
+
101
+ def __init__(self):
102
+ model_path = hub_file(RERANK_REPO, RERANK_ONNX)
103
+ tok_path = hub_file(RERANK_REPO, "tokenizer.json")
104
+ self.session = make_ort_session(model_path)
105
+ self.run_options = make_run_options()
106
+ self.input_names = {i.name for i in self.session.get_inputs()}
107
+
108
+ self.tokenizer = Tokenizer.from_file(tok_path)
109
+ # only_second keeps the query intact and truncates the (longer) section.
110
+ self.tokenizer.enable_truncation(max_length=MAX_TOKENS, strategy="only_second")
111
+ # An untruncated twin measures the query (see cap_query): a truncated
112
+ # tokenizer cannot encode a lone sequence under only_second.
113
+ self._measure = Tokenizer.from_file(tok_path)
114
+
115
+ @staticmethod
116
+ def cap_query(tokenizer, query, max_tokens=_MAX_QUERY_TOKENS):
117
+ """`query` cut to its first `max_tokens` tokens of `tokenizer` (an
118
+ untruncated tokenizer), on a token boundary of the original string;
119
+ the query itself, untouched, when it fits."""
120
+ enc = tokenizer.encode(query, add_special_tokens=False)
121
+ if len(enc.ids) <= max_tokens:
122
+ return query
123
+ return query[:enc.offsets[max_tokens - 1][1]]
124
+
125
+ def score(self, query, documents):
126
+ """Return a relevance logit for each document paired with the query.
127
+
128
+ Higher means more relevant. The returned list is aligned with `documents`.
129
+
130
+ Each (query, document) pair is scored in an ONNX run of its own -- a
131
+ contract, not a tuning choice. The int8 model quantizes activations
132
+ per run over the whole input tensor (DynamicQuantizeLinear), so in a
133
+ padded batch a pair's logit moved with its batch companions: all 192
134
+ of a 12 x 16 sample moved, by up to 8.8. One run per pair makes a
135
+ score a property of the pair alone -- the same in the rerank pool,
136
+ the pinpoint pass and a test -- and carries no padding:
137
+ search(top_k=6) p50 over 40 eval questions 1,425 -> 951 ms. Adopted
138
+ on the eval (audit follow-up 2026-09-21, group A item 8a): legacy
139
+ Hit@3 .9583 -> .9688, MRR .8651 -> .8668; holdout (undated) Hit@3
140
+ .9250 -> .9500, MRR .8383 -> .8404; no Hit@1 moved; top-6 order
141
+ moves on 209 of 339 questions.
142
+ """
143
+ if not documents:
144
+ return []
145
+ query = self.cap_query(self._measure, query)
146
+ encs = self.tokenizer.encode_batch(
147
+ [(query, doc[:_MAX_DOC_CHARS]) for doc in documents])
148
+ return [self._logit(enc) for enc in encs]
149
+
150
+ def _logit(self, enc):
151
+ """The logit of one encoded (query, document) pair, run alone."""
152
+ feed = {"input_ids": np.asarray([enc.ids], dtype=np.int64),
153
+ "attention_mask": np.asarray([enc.attention_mask], dtype=np.int64)}
154
+ if "token_type_ids" in self.input_names:
155
+ feed["token_type_ids"] = np.asarray([enc.type_ids], dtype=np.int64)
156
+ logits = self.session.run(None, feed, self.run_options)[0]
157
+ return float(np.asarray(logits, dtype=np.float32).reshape(-1)[0])
158
+
159
+
160
+ def main():
161
+ import time
162
+ print(f"Loading {RERANK_REPO} ({RERANK_ONNX}) ...")
163
+ reranker = Reranker()
164
+ query = "powers of arrest without warrant"
165
+ docs = [
166
+ "Arrest without warrant. A peace officer may arrest without warrant a "
167
+ "person who has committed a criminal offence.",
168
+ "Definitions. In this Act, fish means any fish and includes shellfish, "
169
+ "crustaceans and marine animals.",
170
+ "Importation. It is prohibited to import cannabis except as authorized "
171
+ "under this Act.",
172
+ ]
173
+ start = time.perf_counter()
174
+ scores = reranker.score(query, docs)
175
+ elapsed = (time.perf_counter() - start) * 1000
176
+ print(f"\nQuery: {query!r}")
177
+ for doc, score in sorted(zip(docs, scores), key=lambda x: x[1], reverse=True):
178
+ print(f" {score:8.3f} {doc[:62]}")
179
+ print(f"\n{elapsed:.0f} ms for {len(docs)} documents")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
canlex/reviewed_code.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/screening.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/server.py ADDED
The diff for this file is too large to render. See raw diff
 
canlex/sweep.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coordinate-descent tuning sweep for the four retrieval knobs.
2
+
3
+ Runs the 141-question eval repeatedly, varying one knob at a time while holding
4
+ the others at their current best. Picks the value that maximises Hit@5 (with
5
+ MRR as the tiebreak) and continues to the next knob. One pass through all four
6
+ knobs is usually enough to settle.
7
+
8
+ The knobs are read from env vars at index-load time (see canlex/index.py), so
9
+ each combination is exercised in a fresh subprocess of canlex.eval. Outputs go
10
+ to data/eval/sweep.log and a compact JSON summary to data/eval/sweep.json.
11
+
12
+ py -m canlex.sweep
13
+ """
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ LOG = ROOT / "data" / "eval" / "sweep.log"
24
+ SUMMARY = ROOT / "data" / "eval" / "sweep.json"
25
+
26
+ # (env var, list of candidate values, current default). The defaults match the
27
+ # literals in canlex/index.py; values bracket each on a roughly geometric grid.
28
+ KNOBS = [
29
+ ("CANLEX_MN_WEIGHT", [0.0012, 0.0024, 0.005, 0.01], 0.0024),
30
+ ("CANLEX_MN_CAP", [0.012, 0.024, 0.05, 0.1], 0.024),
31
+ ("CANLEX_REG_PENALTY", [0.002, 0.004, 0.008, 0.016], 0.004),
32
+ ("CANLEX_BACKMATTER_PENALTY",[0.002, 0.004, 0.008, 0.016], 0.004),
33
+ # The hierarchy prior's two guidance tiers (roadmap T5.4): official
34
+ # guidance, and CanLex's own curated commentary below it. Swept together
35
+ # because they trade against each other on the same questions.
36
+ ("CANLEX_MEMO_PENALTY", [0.006, 0.008, 0.010], 0.008),
37
+ ("CANLEX_COMMENTARY_PENALTY",[0.0, 0.002, 0.004, 0.006], 0.0),
38
+ # IRCC's applicant-facing pages, below official guidance (T5.4; the
39
+ # IRCC 'Overcome criminal convictions' page outranked IRPR s. 18 on a
40
+ # legacy rehabilitation question by 0.0135).
41
+ ("CANLEX_APPLICANT_PENALTY", [0.0, 0.008, 0.014, 0.02], 0.0),
42
+ ]
43
+
44
+
45
+ _METRIC_RE = re.compile(
46
+ r"Hit@1:\s*([\d.]+).*?Hit@3:\s*([\d.]+).*?Hit@5:\s*([\d.]+).*?"
47
+ r"Hit@10:\s*([\d.]+).*?MRR:\s*([\d.]+)", re.S)
48
+
49
+
50
+ def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
51
+ """Run canlex.eval once with the given env overrides; return metrics dict."""
52
+ env = dict(os.environ)
53
+ env["CANLEX_EVAL_SWEEP"] = "1" # holdout questions stay sweep-blind
54
+ for k, v in env_overrides.items():
55
+ env[k] = f"{v}"
56
+ proc = subprocess.run(
57
+ [sys.executable, "-u", "-m", "canlex.eval"],
58
+ capture_output=True, text=True, env=env, cwd=ROOT,
59
+ )
60
+ if proc.returncode != 0:
61
+ raise RuntimeError(f"eval failed (exit {proc.returncode}):\n"
62
+ f"{proc.stderr[-800:]}")
63
+ m = _METRIC_RE.search(proc.stdout)
64
+ if not m:
65
+ raise RuntimeError(f"could not parse eval output:\n{proc.stdout[-800:]}")
66
+ h1, h3, h5, h10, mrr = (float(x) for x in m.groups())
67
+ n_misses = proc.stdout.count("miss(es)") # 0 if we end up at 100
68
+ return {"hit1": h1, "hit3": h3, "hit5": h5, "hit10": h10, "mrr": mrr,
69
+ "stdout": proc.stdout}
70
+
71
+
72
+ def _score(metrics: dict[str, float]) -> tuple[float, float]:
73
+ """Order by Hit@5 then MRR (both higher is better)."""
74
+ return (metrics["hit5"], metrics["mrr"])
75
+
76
+
77
+ def main():
78
+ LOG.parent.mkdir(parents=True, exist_ok=True)
79
+ log = LOG.open("w", encoding="utf-8")
80
+ log.write(f"# CanLex tuning sweep -- {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
81
+ print(f"# CanLex tuning sweep -- writing log to {LOG}\n")
82
+
83
+ current = {name: default for name, _values, default in KNOBS}
84
+ all_runs: list[dict] = []
85
+
86
+ # Baseline at current defaults.
87
+ print("Baseline:", current)
88
+ log.write(f"\nBaseline: {current}\n")
89
+ baseline = _run_eval(current)
90
+ print(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}")
91
+ log.write(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}\n")
92
+ all_runs.append({"values": dict(current), "metrics":
93
+ {k: baseline[k] for k in ("hit1", "hit3", "hit5", "hit10", "mrr")}})
94
+ best_metrics = baseline
95
+
96
+ for name, values, _default in KNOBS:
97
+ print(f"\nSweeping {name} in {values} (others held at {current})...")
98
+ log.write(f"\nSweeping {name} in {values} (others held at {current})\n")
99
+ local_best = (current[name], _score(best_metrics), best_metrics)
100
+ for v in values:
101
+ if v == current[name]:
102
+ # Re-use the already-measured baseline at this knob value.
103
+ metrics = best_metrics
104
+ else:
105
+ run_values = dict(current, **{name: v})
106
+ metrics = _run_eval(run_values)
107
+ row = (f" {name}={v!r:<8s} -> Hit@1={metrics['hit1']:.3f} "
108
+ f"Hit@3={metrics['hit3']:.3f} Hit@5={metrics['hit5']:.3f} "
109
+ f"Hit@10={metrics['hit10']:.3f} MRR={metrics['mrr']:.3f}")
110
+ print(row); log.write(row + "\n"); log.flush()
111
+ all_runs.append({"values": dict(current, **{name: v}),
112
+ "metrics": {k: metrics[k] for k in
113
+ ("hit1", "hit3", "hit5", "hit10", "mrr")}})
114
+ score = _score(metrics)
115
+ if score > local_best[1]:
116
+ local_best = (v, score, metrics)
117
+ if local_best[0] != current[name]:
118
+ print(f" ! {name}: {current[name]} -> {local_best[0]} "
119
+ f"(Hit@5 {best_metrics['hit5']:.3f} -> {local_best[2]['hit5']:.3f})")
120
+ log.write(f" ! {name}: {current[name]} -> {local_best[0]}\n")
121
+ current[name] = local_best[0]
122
+ best_metrics = local_best[2]
123
+
124
+ print(f"\nBest: {current}")
125
+ print(f" Hit@1={best_metrics['hit1']:.3f} Hit@3={best_metrics['hit3']:.3f} "
126
+ f"Hit@5={best_metrics['hit5']:.3f} Hit@10={best_metrics['hit10']:.3f} "
127
+ f"MRR={best_metrics['mrr']:.3f}")
128
+ log.write(f"\nBest: {current}\n Hit@1={best_metrics['hit1']:.3f} "
129
+ f"Hit@5={best_metrics['hit5']:.3f} MRR={best_metrics['mrr']:.3f}\n")
130
+ log.close()
131
+ SUMMARY.write_text(json.dumps({
132
+ "best": current,
133
+ "best_metrics": {k: best_metrics[k] for k in
134
+ ("hit1", "hit3", "hit5", "hit10", "mrr")},
135
+ "runs": all_runs,
136
+ }, indent=2), encoding="utf-8")
137
+ print(f"\nLog: {LOG}\nSummary: {SUMMARY}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
canlex/synonyms.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Query-side expansion of legal abbreviations and informal terms.
2
+
3
+ Statutes use formal wording -- "application for protection", "removal order" --
4
+ but users (and a model drafting a search) reach for everyday shorthand: "PRRA",
5
+ "H&C", "deportation". Before retrieval, expand_query() appends the canonical
6
+ statutory terms for any abbreviation or nickname it recognises, so the BM25 and
7
+ semantic stages can match the provision's actual language. It only ever ADDS
8
+ words -- the user's own phrasing is left untouched -- and the cross-encoder
9
+ reranker still sees the original query, so precision is unaffected.
10
+
11
+ python -m canlex.synonyms "PRRA eligibility and an H&C application"
12
+ """
13
+ import re
14
+ import sys
15
+
16
+ # (trigger, canonical terms to append). The trigger is a regex fragment matched
17
+ # case-insensitively as a whole word. Keep this list high-precision: an entry
18
+ # earns its place only when the shorthand is unambiguous in Canadian border,
19
+ # immigration, customs, financial-crime or labour law.
20
+ _SYNONYMS = [
21
+ # Immigration and refugee law
22
+ (r"prra", "pre-removal risk assessment application for protection"),
23
+ (r"pre[- ]removal risk assessment", "application for protection"),
24
+ (r"h\s*&\s*c", "humanitarian and compassionate"),
25
+ (r"rad", "refugee appeal division"),
26
+ (r"rpd", "refugee protection division"),
27
+ (r"iad", "immigration appeal division"),
28
+ (r"irb", "immigration and refugee board"),
29
+ (r"trp", "temporary resident permit"),
30
+ (r"deportation", "removal order"),
31
+ # "No right to appeal (a removal order)" is how users ask about IRPA
32
+ # s. 64, but the section itself never says "removal order" or "right" --
33
+ # its vocabulary is "no appeal may be made ... inadmissible on grounds
34
+ # of ... serious criminality". Bridge the gap.
35
+ (r"no (?:right (?:to|of) )?appeal",
36
+ "no appeal may be made inadmissibility serious criminality"),
37
+ # "Standard of proof" is how users ask about IRPA s. 33, whose own words
38
+ # are "reasonable grounds to believe" that the facts "have occurred".
39
+ (r"standard of proof", "reasonable grounds to believe facts constitute"),
40
+ # "Apply for (criminal) rehabilitation" points at IRPR s. 17, a tiny
41
+ # section titled only "Prescribed period" whose vocabulary is the
42
+ # five-year period after "completion of an imposed sentence".
43
+ (r"appl(?:y|ication) for (?:criminal )?rehabilitation",
44
+ "prescribed period five years completion of imposed sentence"),
45
+ (r"misrep", "misrepresentation"),
46
+ (r"ircc", "immigration refugees and citizenship canada"),
47
+ (r"eta", "electronic travel authorization"),
48
+ (r"trv", "temporary resident visa"),
49
+ (r"arc", "authorization to return to canada removal order"),
50
+ (r"danger opinion", "danger to the public refoulement protection"),
51
+ (r"ministerial relief", "national interest exception inadmissibility"),
52
+ (r"poe", "port of entry"),
53
+ # French for the IRB Chairperson's Guidelines, which exist in English only
54
+ (r"(?:directives?|lignes? directrices?) (?:de la|du) (?:cisr|président)",
55
+ "Chairperson's Guideline Immigration and Refugee Board"),
56
+ # Language of service: the Official Languages Act never says 'French'
57
+ # or 'English' in Part IV -- its words are 'either official language',
58
+ # 'communicate with and obtain available services', 'significant demand'.
59
+ (r"(?:served|service|serve|communicate) (?:in|en) (?:french|english|français|anglais)",
60
+ "communicate with and obtain available services in either official language significant demand"),
61
+ # Device examinations: officers say 'cell phone' / 'laptop'; Customs Act
62
+ # s. 99 says 'goods' and ENF 12 s. 6.15 'digital devices'. Without the
63
+ # bridge an arrest-context SCC case (Fearon) and two unrelated 'without
64
+ # warrant' marginal notes outrank the governing border authorities.
65
+ (r"(?:cell ?phones?|smart ?phones?|mobile (?:phones?|devices?)|laptops?"
66
+ r"|tablets?|digital devices?|electronic devices?)",
67
+ "examination of goods digital device electronic device"),
68
+ # Money: 'cash', 'USD', '10k' never reach PCMLTFA s. 12's own words
69
+ # ('currency or monetary instruments ... prescribed amount').
70
+ (r"(?:cash|us ?dollars|usd|10 ?k|ten thousand dollars)",
71
+ "currency or monetary instruments report prescribed amount"),
72
+ (r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
73
+ # Tax evasion: 'evasion' stems to 'evas' and the Income Tax Act's own
74
+ # words ('evaded or attempted to evade ... payment of taxes') stem to
75
+ # 'evad', and s. 239 is titled only 'Other offences and punishment' -- so
76
+ # without the bridge "penalty for tax evasion" never reaches s. 239.
77
+ # 'tax' is required in the trigger, so customs-duty evasion is untouched.
78
+ (r"(?:tax(?:es)? evasion|evasion of (?:income )?tax(?:es)?"
79
+ r"|evad(?:e|ed|ing) (?:income )?tax(?:es)?|tax fraud"
80
+ r"|[ée]vasion fiscale|fraude fiscale)",
81
+ "evaded or attempted to evade payment of taxes false or deceptive "
82
+ "statements in a return offences and punishment"),
83
+ # The single most common US-screening term: bridge DUI shorthand to the
84
+ # Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
85
+ (r"(?:dwi|dui|drunk driving)",
86
+ "operation while impaired blood alcohol concentration conveyance"),
87
+ # Obstructing or assaulting an officer. The everyday phrasing matches the
88
+ # generic obstruction sections of the Quarantine and Excise Acts word for
89
+ # word ('hinder or wilfully obstruct ... duties or functions'), while the
90
+ # four provisions an officer actually needs each speak differently:
91
+ # Customs Act s. 153.1 'interfere with or molest', IRPA s. 129(1)(d)
92
+ # 'obstructs or impedes', Criminal Code s. 129 'resists or wilfully
93
+ # obstructs' and s. 270 'assaults a public officer or peace officer'.
94
+ # The trigger requires the conduct word to GOVERN 'officer' -- at most two
95
+ # words in between -- so 'what factors impede an immigration officer' and
96
+ # 'grab the officer's business card' are untouched. It also takes the
97
+ # plain words an officer writes a fact pattern in ('shoved', 'spat at'),
98
+ # none of which appears in any of the four provisions. Verbs that are
99
+ # common in ordinary legal prose ('impede', 'threaten', 'push', 'grab')
100
+ # are deliberately left out: they false-fire more often than they help.
101
+ (r"(?:obstruct|hinder|resist|assault|shov|punch|strike|struck"
102
+ r"|kick|spit|spat|swung|swing)\w*"
103
+ r"(?:\W+\w+){0,2}\W+(?:border services |peace |public |customs )?officers?",
104
+ "interfere with or molest an officer hindering an officer "
105
+ "obstructs or impedes an officer in the performance of the officer's "
106
+ "duties offences relating to officers "
107
+ "resists or wilfully obstructs a public officer or peace officer in the "
108
+ "execution of his duty "
109
+ "assaults a public officer or peace officer engaged in the execution of "
110
+ "his duty"),
111
+ # Arrest at the border. Customs Act s. 163.5 is what lends a designated
112
+ # officer the peace officer's arrest powers, but it never says 'arrest
113
+ # without warrant' -- its words are 'the powers and obligations of a peace
114
+ # officer under sections 495 and 497'. 'Fresh pursuit' is the officer's
115
+ # term for Criminal Code s. 494(1)(b)(ii)'s 'freshly pursued'.
116
+ (r"(?:arrest(?:s|ed|ing)? without (?:a )?warrant"
117
+ r"|without (?:a )?warrant arrest\w*"
118
+ r"|(?:found|finds|find) committing"
119
+ r"|fresh(?:ly)? pursu\w+|continuous pursuit)",
120
+ "arrest without warrant finds committing a criminal offence escaping "
121
+ "from and freshly pursued powers and obligations of a peace officer "
122
+ "under sections 495 and 497 designated officer at a customs office"),
123
+ (r"personal exemption",
124
+ "returning persons exemption duty free allowance"),
125
+ # Health-plan vocabulary. Drug brand and molecule names appear nowhere in
126
+ # the PSHCP documents: the rule that answers a Botox question is the
127
+ # exclusion of prescription drugs 'used for cosmetic purposes'.
128
+ (r"(?:botox|botulinum(?: toxin)?|cosmetic (?:drugs?|purposes?|treatments?"
129
+ r"|procedures?|surgery))",
130
+ "prescription drugs used for cosmetic purposes exclusions expenses for "
131
+ "services or products for cosmetic purposes only prior authorization"),
132
+ # Likewise the plans never name a provincial insurer: RAMQ, OHIP and the
133
+ # rest are all 'a provincial/territorial health insurance plan' that the
134
+ # PSHCP supplements, with a co-ordination rule for a Quebec resident's
135
+ # prescription drug claim. Only the insurers' own names trigger this:
136
+ # 'provincial health plan' by itself is ordinary immigration vocabulary
137
+ # (a permit holder's provincial coverage) and must not reach the PSHCP.
138
+ (r"(?:ramq|ohip|r[ée]gie de l'assurance[- ]maladie(?: du qu[ée]bec)?"
139
+ r"|qu[ée]bec (?:drug|prescription drug) (?:insurance|plan))",
140
+ "provincial/territorial health insurance plan supplementary coverage "
141
+ "co-ordination of benefits Quebec resident submitting a prescription "
142
+ "drug claim"),
143
+ (r"(?:nexus|trusted traveller)",
144
+ "presentation of persons alternative manner authorization"),
145
+ # Border and customs
146
+ (r"cbsa", "canada border services agency"),
147
+ (r"bsos?", "border services officer"),
148
+ (r"amps", "administrative monetary penalty system"),
149
+ # Financial-crime and labour
150
+ (r"fintrac", "financial transactions and reports analysis centre"),
151
+ (r"njc", "national joint council"),
152
+ # French -> English bridges. The corpus is English-only, but officers work
153
+ # bilingually and drafting agents emit French terms; with the tokenizer's
154
+ # accent folding these triggers match either accented or folded forms.
155
+ # High-precision statutory anchors only.
156
+ (r"erar", "pre-removal risk assessment application for protection"),
157
+ (r"asfc", "canada border services agency"),
158
+ (r"interdiction de territoire", "inadmissibility inadmissible"),
159
+ (r"grande criminalit[ée]", "serious criminality"),
160
+ (r"criminalit[ée]", "criminality convicted offence"),
161
+ (r"renvoi", "removal order"),
162
+ (r"saisie?s?", "seizure seized goods"),
163
+ # 'devises' is the everyday word for currency; the PCMLTFA's French text
164
+ # says 'espèces et effets' -- bridge to both statutory vocabularies.
165
+ (r"devises", "currency monetary instruments espèces effets"),
166
+ (r"r[ée]sidents? permanents?", "permanent resident"),
167
+ (r"[ée]trangers?", "foreign national"),
168
+ (r"d[ée]tention", "detention detained review"),
169
+ (r"r[ée]fugi[ée]s?", "refugee protection convention"),
170
+ (r"douanes?", "customs duties"),
171
+ (r"marchandises?", "goods imported"),
172
+ (r"d[ée]claration", "report declaration"),
173
+ (r"armes? [àa] feu", "firearm weapon"),
174
+ (r"contr[ôo]le", "examination search"),
175
+ (r"agents? des services frontaliers", "border services officer"),
176
+ (r"permis de travail", "work permit"),
177
+ (r"permis d'[ée]tudes", "study permit"),
178
+ (r"visa de r[ée]sident temporaire", "temporary resident visa"),
179
+ (r"r[ée]adaptation", "rehabilitation prescribed period"),
180
+ (r"expulsion", "deportation removal order"),
181
+ (r"passeurs?", "human smuggling organized entry"),
182
+ (r"fouille", "search of the person strip search"),
183
+ (r"griefs?", "grievance adjudication"),
184
+ (r"convention collective", "collective agreement"),
185
+ (r"heures suppl[ée]mentaires", "overtime hours of work"),
186
+ ]
187
+
188
+ _COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)
189
+ for trigger, expansion in _SYNONYMS]
190
+
191
+
192
+ def expand_query(query):
193
+ """Return `query` with canonical statutory terms appended for every legal
194
+ abbreviation it contains; return it unchanged if it contains none."""
195
+ additions = [exp for pattern, exp in _COMPILED if pattern.search(query)]
196
+ if not additions:
197
+ return query
198
+ return f"{query} {' '.join(additions)}"
199
+
200
+
201
+ def main():
202
+ query = " ".join(sys.argv[1:]) or "PRRA eligibility and an H&C application"
203
+ print(f"query: {query}")
204
+ print(f"expanded: {expand_query(query)}")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
canlex/tariff_schedule.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest Chapters 98 and 99 of the Customs Tariff Schedule.
2
+
3
+ The Customs Tariff's Schedule is the Harmonized System classification of goods
4
+ -- chapters 1-97 categorise every imported good for duty purposes. Those 97
5
+ chapters are huge and outside CanLex's scope, but **chapters 98 and 99 are
6
+ different**: they carry Canada's "special classification" provisions, which
7
+ matter for almost every CBSA border interaction:
8
+
9
+ - Chapter 98 (non-commercial): traveller exemptions, settler's effects,
10
+ Canadian goods returned, ancestral household effects, conveyances
11
+ temporarily imported by a resident, etc.
12
+ - Chapter 99 (commercial): temporary importations, end-use programs,
13
+ government imports, reduced-rate goods for specific industries.
14
+
15
+ Source: the CBSA's HTML edition of the current Customs Tariff. The Justice
16
+ Laws XML for the Act (C-54.011) does NOT include the Schedule.
17
+
18
+ Chunking is one chunk per 4-digit HEADING (98.01, 98.02, ...) plus one chunk
19
+ per chapter for its Notes and Subheading Notes -- a heading is the natural
20
+ unit of legal classification (the eight- and ten-digit items below it are the
21
+ same rule with finer rate granularity).
22
+
23
+ py -m canlex.tariff_schedule [--allow-shrink]
24
+ """
25
+ import re
26
+ import sys
27
+ from collections import defaultdict
28
+
29
+ from bs4 import BeautifulSoup
30
+
31
+ from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
32
+ stored_chunks, write_corpus)
33
+ from .config import PROCESSED_DIR, RAW_DIR
34
+
35
+ RAW = RAW_DIR / "tariff_schedule"
36
+ OUT = PROCESSED_DIR / "tariff_schedule.json"
37
+
38
+ # The 2026 edition is the current Customs Tariff at the time of writing. To
39
+ # refresh: bump the year in the URL and `edition` once CBSA publishes the next.
40
+ SOURCES = {
41
+ "ch98": {
42
+ "code": "ch98",
43
+ "chapter": "98",
44
+ "title": "Special classification provisions — non-commercial",
45
+ "url": ("https://www.cbsa-asfc.gc.ca/trade-commerce/tariff-tarif/"
46
+ "2026/html/00/ch98-eng.html"),
47
+ "edition": "2026",
48
+ },
49
+ "ch99": {
50
+ "code": "ch99",
51
+ "chapter": "99",
52
+ "title": "Special classification provisions — commercial",
53
+ "url": ("https://www.cbsa-asfc.gc.ca/trade-commerce/tariff-tarif/"
54
+ "2026/html/00/ch99-eng.html"),
55
+ "edition": "2026",
56
+ },
57
+ }
58
+
59
+
60
+ def _fetch(url, dest, force=False):
61
+ return fetch_cached(url, dest, force=force)
62
+
63
+
64
+ def _heading_of(item):
65
+ """Map a tariff-item code to its 4-digit heading. The schedule uses three
66
+ levels:
67
+ - '98.01' (4 digits) -- heading
68
+ - '9801.10' (6 digits) -- subheading
69
+ - '9801.10.10' (8 digits) -- tariff item
70
+ All three roll up to heading '98.01'."""
71
+ digits = re.sub(r"\D", "", item)
72
+ if len(digits) < 4:
73
+ return None
74
+ return f"{digits[:2]}.{digits[2:4]}"
75
+
76
+
77
+ def _chapter_part(chapter):
78
+ """The `part` every chunk of a chapter carries -- and, because nothing else
79
+ on a chunk names its chapter, the per-chapter identity preserve_failed
80
+ matches on. Derived in one place so the two stay in step."""
81
+ return f"Schedule, Chapter {chapter}"
82
+
83
+
84
+ def _collect_notes(main):
85
+ """Return the chapter's Notes + Subheading Notes as a single text block."""
86
+ out = []
87
+ for label in ("Notes", "Subheading Notes"):
88
+ h2 = main.find(
89
+ "h2",
90
+ string=lambda s, lbl=label: s and lbl in s and (
91
+ lbl != "Notes" or "Subheading" not in s))
92
+ if not h2:
93
+ continue
94
+ parts = []
95
+ sib = h2.find_next_sibling()
96
+ while sib and sib.name != "h2":
97
+ t = _norm(sib.get_text(" ", strip=True))
98
+ if t:
99
+ parts.append(t)
100
+ sib = sib.find_next_sibling()
101
+ if parts:
102
+ out.append(f"{label}:\n" + "\n".join(parts))
103
+ return "\n\n".join(out)
104
+
105
+
106
+ def parse_chapter(html, src):
107
+ """Parse one Customs Tariff Schedule chapter into chunks."""
108
+ soup = BeautifulSoup(html, "html.parser")
109
+ main = soup.find("main")
110
+ if main is None:
111
+ return []
112
+ for sup in main.find_all("sup"):
113
+ sup.decompose()
114
+
115
+ chunks = []
116
+ chapter = src["chapter"]
117
+ citation_root = f"Customs Tariff, Sched., Ch. {chapter}"
118
+
119
+ # Chapter Notes + Subheading Notes -- one chunk per chapter.
120
+ notes_body = _collect_notes(main)
121
+ if notes_body:
122
+ chunks.append({
123
+ "id": f"tariff-sched-ch{chapter}-notes",
124
+ "doc_type": "legislation",
125
+ "act_code": "C-54.011",
126
+ "act_short": "Customs Tariff",
127
+ "act_name": "Customs Tariff",
128
+ "section": f"Sch-Ch{chapter}-Notes",
129
+ "marginal_note": (f"Chapter {chapter} Notes — "
130
+ f"{src['title']}"),
131
+ "part": _chapter_part(chapter),
132
+ "division": "",
133
+ "heading": src["title"],
134
+ "text": notes_body,
135
+ "history": "",
136
+ "last_amended": "",
137
+ "current_to": src["edition"],
138
+ "citation": f"{citation_root}, Notes",
139
+ "source_url": src["url"],
140
+ })
141
+
142
+ # Walk every row in the schedule table, grouping by 4-digit heading.
143
+ table = main.find("table")
144
+ if table is None:
145
+ return chunks
146
+
147
+ rows_by_heading = defaultdict(list) # heading -> list of (item, ss, desc, unit, mfn, pref)
148
+ heading_desc = {} # heading -> the 4-digit row's description
149
+
150
+ for tr in table.find_all("tr"):
151
+ cells = tr.find_all(["td", "th"], recursive=False)
152
+ if not cells:
153
+ continue
154
+ first = _norm(cells[0].get_text(" ", strip=True))
155
+ if not first or first == "Tariff Item":
156
+ continue # header row or blank
157
+ heading = _heading_of(first)
158
+ if heading is None:
159
+ continue
160
+
161
+ def col(i):
162
+ return _norm(cells[i].get_text(" ", strip=True)) if i < len(cells) else ""
163
+
164
+ ss, desc, unit, mfn, pref = col(1), col(2), col(3), col(4), col(5)
165
+
166
+ # A 4-digit row carries only the heading number and description -- store
167
+ # it once. Otherwise it's a subheading/item row; remember its rate cells.
168
+ digits = re.sub(r"\D", "", first)
169
+ if len(digits) == 4 and desc and heading not in heading_desc:
170
+ heading_desc[heading] = desc
171
+ rows_by_heading[heading].append((first, ss, desc, unit, mfn, pref))
172
+
173
+ for heading in sorted(rows_by_heading):
174
+ desc = heading_desc.get(heading, "")
175
+ rows = rows_by_heading[heading]
176
+ if not desc:
177
+ # No bare 4-digit row -- fall back to the first row's description.
178
+ desc = next((r[2] for r in rows if r[2]), "")
179
+
180
+ # Format the chunk: heading description first, then the items as a
181
+ # readable list. Suppress description on item/subheading rows that just
182
+ # repeat the heading desc verbatim (very common in this Schedule).
183
+ lines = []
184
+ for item, ss, item_desc, unit, mfn, pref in rows:
185
+ d = re.sub(r"\D", "", item)
186
+ if len(d) == 4:
187
+ continue # the heading row, already in desc
188
+ label = f" {item}"
189
+ if ss:
190
+ label += f" (SS {ss})"
191
+ extras = []
192
+ if item_desc and item_desc != desc:
193
+ extras.append(item_desc)
194
+ rate = []
195
+ if mfn:
196
+ rate.append(f"MFN {mfn}")
197
+ if pref:
198
+ rate.append(f"Pref: {pref}")
199
+ if unit and unit != "-":
200
+ rate.append(f"Unit {unit}")
201
+ if rate:
202
+ extras.append(" | ".join(rate))
203
+ tail = " — " + "; ".join(extras) if extras else ""
204
+ lines.append(label + tail)
205
+
206
+ body = f"Heading {heading} — {desc}"
207
+ if lines:
208
+ body += "\n\nTariff items:\n" + "\n".join(lines)
209
+
210
+ chunks.append({
211
+ "id": f"tariff-sched-{heading.replace('.', '-')}",
212
+ "doc_type": "legislation",
213
+ "act_code": "C-54.011",
214
+ "act_short": "Customs Tariff",
215
+ "act_name": "Customs Tariff",
216
+ "section": f"Sch-{heading}",
217
+ "marginal_note": desc[:200],
218
+ "part": _chapter_part(chapter),
219
+ "division": "",
220
+ "heading": src["title"],
221
+ "text": body,
222
+ "history": "",
223
+ "last_amended": "",
224
+ "current_to": src["edition"],
225
+ "citation": f"Customs Tariff, Sched., heading {heading}",
226
+ "source_url": src["url"],
227
+ })
228
+
229
+ return chunks
230
+
231
+
232
+ def preserve_failed(chunks, failures, stored):
233
+ """Re-attach the last-good chunks of chapters this run could not rebuild.
234
+
235
+ Only two chapters make up this corpus, so losing one is losing a third to
236
+ two thirds of it -- and a CBSA page that has been reshaped (or a fetch that
237
+ died mid-run) is indistinguishable from a chapter that legitimately
238
+ vanished until a human looks. Serving last week's copy of ch. 98 beats
239
+ serving no traveller exemptions at all. Restricted to the chapters that
240
+ actually failed. Returns (chunks, preserved chapter identities).
241
+ """
242
+ failed = {_chapter_part(chapter) for chapter, _why in failures}
243
+ return preserve_dropped(chunks, stored, key=lambda c: c["part"],
244
+ only=failed)
245
+
246
+
247
+ def build(allow_shrink=False, force=False):
248
+ all_chunks, failures = [], []
249
+ for src in SOURCES.values():
250
+ print(f"Ingesting Customs Tariff Schedule {src['code']} ...")
251
+ try:
252
+ html = _fetch(src["url"], RAW / f"{src['code']}.html",
253
+ force=force)
254
+ chunks = parse_chapter(html, src)
255
+ except Exception as exc:
256
+ print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
257
+ failures.append((src["chapter"], f"{type(exc).__name__}: {exc}"))
258
+ continue
259
+ if not chunks:
260
+ # A chapter never parses to nothing legitimately -- an empty result
261
+ # means the page moved or changed shape, so treat it as a failure
262
+ # rather than letting it silently shrink the corpus.
263
+ print(f" !! {src['code']}: no chunks parsed")
264
+ failures.append((src["chapter"], "no chunks parsed"))
265
+ continue
266
+ all_chunks.extend(chunks)
267
+ print(f" {len(chunks)} chunks")
268
+
269
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
270
+ stored_chunks(OUT))
271
+ if preserved:
272
+ print(f" kept the last-good copy of {len(preserved)} chapter(s): "
273
+ f"{', '.join(preserved)}")
274
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
275
+ return False
276
+ print(f"\n{len(all_chunks)} tariff-schedule chunks from "
277
+ f"{len(SOURCES) - len(failures)} chapter(s) -> {OUT}")
278
+ return True
279
+
280
+
281
+ def main():
282
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
283
+ force="--force" in sys.argv) else 1)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
canlex/transborder.py ADDED
@@ -0,0 +1,778 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transborder criminality: IRPA s. 36(2.1), IRPR ss. 19, 228(1)(a.01) and
2
+ 229(1)(d.1) (roadmap T2.1, with the critic's correction).
3
+
4
+ s. 36(2)(d) is repealed (2023, c. 32, s. 53); s. 36(2.1) now reads 'A foreign
5
+ national is inadmissible on grounds of transborder criminality for
6
+ committing, on entering Canada, a prescribed offence under an Act of
7
+ Parliament', and IRPR s. 19(1) prescribes the offences:
8
+
9
+ (a) any indictable offence under the Criminal Code;
10
+ (b) any indictable offence under the Immigration and Refugee Protection Act;
11
+ (c)-(g) listed offences under the Firearms Act, the Customs Act (by goods),
12
+ the Controlled Drugs and Substances Act and the Cannabis Act.
13
+
14
+ (a) and (b) are OPEN CLASSES, not lists, so they are rules here, driven by the
15
+ offence's mode -- hybrid offences are deemed indictable by s. 19(2). Only
16
+ (c)-(g) are tabulated, and that table is curated legal content
17
+ (data/curated/transborder.json, HELD FOR REVIEW). A composed release ships
18
+ without it: (a)/(b) still work and the answer says the (c)-(g) table awaits
19
+ review.
20
+
21
+ Also statute-mechanical, and transcribed here because the removal path turns
22
+ on them: which offences the Minister's delegate may decide by a deportation
23
+ order (s. 228(1)(a.01)(i)-(v)) and which go to the Immigration Division
24
+ (s. 229(1)(d.1)); the s. 36(3)(c) rule that rehabilitation does not reach
25
+ s. 36(2.1); and the s. 36(3)(e)(i) contraventions carve-out, checked against
26
+ the Contraventions Regulations (SOR-96-313) in the corpus.
27
+
28
+ Every statutory phrase below is pinned against the corpus text by
29
+ tests/test_transborder.py. Every definition an officer reads is registered
30
+ in canlex/reviewed_code.py.
31
+ """
32
+ import json
33
+ import re
34
+
35
+ TABLE_FILE = "transborder.json"
36
+
37
+ # ---- the Acts IRPR s. 19(1) names ------------------------------------------------
38
+ # Corpus act code -> the Act's name as s. 19(1) writes it, the paragraph, and
39
+ # whether the paragraph is an open class (a rule on the mode) or a list (the
40
+ # curated table). Any other Act: s. 19(1) prescribes no offence under it.
41
+ R19_ACTS = {
42
+ "C-46": {"name": "Criminal Code", "paragraph": "19(1)(a)", "open": True,
43
+ "aliases": ("criminal code", "cc", "ccc", "c-46", "code criminel")},
44
+ "I-2.5": {"name": "Immigration and Refugee Protection Act", "paragraph": "19(1)(b)",
45
+ "open": True,
46
+ "aliases": ("immigration and refugee protection act", "irpa", "i-2.5", "lipr")},
47
+ "F-11.6": {"name": "Firearms Act", "paragraph": "19(1)(c)", "open": False,
48
+ "aliases": ("firearms act", "f-11.6", "loi sur les armes a feu")},
49
+ "C-52.6": {"name": "Customs Act", "paragraph": "19(1)(d) and (e)", "open": False,
50
+ "aliases": ("customs act", "c-52.6", "loi sur les douanes")},
51
+ "C-38.8": {"name": "Controlled Drugs and Substances Act", "paragraph": "19(1)(f)",
52
+ "open": False,
53
+ "aliases": ("controlled drugs and substances act", "cdsa", "c-38.8", "lrcdas")},
54
+ "C-24.5": {"name": "Cannabis Act", "paragraph": "19(1)(g)", "open": False,
55
+ "aliases": ("cannabis act", "c-24.5", "loi sur le cannabis")},
56
+ }
57
+
58
+ # The goods classes IRPR s. 19(1)(d) and (e) turn on, as the Regulations
59
+ # define them. The keys are CanLex's; the labels quote s. 19(1).
60
+ GOODS = {
61
+ "child-pornography": "child pornography, as defined in subsection 163.1(1) of the "
62
+ "Criminal Code",
63
+ "firearm-or-weapon": "an automatic firearm, imitation firearm, non-restricted firearm, "
64
+ "prohibited ammunition, prohibited device, prohibited firearm, "
65
+ "prohibited weapon, replica firearm, restricted firearm or "
66
+ "restricted weapon, as those terms are defined in subsection "
67
+ "84(1) of the Criminal Code",
68
+ "controlled-substance": "a substance listed in any of Schedules I to VI of the "
69
+ "Controlled Drugs and Substances Act",
70
+ "schedule-ix-device": "a device listed in Schedule IX of that Act",
71
+ "cannabis": "cannabis, as defined in subsection 2(1) of the Cannabis Act",
72
+ "other": "goods of any other kind",
73
+ }
74
+
75
+ MODES = ("indictable", "hybrid", "summary-only")
76
+
77
+ # IRPR s. 228(1)(a.01): the transborder offences the Minister's delegate
78
+ # decides with a deportation order (the report 'shall not be referred to the
79
+ # Immigration Division'). (subparagraph, act, provisions, goods condition).
80
+ # For the Customs Act, '12' and '156' name the provision contravened: the
81
+ # offence is under s. 160(1) 'that is ... a contravention of section 12' (or
82
+ # 156). Every other prescribed offence is decided by the Immigration
83
+ # Division: s. 229(1)(d.1).
84
+ MD_ORDERABLE = (
85
+ ("228(1)(a.01)(i)", "C-46", ("86", "87", "90", "91", "95", "104", "320.15(1)"), None),
86
+ ("228(1)(a.01)(ii)", "I-2.5", ("124(1)(b)",), None),
87
+ ("228(1)(a.01)(iii)", "F-11.6", ("110",), None),
88
+ ("228(1)(a.01)(iv)", "C-52.6", ("159(1)",), "firearm-or-weapon"),
89
+ ("228(1)(a.01)(v)(A)", "C-52.6", ("12",), "firearm-or-weapon"),
90
+ ("228(1)(a.01)(v)(B)", "C-52.6", ("156",), None),
91
+ )
92
+
93
+ # ---- statute text the memo quotes (pinned in tests/test_transborder.py) ------------
94
+ S36_2_1 = ("A foreign national is inadmissible on grounds of transborder criminality "
95
+ "for committing, on entering Canada, a prescribed offence under an Act of "
96
+ "Parliament")
97
+ ALLEGATION_2_1 = "committing, on entering Canada, a prescribed offence under an Act of Parliament"
98
+ R19_2 = ("an offence that may be prosecuted either summarily or by way of indictment is "
99
+ "deemed to be an offence punishable by way of indictment, even if it has been "
100
+ "prosecuted summarily")
101
+ S36_3_C = ("the matters referred to in paragraphs (1)(b) and (c) and (2)(b) and (c) do "
102
+ "not constitute inadmissibility")
103
+ S36_3_E = "inadmissibility under subsections (1) to (2.1) may not be based on an offence"
104
+ S36_3_E_I = "designated as a contravention under the Contraventions Act"
105
+ S36_3_B = "there has been a final determination of an acquittal"
106
+ S36_3_B_CONV = "inadmissibility under subsections (1) to (2.1) may not be based on a conviction"
107
+ # s. 36(3)(b) governs s. 36(2.1), but its acquittal words attach to 'a
108
+ # conviction ... in respect of which'; s. 36(2.1) needs no conviction. The
109
+ # law is unsettled here (principle 5): both readings, no pick.
110
+ ACQUITTAL_NOTE = (
111
+ "Acquittal: s. 36(3)(b) governs s. 36(2.1) too ('" + S36_3_B_CONV + "' ... "
112
+ "'in respect of which " + S36_3_B + "'), but its words bar a finding based on "
113
+ "a CONVICTION that ended in an acquittal, and s. 36(2.1) rests on "
114
+ "'committing', not on a conviction. Whether a final acquittal of the entry "
115
+ "offence ends a s. 36(2.1) allegation is NOT SETTLED here: (A) on its words "
116
+ "s. 36(3)(b) does not reach a ground that needs no conviction, and the facts "
117
+ "are still proved on reasonable grounds to believe (s. 33); (B) the acquittal "
118
+ "is read as ending the allegation. Confirm before relying on either reading.")
119
+ R228_OPENING = ("if a report in respect of a foreign national does not include any "
120
+ "grounds of inadmissibility other than those set out in the following "
121
+ "circumstances, the report shall not be referred to the Immigration "
122
+ "Division")
123
+ R228_4_A = "is under 18 years of age and not accompanied by a parent or an adult legally responsible for them"
124
+ R229_D1 = ("an offence other than one referred to in any of subparagraphs "
125
+ "228(1)(a.01)(i) to (v)")
126
+ CA_8_1_A = ("designating as contraventions offences created by any enactment, other "
127
+ "than offences for which an offender may be prosecuted only on indictment")
128
+ CR_S1 = ("An offence under a provision of an enactment set out in column I of the "
129
+ "Schedules is designated as a contravention")
130
+
131
+ # ---- the guidance the memo cites (chunk ids pinned in tests/test_transborder.py) ---
132
+ GUIDANCE_CURRENT = (
133
+ "ENF 5 (2025-02-20) Appendix F and ENF 6 (2025-02-20) Appendix A tabulate "
134
+ "A36(2.1) as 'FN only': the ID makes the deportation order under R229(1)(d.1), "
135
+ "the MD only for 'specific offences' under R228(1)(a.01) (enf-5-app-f-p2; "
136
+ "enf-6-app-a-p1, enf-6-app-a-p2). ENF 5 s. 7.4 lists 'Transborder criminality "
137
+ "[A36(2.1)]' among "
138
+ "the grounds proved on reasonable grounds to believe (enf-5-7-4; IRPA s. 33), "
139
+ "and ENF 5 s. 4 names the Minister of Public Safety as policy lead for A36(2.1) "
140
+ "(enf-5-4-p1).")
141
+ GUIDANCE_STALE = (
142
+ "Older guidance still describes the repealed paragraph: ENF 1 s. 3 (2013; "
143
+ "enf-1-3-p30) gives 'R19: Transborder Crime' for 'paragraph A36(2)(d)' with the "
144
+ "old list of five Acts (no Cannabis Act), and ENF 3 s. 10.8 (2022; "
145
+ "enf-3-10-8-p2) and the withdrawn ENF 2 (ATIP copy, enf-2-3-p12) say the "
146
+ "allegation 'may only be used' in a 'present tense' scenario -- the person "
147
+ "cannot later be found inadmissible for a past 'on entering Canada' offence "
148
+ "once they withdrew or left. That limit was written for former s. 36(2)(d); "
149
+ "whether it still states CBSA policy under s. 36(2.1) is NOT SETTLED here "
150
+ "(historical guidance -- confirm before relying on it).")
151
+
152
+ TABLE_HELD = (
153
+ "The IRPR s. 19(1)(c)-(g) table (the Firearms Act, Customs Act, Controlled "
154
+ "Drugs and Substances Act and Cannabis Act offences, by section and goods) is "
155
+ "HELD FOR REVIEW and is not in this deployment: CanLex does not say here "
156
+ "whether this offence is prescribed. Read IRPR s. 19(1)(c)-(g) with "
157
+ "canlex_get_section (act 'IRPR', section '19').")
158
+
159
+
160
+ # ---- helpers ------------------------------------------------------------------------
161
+
162
+ def _norm(text):
163
+ t = (text or "").lower().replace("’", "'").replace("é", "e").replace("à", "a")
164
+ t = re.sub(r"[^a-z0-9.\- ]", " ", t)
165
+ t = re.sub(r"\bthe\b", " ", t)
166
+ return " ".join(t.split())
167
+
168
+
169
+ # The spellings an Act's name carries on a record or in a client's words (the code
170
+ # audit of 2026-09-21, HELD): a parenthesised suffix ('(Canada)', '(CDSA)'), a
171
+ # chapter citation ('R.S.C. 1985, c. C-46', 'S.C. 2018, c. 16') and a trailing
172
+ # section ('s. 95', 'section 159(1)'). Before this any spelling the alias table did
173
+ # not hold exactly -- 'Criminal Code of Canada', 'Customs Act (Canada)' -- resolved
174
+ # to 'other' and the bottom line was a settled NOT A PRESCRIBED OFFENCE.
175
+ _ACT_CITE = re.compile(r"\b(?:r\.?s\.?c\.?|s\.?c\.?|l\.?r\.?c\.?|l\.?c\.?)\s*\(?\d{4}\)?"
176
+ r"(?:,?\s*(?:c|ch|chapter)\.?\s*[a-z0-9\-]+(?:\s*\(\s*\d+(?:st|nd|rd|th)"
177
+ r"\s+supp\.?\s*\))?)?", re.I)
178
+ # (the audit follow-up of 2026-09-21: 'subsection'/'paragraph' is no lift trigger of
179
+ # its own -- '..., section 95, subsection 1' read section 1 -- it rides on the section
180
+ # before it as 95(1))
181
+ _ACT_TRAILING_SECTION = re.compile(
182
+ r"[\s,]+(?:s(?:ection|ect\.|ec\.|s\.|\.)?|art(?:icle|\.)?)\s*"
183
+ r"(\d+(?:\.\d+)?(?:\s*\(\s*[0-9a-z.]+\s*\))*)"
184
+ # (a value is a digit run or one or two letters, never a word: the value class
185
+ # must not overlap the keywords, or a long tail of ', subsection 1' backtracks
186
+ # exponentially -- the review of the follow-up; at most four of them)
187
+ r"((?:[\s,]+(?:sub(?:section|s\.|-s\.)|par(?:agraph|a\.?|\.)|cl(?:ause|\.))\s*"
188
+ r"\(?\s*(?:\d[0-9.]*|[a-z]{1,2})\s*\)?){0,4})\s*$", re.I)
189
+ _ACT_SUBSECTION = re.compile(r"(?:sub(?:section|s\.|-s\.)|par(?:agraph|a\.?|\.)|cl(?:ause|\.))\s*"
190
+ r"\(?\s*(\d[0-9.]*|[a-z]{1,2})\s*\)?", re.I)
191
+ # the short codes match as whole tokens only; the names as word-bounded phrases
192
+ _ACT_CODES = {"cc", "ccc", "c-46", "irpa", "i-2.5", "lipr", "f-11.6", "c-52.6", "cdsa",
193
+ "c-38.8", "lrcdas", "c-24.5"}
194
+
195
+
196
+ def act_and_section(act):
197
+ """(act code, recognised, trailing section): resolve_act's answer and the
198
+ section a citation form carries after the Act ('Criminal Code s. 95' ->
199
+ ('C-46', True, '95')), or None when the words carry none."""
200
+ if not act or not act.strip():
201
+ return None, False, None
202
+ text = str(act)
203
+ section = None
204
+ m = _ACT_TRAILING_SECTION.search(text)
205
+ if m:
206
+ section = re.sub(r"\s+", "", m.group(1)) + "".join(
207
+ f"({s})" for s in _ACT_SUBSECTION.findall(m.group(2) or ""))
208
+ text = text[:m.start()]
209
+ text = re.sub(r"\([^)]*\)", " ", text) # '(Canada)', '(CDSA)', '(2nd Supp.)'
210
+ text = _ACT_CITE.sub(" ", text).replace("&", " and ")
211
+ n = _norm(text)
212
+ if not n:
213
+ return None, False, section
214
+ tokens = set(n.split())
215
+ for code, meta in R19_ACTS.items():
216
+ names = [_norm(meta["name"])] + [a for a in meta["aliases"] if a not in _ACT_CODES]
217
+ if any(re.search(r"(?<![a-z0-9])" + re.escape(name) + r"(?![a-z0-9])", n)
218
+ for name in names if name):
219
+ return code, True, section
220
+ for code, meta in R19_ACTS.items():
221
+ codes = {code.lower()} | {a for a in meta["aliases"] if a in _ACT_CODES}
222
+ if tokens & codes:
223
+ return code, True, section
224
+ return "other", True, section
225
+
226
+
227
+ def resolve_act(act):
228
+ """(act code, recognised) for an Act as the officer names it: a code of
229
+ R19_ACTS when it is one of the six Acts s. 19(1) names -- as a whole, or
230
+ inside a longer spelling ('Criminal Code of Canada', 'Customs Act
231
+ (Canada)', 'the Controlled Drugs & Substances Act (CDSA)', a chapter cite,
232
+ a trailing 's. N'); (None, False) when nothing was given; ('other', True)
233
+ for any other Act (the Excise Act, 2001, the Customs Tariff, a provincial
234
+ Act: genuinely not prescribed)."""
235
+ code, recognised, _ = act_and_section(act)
236
+ return code, recognised
237
+
238
+
239
+ _SEC = re.compile(r"^\s*(?:(?:sub)?s(?:ection|ect\.|ec\.|s\.|\.)?\s*)?"
240
+ r"(\d+(?:\.\d+)?)\s*((?:\(\s*[0-9a-z.]+\s*\)\s*)*)$", re.I)
241
+
242
+
243
+ def parse_section(text):
244
+ """('320.15', ['1']) for '320.15(1)', ('124', ['1', 'b']) for
245
+ 's. 124(1)(b)'; None when the text is not a section reference."""
246
+ if not text:
247
+ return None
248
+ m = _SEC.match(str(text).strip())
249
+ if not m:
250
+ return None
251
+ path = [p.strip().lower() for p in re.findall(r"\(\s*([0-9a-z.]+)\s*\)", m.group(2) or "")]
252
+ return m.group(1), path
253
+
254
+
255
+ def section_relation(given, key):
256
+ """'match' when the officer's section is the rule's provision or inside
257
+ it; 'partial' when the officer gave less than the rule needs ('320.15'
258
+ against '320.15(1)'); 'no' otherwise."""
259
+ kb = parse_section(key)
260
+ if not given or not kb:
261
+ return "no"
262
+ gb, gp = given
263
+ if gb != kb[0]:
264
+ return "no"
265
+ kp = kb[1]
266
+ n = min(len(gp), len(kp))
267
+ if gp[:n] != kp[:n]:
268
+ return "no"
269
+ return "match" if len(gp) >= len(kp) else "partial"
270
+
271
+
272
+ def section_label(sec):
273
+ if not sec:
274
+ return None
275
+ return sec[0] + "".join(f"({p})" for p in sec[1])
276
+
277
+
278
+ def load_table(path=None):
279
+ """The curated s. 19(1)(c)-(g) table, or None when it is absent (a
280
+ composed release ships without held files)."""
281
+ from canlex.config import DATA_DIR
282
+ path = path or (DATA_DIR / "curated" / TABLE_FILE)
283
+ try:
284
+ return json.loads(path.read_text(encoding="utf-8"))
285
+ except (OSError, ValueError):
286
+ return None
287
+
288
+
289
+ def mode_from_pairing(p):
290
+ """The Canadian offence's mode as the curated pairing records it
291
+ (summary_only / hybrid / a maximum), or None."""
292
+ if not p or p.get("equivalent") is False:
293
+ return None
294
+ if p.get("summary_only") is True:
295
+ return "summary-only"
296
+ if p.get("hybrid") is True:
297
+ return "hybrid"
298
+ if p.get("hybrid") is False and type(p.get("max_years")) is int:
299
+ return "indictable"
300
+ return None
301
+
302
+
303
+ def pairing_act(p):
304
+ """The one Act a pairing's verify rows cite, or None when they cite
305
+ several or none."""
306
+ acts = {v.get("act") for v in (p or {}).get("verify") or []}
307
+ return acts.pop() if len(acts) == 1 else None
308
+
309
+
310
+ # ---- contraventions (IRPA s. 36(3)(e)(i)) -------------------------------------------
311
+
312
+ def _schedule_act(chunk):
313
+ """The Act a Contraventions Regulations schedule is for: its title, or
314
+ (for a schedule the ingester titled only 'Schedule X') the first line
315
+ naming it."""
316
+ title = (chunk.get("marginal_note") or "").strip()
317
+ if title and not title.lower().startswith("schedule"):
318
+ return title
319
+ for line in (chunk.get("text") or "").splitlines():
320
+ line = line.strip()
321
+ if "|" in line: # a table row is never an Act's name
322
+ return ""
323
+ if line and not line.startswith("(Section") and line.lower() != "part i":
324
+ return line
325
+ return ""
326
+
327
+
328
+ def contravention_schedules(processed_dir=None):
329
+ """(the Acts the SOR-96-313 schedules are for, the corpus current_to date),
330
+ or (None, None) when the Regulations are not in the corpus."""
331
+ from canlex.config import DATA_DIR
332
+ path = (processed_dir or (DATA_DIR / "processed")) / "SOR-96-313.json"
333
+ try:
334
+ chunks = json.loads(path.read_text(encoding="utf-8"))
335
+ except (OSError, ValueError):
336
+ return None, None
337
+ acts, current, seen = [], None, set()
338
+ for c in chunks:
339
+ if c.get("lang", "en") != "en":
340
+ continue
341
+ current = current or c.get("current_to")
342
+ sec = str(c.get("section", ""))
343
+ # One Act per schedule, read from the schedule's FIRST chunk: a
344
+ # continuation chunk titled only 'Schedule X' starts with a table row.
345
+ if not sec.lower().startswith("schedule") or sec in seen:
346
+ continue
347
+ seen.add(sec)
348
+ a = _schedule_act(c)
349
+ if a and a not in acts and a.lower() != "[repealed]":
350
+ acts.append(a)
351
+ return acts, current
352
+
353
+
354
+ def contravention_check(act_code, schedules=None):
355
+ """{'status', 'text'} for the s. 36(3)(e)(i) carve-out: 'not-designated'
356
+ when no schedule of the Contraventions Regulations is for this Act,
357
+ 'check-schedule' when one is, 'not-checked' when the Regulations are not
358
+ in the corpus or the Act is unknown."""
359
+ acts, current = schedules if schedules is not None else contravention_schedules()
360
+ head = (f"Contraventions carve-out (IRPA s. 36(3)(e)(i)): '{S36_3_E}' "
361
+ f"'{S36_3_E_I}'.")
362
+ if acts is None:
363
+ return {"status": "not-checked",
364
+ "text": head + " The Contraventions Regulations are not in this "
365
+ "deployment's corpus, so this was not checked -- read "
366
+ "SOR-96-313 before relying on the allegation."}
367
+ if act_code not in R19_ACTS:
368
+ return {"status": "not-checked",
369
+ "text": head + " Not checked: the Act is not one IRPR s. 19(1) names."}
370
+ name = R19_ACTS[act_code]["name"]
371
+ if any(_norm(a) == _norm(name) for a in acts):
372
+ return {"status": "check-schedule",
373
+ "text": head + f" The Contraventions Regulations (SOR-96-313, current "
374
+ f"to {current}) have a schedule for the {name}: read it for "
375
+ "this provision -- an offence designated there cannot ground "
376
+ "s. 36(2.1)."}
377
+ text = (head + f" The Contraventions Regulations (SOR-96-313, current to {current}) "
378
+ f"designate offences by schedule ('{CR_S1}'), and none of their "
379
+ f"{len(acts)} schedules is for the {name}: no offence under it is "
380
+ "designated, so the carve-out does not apply.")
381
+ if act_code == "C-46":
382
+ text += (" (Contraventions Act s. 8(1)(a) also excludes 'offences for which an "
383
+ "offender may be prosecuted only on indictment'.)")
384
+ if act_code == "C-24.5":
385
+ text += (" A Cannabis Act ticket (ss. 51-52) is that Act's own procedure, not a "
386
+ "designation under the Contraventions Act, so s. 36(3)(e)(i) does not "
387
+ "reach it on its words; how a ticketable amount is treated at the "
388
+ "border is NOT SETTLED here.")
389
+ return {"status": "not-designated", "text": text}
390
+
391
+
392
+ # ---- the assessment -------------------------------------------------------------------
393
+
394
+ def _open_class(code, mode):
395
+ """(prescribed, how) for s. 19(1)(a)/(b): indictable and hybrid offences
396
+ are prescribed (hybrids deemed indictable by s. 19(2)); a summary-only
397
+ offence is not."""
398
+ para = R19_ACTS[code]["paragraph"]
399
+ name = R19_ACTS[code]["name"]
400
+ if mode == "indictable":
401
+ return True, f"IRPR s. {para}: 'any indictable offence under the {name}'"
402
+ if mode == "hybrid":
403
+ return True, (f"IRPR s. {para} with s. 19(2): '{R19_2}' -- a hybrid offence is "
404
+ "prescribed")
405
+ if mode == "summary-only":
406
+ return False, (f"IRPR s. {para} prescribes only indictable offences under the "
407
+ f"{name}; an offence that may only be prosecuted summarily is "
408
+ "not prescribed")
409
+ return None, None
410
+
411
+
412
+ def _md(code, sec, goods):
413
+ """(md, subparagraph, readings): whether IRPR s. 228(1)(a.01) lets the
414
+ Minister's delegate make the deportation order. md is None when the
415
+ officer's section or goods leave it open."""
416
+ rules = [r for r in MD_ORDERABLE if r[1] == code]
417
+ if not rules:
418
+ return False, None, []
419
+ if sec is None:
420
+ return None, None, [f"s. {k}" + (f" with goods: {r[3]}" if r[3] else "")
421
+ + f" (IRPR s. {r[0]})" for r in rules for k in r[2]]
422
+ partial = []
423
+ for sub, _, keys, cond in rules:
424
+ for k in keys:
425
+ rel = section_relation(sec, k)
426
+ if rel == "no":
427
+ continue
428
+ gm = ("match" if cond is None or goods == cond
429
+ else "partial" if goods is None else "no")
430
+ if rel == "match" and gm == "match":
431
+ return True, sub, []
432
+ if gm != "no":
433
+ partial.append(f"s. {k}" + (f" with goods: {cond}" if cond else "")
434
+ + f" (IRPR s. {sub})")
435
+ return (None if partial else False), None, partial
436
+
437
+
438
+ def _table_rows(table, code):
439
+ return [r for r in (table or {}).get("rows", []) if r.get("act") == code]
440
+
441
+
442
+ def _from_table(table, code, sec, goods):
443
+ """(prescribed, row, readings, needs) from the curated (c)-(g) table."""
444
+ rows = _table_rows(table, code)
445
+ if sec is None:
446
+ readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
447
+ + f": {'prescribed' if r.get('prescribed', True) else 'NOT prescribed'}"
448
+ + (f" (IRPR s. {r['paragraph']})" if r.get("paragraph") else "")
449
+ for r in rows]
450
+ return None, None, readings, [{"fact": "section", "parameter": "section",
451
+ "question": f"Which section of the "
452
+ f"{R19_ACTS[code]['name']} was contravened?"}]
453
+ hits = [(section_relation(sec, r["section"]), r) for r in rows]
454
+ exact = [r for rel, r in hits if rel == "match"]
455
+ partial = [r for rel, r in hits if rel == "partial"]
456
+ # A penalty section (Customs Act s. 160(1), CDSA s. 46) punishes the
457
+ # contravention of other provisions: the provision contravened decides.
458
+ punished = [r for r in rows if r.get("punished_under")
459
+ and section_relation(sec, r["punished_under"]) != "no"]
460
+ if punished and not exact and not partial:
461
+ readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
462
+ + (" (prescribed" + (f", IRPR s. {r['paragraph']})" if r.get("paragraph")
463
+ else ")")
464
+ if r.get("prescribed", True) else " (NOT prescribed)")
465
+ for r in punished]
466
+ return None, None, readings, [{
467
+ "fact": "section", "parameter": "section",
468
+ "question": f"s. {section_label(sec)} of the {R19_ACTS[code]['name']} punishes "
469
+ "contraventions of other provisions: give the provision "
470
+ "contravened (for example '12' for failing to report goods, "
471
+ "'156', '46.3(1)')."}]
472
+ if exact:
473
+ goods_rows = [r for r in exact if r.get("goods")]
474
+ if goods_rows:
475
+ chosen = [r for r in goods_rows if r["goods"] == goods]
476
+ if goods is None:
477
+ readings = [f"goods {r['goods']}: "
478
+ + ("prescribed" if r.get("prescribed", True) else "NOT prescribed")
479
+ + (f" (IRPR s. {r['paragraph']})" if r.get("paragraph") else "")
480
+ for r in goods_rows]
481
+ if not any(r["goods"] == "other" for r in goods_rows):
482
+ readings.append("any other goods: NOT prescribed under this provision")
483
+ return None, None, readings, [{
484
+ "fact": "goods", "parameter": "goods",
485
+ "question": "What were the goods? IRPR s. 19(1)(d)-(e) prescribe "
486
+ "this offence only for the goods classes listed."}]
487
+ if chosen:
488
+ r = chosen[0]
489
+ return bool(r.get("prescribed", True)), r, [], []
490
+ return False, None, [], []
491
+ r = exact[0]
492
+ return bool(r.get("prescribed", True)), r, [], []
493
+ if partial:
494
+ readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
495
+ + (" (prescribed)" if r.get("prescribed", True) else " (NOT prescribed)")
496
+ for r in partial]
497
+ return None, None, readings, [{"fact": "section", "parameter": "section",
498
+ "question": "Give the subsection or the provision "
499
+ "contravened: " + "; ".join(readings)}]
500
+ return False, None, [], []
501
+
502
+
503
+ def assess(act=None, section=None, mode=None, goods=None, status=None, offence=None,
504
+ pairings=None, table=None, match=None):
505
+ """The transborder result as a dict (see server.canlex_transborder).
506
+ pairings: the curated equivalency pairings (for the mode of a Criminal
507
+ Code / IRPA offence named in words); table: the curated (c)-(g) table or
508
+ None; match: screening.match_pairings (injected, so this module has no
509
+ matcher dependency)."""
510
+ person = status or "foreign-national"
511
+ code, recognised, lifted = act_and_section(act)
512
+ lifted_note = None
513
+ if lifted and not (section and str(section).strip()):
514
+ # 'Criminal Code s. 95' with no section given: the section rides on the Act
515
+ section, lifted_note = lifted, (f"Section '{lifted}' read from the act words "
516
+ f"('{act}'); give it as `section` to be sure.")
517
+ sec = parse_section(section)
518
+ out = {"person": person, "act": code if code != "other" else None,
519
+ "act_given": act, "act_name": R19_ACTS.get(code, {}).get("name"),
520
+ "section": section_label(sec), "section_given": section,
521
+ "mode": mode, "mode_source": "given" if mode else None,
522
+ "goods": goods, "paragraph": None, "prescribed": None,
523
+ "prescribed_how": None, "readings": [], "needs": [], "pairing": None,
524
+ "md_orderable": None, "md_provision": None, "md_readings": [],
525
+ "removal": None, "removal_provision": None, "table": "present" if table else "absent",
526
+ "notes": []}
527
+ if lifted_note:
528
+ out["notes"].append(lifted_note)
529
+ if section and sec is None:
530
+ out["notes"].append(f"'{section}' is not read as a section number (write it as "
531
+ "'95', '320.15(1)' or '124(1)(b)').")
532
+ # A Criminal Code / IRPA offence named only in words: the curated pairing
533
+ # (and its verified mode flags) may name the Act and the mode.
534
+ if offence and pairings and match and (code in (None, "C-46", "I-2.5")):
535
+ scored = match(offence, pairings)
536
+ if scored:
537
+ p = scored[0][1]
538
+ pact = pairing_act(p)
539
+ if pact in ("C-46", "I-2.5") and (code is None or code == pact):
540
+ out["pairing"] = p["id"]
541
+ if code is None:
542
+ code, out["act"] = pact, pact
543
+ out["act_name"] = R19_ACTS[pact]["name"]
544
+ out["notes"].append(f"Act read from the curated pairing '{p['id']}' "
545
+ f"({p['canadian_offence']}) -- confirm it.")
546
+ if mode is None and mode_from_pairing(p):
547
+ mode = out["mode"] = mode_from_pairing(p)
548
+ out["mode_source"] = f"pairing:{p['id']}"
549
+ if code is None:
550
+ out["needs"].append({"fact": "act", "parameter": "act",
551
+ "question": "Under which Act is the offence (Criminal "
552
+ "Code, IRPA, Firearms Act, Customs Act, CDSA, "
553
+ "Cannabis Act, or another)?"})
554
+ elif code == "other":
555
+ out["prescribed"] = False
556
+ out["prescribed_how"] = (
557
+ f"IRPR s. 19(1) prescribes offences under six Acts only -- the "
558
+ f"Criminal Code, IRPA, the Firearms Act, the Customs Act, the CDSA and "
559
+ f"the Cannabis Act -- and '{act}' is not read as one of them. If it is "
560
+ "one of the six, re-run with its name or code.")
561
+ elif R19_ACTS[code]["open"]:
562
+ out["paragraph"] = R19_ACTS[code]["paragraph"]
563
+ pres, how = _open_class(code, mode)
564
+ out["prescribed"], out["prescribed_how"] = pres, how
565
+ if pres is None:
566
+ name = R19_ACTS[code]["name"]
567
+ out["readings"] = [f"indictable or hybrid (s. 19(2) deems a hybrid "
568
+ f"indictable): prescribed under IRPR s. {out['paragraph']}",
569
+ "summary-only (may ONLY be prosecuted summarily): NOT "
570
+ "prescribed"]
571
+ out["needs"].append({"fact": "mode", "parameter": "mode",
572
+ "question": f"How may this {name} offence be "
573
+ "prosecuted: indictable, hybrid or "
574
+ "summary-only? (read the section's "
575
+ "punishment clause)"})
576
+ elif table is None:
577
+ out["paragraph"] = R19_ACTS[code]["paragraph"]
578
+ out["notes"].append(TABLE_HELD)
579
+ else:
580
+ pres, row, readings, needs = _from_table(table, code, sec, goods)
581
+ out["prescribed"], out["readings"] = pres, readings
582
+ out["needs"] += needs
583
+ if row:
584
+ out["paragraph"] = row.get("paragraph")
585
+ out["prescribed_how"] = row.get("note") or (
586
+ f"IRPR s. {row['paragraph']}" if row.get("paragraph") else None)
587
+ out["row"] = row.get("id")
588
+ elif pres is False:
589
+ out["prescribed_how"] = (f"not among the offences IRPR s. "
590
+ f"{R19_ACTS[code]['paragraph']} prescribes under the "
591
+ f"{R19_ACTS[code]['name']} (as CanLex tabulates "
592
+ "them, held for review)")
593
+ # The delegate's jurisdiction (s. 228(1)(a.01)) -- a rule on the section,
594
+ # whether or not the (c)-(g) table is present.
595
+ if code in R19_ACTS and out["prescribed"] is not False:
596
+ md, sub, mreadings = _md(code, sec, goods)
597
+ out["md_orderable"], out["md_readings"] = md, mreadings
598
+ if sub:
599
+ out["md_provision"] = f"IRPR s. {sub}"
600
+ if md is None:
601
+ fact = "goods" if (sec is not None and code == "C-52.6" and goods is None) else "section"
602
+ if not any(n["fact"] == fact for n in out["needs"]):
603
+ out["needs"].append({
604
+ "fact": fact, "parameter": fact,
605
+ "question": ("What were the goods? The delegate's order turns on them: "
606
+ if fact == "goods" else
607
+ "Give the section and subsection: the Minister's delegate "
608
+ "makes the order only for ")
609
+ + "; ".join(mreadings)})
610
+ if (code == "C-52.6" and sec and sec[0] == "160"
611
+ and not any(n["fact"] == "section" for n in out["needs"])):
612
+ out["needs"].append({"fact": "section", "parameter": "section",
613
+ "question": "Customs Act s. 160(1) punishes many "
614
+ "contraventions: give the provision "
615
+ "contravened ('12' for failing to report "
616
+ "goods, '156', or '159(1)' for smuggling)."})
617
+ if person == "permanent-resident":
618
+ out["branch"] = "not-reached-pr"
619
+ elif out["prescribed"] is True:
620
+ out["branch"] = "transborder-criminality"
621
+ elif out["prescribed"] is False:
622
+ out["branch"] = "not-prescribed"
623
+ elif code in R19_ACTS and not R19_ACTS[code]["open"] and table is None:
624
+ out["branch"] = "table-held"
625
+ else:
626
+ out["branch"] = "not-settled"
627
+ # The removal path is reported only once the offence is prescribed; while
628
+ # that is open the memo gives it conditionally.
629
+ if person == "foreign-national" and out["prescribed"] is True:
630
+ if out["md_orderable"] is True:
631
+ out["removal"], out["removal_provision"] = "md-deportation-order", out["md_provision"]
632
+ elif out["md_orderable"] is False:
633
+ out["removal"], out["removal_provision"] = "id-referral", "IRPR s. 229(1)(d.1)"
634
+ return out
635
+
636
+
637
+ # ---- the memo ---------------------------------------------------------------------
638
+
639
+ def _yn(v):
640
+ return {True: "YES", False: "NO", None: "NOT SETTLED"}[v]
641
+
642
+
643
+ def render(r, offence=None, trp_fee=None, family_line=None, contravention=None):
644
+ """The memo lines (no banner, no CANLEX_RESULT line) for an assess()
645
+ result. trp_fee: the IRPR s. 298 amount read from the corpus;
646
+ family_line: the s. 42 family-member line; contravention: a
647
+ contravention_check() result."""
648
+ lines = []
649
+ person = r["person"]
650
+ act_txt = (f"{r['act_name']} ({r['act']})" if r.get("act_name")
651
+ else (r.get("act_given") or "not given"))
652
+ facts = [f"Offence: {offence or 'not described'}",
653
+ f"Act: {act_txt}; section: {r['section'] or r.get('section_given') or 'not given'}",
654
+ f"Mode: {r['mode'] or 'not given'}"
655
+ + (f" (from the curated pairing '{r['mode_source'][8:]}' -- confirm it)"
656
+ if (r.get("mode_source") or "").startswith("pairing:") else ""),
657
+ f"Goods: {r['goods'] or 'not given'}",
658
+ f"Person: {person}"]
659
+ lines += ["\n".join(facts), ""]
660
+ lines += ["## Who s. 36(2.1) reaches",
661
+ f"IRPA s. 36(2.1): '{S36_2_1}.' It reaches a foreign national only, "
662
+ "for an offence committed ON ENTERING Canada; it needs no conviction "
663
+ "('committing'), and the facts are proved on reasonable grounds to "
664
+ "believe (IRPA s. 33). Former s. 36(2)(d) is repealed (2023, c. 32, "
665
+ "s. 53).",
666
+ ACQUITTAL_NOTE]
667
+ if person == "permanent-resident":
668
+ lines.append("PERMANENT RESIDENT: s. 36(2.1) does not reach a permanent "
669
+ "resident. A permanent resident is reached for this conduct only "
670
+ "under s. 36(1)(a) once convicted in Canada (a maximum of at least "
671
+ "10 years, or more than six months imposed) -- s. 36(2)(a) reaches "
672
+ "foreign nationals only. s. 37 (organized criminality) is a "
673
+ "separate ground.")
674
+ lines += ["", "## Is it a prescribed offence? (IRPR s. 19)",
675
+ f"Prescribed: {_yn(r['prescribed'])}"
676
+ + (f" -- {r['prescribed_how']}" if r.get("prescribed_how") else "")]
677
+ if r["readings"]:
678
+ lines.append("Each reading:")
679
+ lines += [f"- {x}" for x in r["readings"]]
680
+ for n in r["notes"]:
681
+ lines.append(f"NOTE: {n}")
682
+ if r.get("row"):
683
+ lines.append(f"(Row '{r['row']}' of the IRPR s. 19(1)(c)-(g) table -- HELD FOR "
684
+ "REVIEW.)")
685
+ lines += ["", "## Removal path"]
686
+ if person == "permanent-resident":
687
+ lines.append("Not reached: no s. 36(2.1) report lies against a permanent resident.")
688
+ elif r["prescribed"] is False:
689
+ lines.append("None on this ground: the offence is not prescribed.")
690
+ else:
691
+ cond = "" if r["prescribed"] else "IF the offence is prescribed (not settled above): "
692
+ if r["md_orderable"] is True:
693
+ lines.append(
694
+ f"{cond}The Minister's delegate makes a DEPORTATION ORDER ({r['md_provision']}): "
695
+ "the offence is one R228(1)(a.01) lists, so the report is not referred "
696
+ "to the Immigration Division -- but only where the report includes no "
697
+ f"other ground (R228(1): '{R228_OPENING}'), and not for a person who "
698
+ f"'{R228_4_A}' or who cannot appreciate the proceedings and is "
699
+ "unaccompanied (R228(4)).")
700
+ elif r["md_orderable"] is False:
701
+ lines.append(
702
+ f"{cond}Referred to the Immigration Division (IRPA s. 44(2)), which makes a "
703
+ "DEPORTATION ORDER (IRPR s. 229(1)(d.1): transborder criminality for "
704
+ f"'{R229_D1}'). The delegate cannot make the order: the offence is not "
705
+ "one R228(1)(a.01) lists.")
706
+ else:
707
+ lines.append(f"{cond}NOT SETTLED: the delegate makes the deportation order (IRPR "
708
+ "s. 228(1)(a.01)) only for the offences it lists; every other "
709
+ "prescribed offence is referred to the Immigration Division "
710
+ "(IRPR s. 229(1)(d.1)). The delegate's list for this Act:")
711
+ lines += [f"- {x}" for x in r["md_readings"]] or ["- (none)"]
712
+ lines.append("At a port of entry the alternatives are allowing the person to "
713
+ "withdraw the application to enter and leave (IRPR s. 42 -- once a "
714
+ "s. 44(1) report is being prepared, only if the Minister decides not "
715
+ "to make a removal order or not to refer the report, s. 42(2)) or a "
716
+ "temporary resident permit (below).")
717
+ lines += ["", "## Rehabilitation",
718
+ "None: s. 36(3)(c) cures only 'the matters referred to in paragraphs "
719
+ "(1)(b) and (c) and (2)(b) and (c)' -- s. 36(2.1) is not among them, so "
720
+ "neither deemed nor individual rehabilitation (IRPR ss. 17-18) reaches "
721
+ "transborder criminality."]
722
+ if contravention:
723
+ lines += ["", "## Contraventions", contravention["text"]]
724
+ lines += ["", "## Next steps"]
725
+ if person == "foreign-national" and r["prescribed"] is not False:
726
+ lead = "" if r["prescribed"] else "If the offence is prescribed (above): "
727
+ name = (offence or "the offence").strip()
728
+ cite = (f" contrary to section {r['section']} of the {r['act_name']}"
729
+ if r.get("section") and r.get("act_name") else
730
+ f" under the {r['act_name']}" if r.get("act_name") else "")
731
+ para = (f", an offence prescribed by paragraph {r['paragraph']} of the Immigration "
732
+ "and Refugee Protection Regulations" if r.get("paragraph")
733
+ and r["prescribed"] else "")
734
+ deemed = (" (a hybrid offence, deemed indictable by IRPR s. 19(2))"
735
+ if r.get("mode") == "hybrid" and r.get("paragraph") in ("19(1)(a)", "19(1)(b)")
736
+ else "")
737
+ lines.append(f"{lead}Draft allegation paragraph (s. 36(2.1) wording; ENF 5 s. 13 "
738
+ "sets out the report narrative): the person is a "
739
+ "foreign national inadmissible under IRPA subsection 36(2.1) for "
740
+ f"{ALLEGATION_2_1}, namely {name}{cite}{para}{deemed}, committed on "
741
+ "entering Canada on <date> at <port of entry>. Record in the "
742
+ "narrative the facts of entry, the evidence (statutory declaration, "
743
+ "notebook, seizure record) and the section contravened.")
744
+ lines.append("Temporary resident permit (IRPA s. 24): available at the officer's "
745
+ "discretion where entry is justified in the circumstances, subject "
746
+ "to the s. 24(3.1)-(5) bars; the processing fee under IRPR s. 298 is "
747
+ + (f"${trp_fee}" if trp_fee else "set out in IRPR s. 298") + ".")
748
+ elif person == "foreign-national":
749
+ lines.append("No s. 36(2.1) allegation: the offence is not prescribed. Consider "
750
+ "whether another ground applies (a conviction under s. 36(2)(a), "
751
+ "s. 41 non-compliance).")
752
+ if family_line and person == "foreign-national":
753
+ lines.append(family_line)
754
+ lines += ["", "## Guidance", GUIDANCE_CURRENT, GUIDANCE_STALE]
755
+ return lines
756
+
757
+
758
+ def bottom_line(r):
759
+ person = r["person"]
760
+ if person == "permanent-resident":
761
+ return ("NOT REACHED -- s. 36(2.1) does not reach a permanent resident; "
762
+ "s. 36(1)(a) only once convicted.")
763
+ if r["branch"] == "transborder-criminality":
764
+ how = ("the delegate makes the deportation order (" + r["md_provision"] + ")"
765
+ if r["md_orderable"] is True else
766
+ "referral to the Immigration Division (IRPR s. 229(1)(d.1))"
767
+ if r["md_orderable"] is False else
768
+ "the delegate or the Immigration Division, by section (not settled)")
769
+ return (f"TRANSBORDER CRIMINALITY (IRPA s. 36(2.1); IRPR s. {r['paragraph']}) "
770
+ f"if committed on entering Canada -- {how}; no rehabilitation; TRP "
771
+ "available.")
772
+ if r["branch"] == "not-prescribed":
773
+ return "NOT A PRESCRIBED OFFENCE -- no s. 36(2.1) ground on these facts."
774
+ if r["branch"] == "table-held":
775
+ return ("NOT ASSESSED -- the IRPR s. 19(1)(c)-(g) table is held for review; read "
776
+ "s. 19(1) for this offence.")
777
+ return "NOT SETTLED -- " + "; ".join(n["question"] for n in r["needs"]) if r["needs"] \
778
+ else "NOT SETTLED."