Beemer0 Beemer claude fable 5 commited on
Commit
0c19da8
·
0 Parent(s):

Squash history to reclaim LFS storage (1 GB cap); tree unchanged

Browse files

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,2 @@
 
 
 
1
+ data/processed/caselaw.json filter=lfs diff=lfs merge=lfs -text
2
+ data/processed/embeddings.npz filter=lfs diff=lfs merge=lfs -text
.github/workflows/ci.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ - name: Install minimal deps (no ONNX/model downloads)
24
+ run: pip install snowballstemmer pydantic "mcp>=1.0" beautifulsoup4 pypdf
25
+ - name: Offline test suite
26
+ run: python -m unittest discover -s tests -v
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ data/raw/
6
+ canlii_key.txt
7
+ data/citator_*.json
8
+ US-Dispositions-Reference.pdf
9
+ *.bundle
.mcp.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
9
+ }
10
+ }
11
+ }
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Application code, the processed corpus (section-chunk JSON), and the prebuilt
23
+ # semantic embeddings (embeddings.npz, committed via git-LFS). Shipping the
24
+ # embeddings as an artifact keeps the build fast: embedding ~20k chunks on the
25
+ # build host took ~33 min and was tripping Hugging Face's job timeout as the
26
+ # corpus grew. Regenerate with 'python -m canlex.embed' after any corpus change
27
+ # and commit the result.
28
+ COPY --chown=app:app canlex/ ./canlex/
29
+ COPY --chown=app:app data/processed/*.json ./data/processed/
30
+ COPY --chown=app:app data/processed/embeddings.npz ./data/processed/
31
+ # Curated commentary datasets (the canlex_us_disposition tool reads the
32
+ # structured source file directly, not just its processed chunks).
33
+ COPY --chown=app:app data/curated/ ./data/curated/
34
+
35
+ USER app
36
+ # CANLEX_LOG_DIR turns on the opt-in query log (query text + outcome only;
37
+ # loganalyze.py mines it for corpus gaps). On the free tier this path is
38
+ # ephemeral -- lost on rebuild/sleep -- so treat it as best-effort telemetry;
39
+ # the durable log is the local server's (see .mcp.json).
40
+ ENV HOME=/app \
41
+ HF_HOME=/app/.hf_cache \
42
+ CANLEX_HTTP=1 \
43
+ CANLEX_LOG_DIR=/tmp/canlex-logs \
44
+ PORT=7860 \
45
+ PYTHONUNBUFFERED=1
46
+
47
+ # Pre-fetch the embedder and cross-encoder models so the cache is baked into the
48
+ # image and the first request needs no network. The corpus is NOT re-embedded
49
+ # here -- the committed embeddings.npz is used as-is; only the bge query model
50
+ # (for embedding live queries) and the reranker are downloaded.
51
+ RUN python -c "from canlex.embed import Embedder; Embedder()" \
52
+ && python -c "from canlex.rerank import Reranker; Reranker()"
53
+
54
+ # From here on, model files are served from the baked cache, never fetched.
55
+ ENV HF_HUB_OFFLINE=1
56
+
57
+ EXPOSE 7860
58
+ CMD ["python", "-m", "canlex.server"]
README.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Ten 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; the ENF operational manuals; CBSA D-Memoranda and the AMPS
20
+ Master Penalty Document; the Charter; Treasury Board collective agreements;
21
+ NJC directives; ~110 leading decisions; delegation instruments;
22
+ benefit-plan booklets; and CanLex's curated commentary.
23
+ - **canlex_get_section** — fetch one provision verbatim by Act and section
24
+ (accepts 's. 34', '34(1)(c)', 'Schedule I').
25
+ - **canlex_us_disposition** — step 1 of the IRPA s. 36 screening chain: is a
26
+ US disposition a "conviction"? 51-jurisdiction state-by-state dataset.
27
+ - **canlex_us_equivalency** — step 2: the Canadian equivalent offence, its
28
+ verified maximum penalty, and the inadmissibility branch.
29
+ - **canlex_rehabilitation** — step 3: deemed rehabilitation / eligibility to
30
+ apply, under IRPA s. 36(3)(c) and IRPR ss. 17–18.1.
31
+ - **canlex_list_acts** — the corpus inventory and its currency.
32
+ - **canlex_whats_new** — provisions amended or newly in force since a date.
33
+ - **canlex_citing** — reverse cross-references: what cites a given section.
34
+ - **canlex_toc** — an Act's table of contents (Parts and marginal notes).
35
+ - **canlex_case** — live CanLII case citator (citation graph by neutral
36
+ citation or URL).
37
+
38
+ The server also publishes an MCP prompt (`us_record_screening`, the guided
39
+ three-step screening flow) and resources (the corpus inventory and the
40
+ conviction/equivalency methodology texts) that any MCP client can use.
41
+
42
+ The labour side now carries the full union stack alongside the FB collective
43
+ agreement: the CIU National By-Laws (EN/FR), CIU Regulations, Branch Rules
44
+ and local modifications, the Branch Elections Kit, the PSAC Constitution,
45
+ the union's member guides, and every published site VSSA — all bannered as
46
+ bargaining-agent documents, distinct from employer policy.
47
+
48
+ Retrieval runs fully locally inside the container and needs no API key. The
49
+ optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
50
+ variable. A weekly scheduled staleness check watches upstream sources for
51
+ drift; see RUNBOOK.md for the maintainer's refresh choreography.
52
+
53
+ Add this Space's `/mcp` URL as a custom connector in any Claude client, or
54
+ point any MCP-capable agent (Gemini in Antigravity, etc.) at it. A companion
55
+ Gradio webapp (`webapp/`, deployed as its own Space) gives the same corpus a
56
+ Gemini-powered agentic chat front-end.
RUNBOOK.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CanLex maintainer runbook
2
+
3
+ Everything a future maintainer needs that the code alone does not say.
4
+
5
+ ## The refresh choreography
6
+
7
+ Order matters — chunks feed embeddings feed the eval:
8
+
9
+ ```
10
+ py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
11
+ py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
12
+ py -m canlex.pdi # IRCC PDI trees (TR / refugee / standard /
13
+ # identity / PR-card; capped BFS, drops logged)
14
+ py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
15
+ py -m canlex.dmemo [--fr] # CBSA D-Memoranda (EN; --fr = Mémorandums D)
16
+ py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
17
+ py -m canlex.amps # AMPS Master Penalty Document
18
+ py -m canlex.charter # Charter / Constitution HTML
19
+ py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
20
+ py -m canlex.delegation # CBSA/IRCC delegation instruments
21
+ py -m canlex.directive [--fr] # NJC directives (EN; --fr = éditions françaises)
22
+ py -m canlex.agreement # FB collective agreement
23
+ py -m canlex.benefits # benefit-plan booklets
24
+ py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
25
+ py -m canlex.commentary # curated datasets -> commentary chunks
26
+ py -m canlex.embed # re-embed EVERYTHING (run after ANY of the above)
27
+ py -m unittest discover -s tests # all offline
28
+ py -m canlex.eval # gate: no metric below the last committed run
29
+ ```
30
+
31
+ `canlex.embed` is mandatory after any chunk change: the index zero-fills
32
+ vectors for chunks missing from embeddings.npz and quietly degrades them to
33
+ BM25-only (a stderr warning is the only signal).
34
+
35
+ ### The corpus-write guard
36
+
37
+ Every ingester above rebuilds its whole processed file from a fresh scrape, so
38
+ an upstream page that changes shape turns into data loss at the write. All of
39
+ them now go through `_common.write_corpus`, which **refuses to write a corpus
40
+ below 80% of the stored one** and makes the process exit non-zero:
41
+
42
+ ```
43
+ REFUSING to write: 0 chunks would replace 1870 already in dmemos.json. ...
44
+ ```
45
+
46
+ That is not a transient error — it means the scrape broke. Diagnose it, don't
47
+ retry. Once the drop is understood and intended (a source genuinely shrank),
48
+ re-run the same module with `--allow-shrink`. Because the runbook chains these
49
+ as `py -m canlex.X && py -m canlex.embed`, a refusal now correctly stops the
50
+ embed instead of propagating a gutted corpus.
51
+
52
+ Most ingesters also **preserve on failure**: an item that errors keeps its
53
+ last-good chunks (`_common.preserve_dropped`), restricted to items that
54
+ actually failed, so anything removed on purpose stays gone. Deliberate
55
+ exception: `enf.py` treats an HTTP 404 as IRCC retiring a chapter — its
56
+ documented policy — so a 404 drops the chapter while any other error preserves
57
+ it. Writes are atomic (tmp + `os.replace`): a killed run cannot leave truncated
58
+ JSON that would read as an empty corpus and disable the guard next time.
59
+
60
+ ## Deploys
61
+
62
+ - **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
63
+ The Space rebuilds automatically. `embeddings.npz` ships in-repo via
64
+ git-LFS — a fresh clone needs `git lfs install`.
65
+ - **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
66
+ `webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
67
+ `webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
68
+ it behind PRO for Docker/Gradio Spaces; git pushes work on the free tier.
69
+
70
+ ## Secrets and variables (set in each Space's settings)
71
+
72
+ | Where | Name | Purpose |
73
+ |---|---|---|
74
+ | MCP Space | `CANLII_API_KEY` | optional, enables canlex_case |
75
+ | Webapp | `GEMINI_API_KEY` | required |
76
+ | Webapp | `CANLEX_WEB_AUTH` | required (`user:pass` per line); the app refuses to start without it |
77
+ | Webapp | `CANLEX_GEMINI_MODEL` | optional; the deployed Space sets `gemini-2.5-pro` (code default is flash) |
78
+
79
+ Locally, the CanLII key lives in `canlii_key.txt` (gitignored — never
80
+ commit it).
81
+
82
+ ## The staleness watch
83
+
84
+ `py -m canlex.refresh` (alias `--check`) diffs upstream: Justice Laws
85
+ consolidation dates, CBSA/IRCC delegation instruments, NJC directive dates,
86
+ the newest per-memo `LAST_UPDATED` in the D-memo datasource, curated-dataset
87
+ age (>180 days → AGING) and curated-vs-chunk regen drift. Non-zero exit =
88
+ something drifted.
89
+ A scheduled task runs it Mondays 03:01 local — if this machine is off, the
90
+ check silently doesn't happen that week. Note: network fetch errors on the
91
+ legislation rows DO fail the check (exit 1) so a broken watch can't pass
92
+ silently.
93
+
94
+ `_PENDING_BILLS` in canlex/refresh.py names bills heading into force that
95
+ affect corpus Acts — edit that list when a new one appears (source-code edit,
96
+ deliberate). A bill stays listed after its in-force date until Justice Laws
97
+ consolidates it: C-16 came into force 2026-07-18 but the 2026-06-14
98
+ consolidation predates it, so the coercive-control provisions are NOT in the
99
+ corpus yet and the notice correctly keeps printing.
100
+
101
+ ## The CBSA D-memo index (rebuilt 2026-07-10)
102
+
103
+ CBSA replaced the static index with a WET DataTable: the page ships **no memo
104
+ links at all**, the rows come from `dmemoranda-datasource-eng.json` beside it.
105
+ `dmemo.memo_urls` reads that datasource and falls back to scraping the page
106
+ only if it disappears. Two consequences worth knowing:
107
+
108
+ - Cancelled memos (`STATUS: "Cancelled"`, 34 of 398) are skipped — retired
109
+ guidance must not retrieve as current. D10-14-48 left the corpus this way.
110
+ - Eight memos are listed Active but their pages 404 (D8-2-2, D8-9-3, D10-2-3,
111
+ D10-14-3, D10-14-18, D10-14-65, D10-15-16, D10-15-25) — CBSA's own broken
112
+ links. `dmemo.preserve_failed` re-attaches the last-good chunks of any memo
113
+ that fails to fetch, so a 404 (or a dropped connection) can no longer delete
114
+ still-in-force guidance. The two D8s were never ingestable and are absent.
115
+
116
+ This is the incident the write guard above exists for: the first run after the
117
+ rebuild scraped zero memos and wrote an empty dmemos.json straight over 1,870
118
+ good chunks, recovered from git. Every ingester now carries the guard.
119
+
120
+ ## Curated-dataset review protocol
121
+
122
+ `data/curated/us_dispositions.json` and `us_equivalency.json` are the only
123
+ hand-maintained legal content. To change them: edit → bump `"reviewed"` →
124
+ `py -m canlex.commentary` → `py -m canlex.embed` → eval → deploy. The refresh
125
+ check flags any drift between the curated `reviewed` date and the rendered
126
+ chunks. Provenance and the verbatim-verification log live inside the JSON
127
+ files themselves; keep them current.
128
+
129
+ ## Backups
130
+
131
+ The HF Space remote is also production. A full-history bundle lives at
132
+ `C:\projects\CanLex-backup.bundle` (recreate: `git bundle create
133
+ ../CanLex-backup.bundle --all`). For a real offsite mirror, create a private
134
+ GitHub repo and:
135
+
136
+ ```
137
+ git remote add mirror https://github.com/<you>/CanLex.git
138
+ git push mirror --all
139
+ ```
140
+
141
+ (GitHub needs git-lfs enabled for embeddings.npz, or add it to a
142
+ `.lfsconfig`-excluded push.)
143
+
144
+ ## Known gaps / parked
145
+
146
+ - ENF coverage (researched and URL-verified 2026-07-22): ENF 20 Detention
147
+ and the current ENF 3 edition live under non-standard filenames — handled
148
+ by `_URL_OVERRIDES` in canlex/enf.py (the old enf03-eng.pdf still serves a
149
+ STALE 2015-era copy; never remove the override). ENF 7 was never publicly
150
+ released; ENF 13/16/25 never existed; ENF 9 (Judicial Reviews) and ENF 11
151
+ (Verifying Departure) are retired with no successor (Wayback copies exist
152
+ but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
153
+ replaced by the PDI section at
154
+ operational-bulletins-manuals/permanent-residence/card.html — ingested by
155
+ `canlex.pdi` (the `pdi-prcard` set). ENF 2 (Evaluating Inadmissibility)
156
+ and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
157
+ ATIP-released copies hosted by a private law firm — NOT ingested pending
158
+ the maintainer's call on unofficial sources; the Tran PDI page now comes
159
+ in with the whole `standard-requirements` tree and partially covers
160
+ ENF 2's ground.
161
+ - PDI scope: `canlex.pdi` crawls six category trees breadth-first
162
+ (`_MAX_DEPTH` 2, `_MAX_PAGES` 160 per tree). Deliberately out of scope:
163
+ citizenship, fees, office service delivery, and the year-by-year
164
+ operational-bulletin archives (mostly expired instructions). If a tree
165
+ ever hits the page cap the run PRINTS how many in-scope pages it dropped
166
+ — raise `_MAX_PAGES` rather than letting the silence read as coverage.
167
+ - CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
168
+ 2026-07-22): open the URL a missing-file stub prints in a real browser,
169
+ copy the page text to the named `data/raw/caselaw/*.txt`, re-run
170
+ `py -m canlex.caselaw && py -m canlex.embed`.
171
+ - Dag (FCA) parses to 2 chunks because it genuinely is a 9-paragraph bench
172
+ judgment — correct, not a bug.
173
+ - A weekly citator sweep over the ~110 corpus decisions (negative-treatment
174
+ detection) was considered and deliberately skipped: ~6 minutes of
175
+ rate-limited CanLII API per run for marginal signal. Run `canlex_case` on
176
+ a decision before relying on it instead.
177
+ - French support is now phase 3: the full parallel French legislation corpus
178
+ is ingested (`--fr`; lang='fr' chunks, '-fr-' id infix, 'art.'/'annexe'
179
+ citations). Retrieval is language-routed: English queries never see French
180
+ chunks; French queries (detected by accents or French function words)
181
+ search both languages. get_section/xref/list_acts stay English-canonical.
182
+ Phase 2 (a multilingual embedder) was tried and REJECTED 2026-07-22 —
183
+ regressed every slice and broke confidence calibration; don't retry
184
+ without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
185
+ published bilingually) is the remaining bilingual frontier.
186
+ - ENF 2 and ENF 14/OP 19 are both ingested from their ATIP releases
187
+ (authorized 2026-07-23, bannered UNOFFICIAL on every chunk). ENF 14's
188
+ image-scan PDF was recovered by OCR (pymupdf rasterization + Windows OCR;
189
+ cached at data/raw/enf/enf14-ocr.txt — enf.py auto-falls-back to
190
+ `*-ocr.txt` when a PDF extracts under 500 chars).
191
+ - Typo robustness, two layers: unknown STEMS get the trigram bridge in
192
+ `index._fuzzy_term` (BM25 recall only), and misspelled WORDS are repaired
193
+ by `index._correct_query` (trigram candidates, Damerau-Levenshtein <= 2,
194
+ shared 2-char prefix, frequency floor 5 so OCR debris can't win) -- the
195
+ repaired text feeds the semantic embedder and the reranker. Ultra-rare
196
+ stems (df <= 2) get the correction APPENDED, never substituted, so rare
197
+ legitimate terms keep their recall. The `typo` eval slice tracks it.
198
+ - Bilingual guidance: the D-memos and NJC directives now carry their French
199
+ editions (`--fr` modes; dmemos-fr.json / directives-fr.json) alongside
200
+ the French legislation, all lang-routed. Remaining English-only: case
201
+ law, ENF/AMPS/PDI, benefits booklets, and the curated commentary.
202
+
203
+ ## Two retrieval invariants worth not re-breaking (2026-07-29)
204
+
205
+ Ingesting the French guidance and the wider PDI trees exposed both of these.
206
+ Each was measured question-by-question against the previous corpus, not
207
+ guessed at — the harness is a per-question rank dump over two corpus
208
+ variants, which is how a 5-question shuffle gets attributed instead of
209
+ argued about.
210
+
211
+ - **BM25 collection statistics are language-scoped.** `_build_bm25` keeps
212
+ `idf`/`avgdl` (whole corpus) AND `idf_en`/`avgdl_en` (English scope);
213
+ every English query scores with the English pair. An English query can
214
+ never retrieve the French twins, but a single corpus-wide idf let those
215
+ unreachable chunks set the rarity of every shared token (numbers, act
216
+ codes, folded cognates) and drag the average length: adding the French
217
+ D-memos and directives alone moved legacy Hit@1 0.767 → 0.750 without a
218
+ single French chunk being retrievable. French queries still use the
219
+ whole-corpus pair — they search both languages.
220
+ - **The diversity cap keys memoranda on the parent DOCUMENT.** `_source_key`
221
+ used `section` for every memorandum, which is the document identity only
222
+ for the flat families (`_FLAT_MEMO_CODES`: one act_code across ~360
223
+ D-memos / ~1,000 AMPS contraventions). ENF chapters, PDI pages and IRB
224
+ guidelines carry a per-document act_code and use `section` to subdivide
225
+ it, so each ENF section and each PDI page was its own "source" and
226
+ neither RECALL_CAP nor SOURCE_CAP ever bound: the ~150-page PDI refugee
227
+ tree filled the candidate pool and pushed IRPA s. 112 out of the top 20
228
+ of "what is a pre-removal risk assessment" entirely. If a new corpus
229
+ family lands, check which field carries its document identity before
230
+ trusting the caps.
canlex/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """CanLex - Canadian legal retrieval for Claude."""
canlex/_common.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ import urllib.request
17
+
18
+ # The browser User-Agent used for government sites whose CDN denylists obvious
19
+ # non-browser clients (the pages are public; politeness comes from caching and
20
+ # the pauses below). The polite UA is for hosts that accept ordinary clients.
21
+ BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
23
+ POLITE_UA = "CanLex/0.1"
24
+
25
+ # Month-name lookup for parsing prose dates ("June 21, 2026").
26
+ MONTHS = {m: i for i, m in enumerate(
27
+ ["january", "february", "march", "april", "may", "june", "july", "august",
28
+ "september", "october", "november", "december"], start=1)}
29
+
30
+
31
+ def norm_ws(text):
32
+ """Collapse all whitespace to single spaces and trim.
33
+
34
+ \\s in a Python 3 str pattern already matches NBSP, en/em/thin spaces and
35
+ friends, so no pre-replacement is needed (several old copies carried a
36
+ redundant .replace("\\xa0", " ")).
37
+ """
38
+ return re.sub(r"\s+", " ", text or "").strip()
39
+
40
+
41
+ def fetch_cached(url, dest, *, ua=BROWSER_UA, timeout=60, pause=0.5,
42
+ force=False, powershell=False):
43
+ """Download a URL to `dest` once and reuse the cached bytes thereafter.
44
+
45
+ powershell=True routes the request through PowerShell's (.NET) HTTP stack,
46
+ which canada.ca accepts while blocking Python's client at the TLS layer.
47
+ `pause` is a politeness sleep after an actual download (0 to skip);
48
+ `timeout` applies to the urllib path (the PowerShell path keeps the fixed
49
+ 180s subprocess budget every prior copy used).
50
+ """
51
+ if dest.exists() and not force:
52
+ return dest.read_bytes()
53
+ dest.parent.mkdir(parents=True, exist_ok=True)
54
+ if powershell:
55
+ command = (f"Invoke-WebRequest -Uri '{url}' -OutFile '{dest}' "
56
+ f"-UseBasicParsing -UserAgent '{ua}'")
57
+ subprocess.run(
58
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
59
+ check=True, capture_output=True, timeout=180)
60
+ else:
61
+ req = urllib.request.Request(url, headers={"User-Agent": ua})
62
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
63
+ dest.write_bytes(resp.read())
64
+ if pause:
65
+ time.sleep(pause)
66
+ return dest.read_bytes()
67
+
68
+
69
+ def split_lines(text, target=1800, fold_stubs=False):
70
+ """Split text into ~target-sized pieces at line boundaries.
71
+
72
+ fold_stubs=True additionally folds a small (<200-char) leading or trailing
73
+ remnant into its neighbour so a section never yields a stub piece -- the
74
+ behaviour ingest.py and benefits.py always had. The other callers
75
+ deliberately keep fold_stubs=False: enabling it would change their chunk
76
+ boundaries, which is an eval-gated change, not a refactor.
77
+ """
78
+ if len(text) <= target:
79
+ return [text]
80
+ pieces, buf, size = [], [], 0
81
+ for line in text.split("\n"):
82
+ if size + len(line) > target and buf:
83
+ pieces.append("\n".join(buf))
84
+ buf, size = [], 0
85
+ buf.append(line)
86
+ size += len(line) + 1
87
+ if buf:
88
+ pieces.append("\n".join(buf))
89
+ if fold_stubs:
90
+ if len(pieces) > 1 and len(pieces[-1]) < 200:
91
+ pieces[-2] = pieces[-2] + "\n" + pieces[-1]
92
+ pieces.pop()
93
+ if len(pieces) > 1 and len(pieces[0]) < 200:
94
+ pieces[1] = pieces[0] + "\n" + pieces[1]
95
+ pieces.pop(0)
96
+ return pieces
97
+
98
+
99
+ # Every ingester rebuilds its whole processed file from a fresh scrape, so an
100
+ # upstream page that changes shape -- or a run that loses its network halfway --
101
+ # shows up as a collapse in chunk count, and the write turns that into data
102
+ # loss. On 2026-07-27 CBSA's rebuilt D-memo index scraped to zero memos and the
103
+ # write replaced 1,870 good chunks with an empty list; one line of stdout was
104
+ # the only warning. Refuse to write below this share of what is already stored.
105
+ CORPUS_SHRINK_GUARD = 0.8
106
+
107
+
108
+ def stored_chunks(path):
109
+ """The chunks already written to `path`, or [] if absent or unreadable."""
110
+ if not path.exists():
111
+ return []
112
+ try:
113
+ return json.loads(path.read_text(encoding="utf-8"))
114
+ except (ValueError, OSError):
115
+ return []
116
+
117
+
118
+ def safe_to_write(new_count, old_count, ratio=CORPUS_SHRINK_GUARD):
119
+ """False when writing `new_count` chunks would gut an existing corpus.
120
+
121
+ Pure, for tests. The write is the irreversible step, so it is the one to
122
+ gate -- a first-ever run (old_count 0) is always allowed.
123
+ """
124
+ return old_count == 0 or new_count >= old_count * ratio
125
+
126
+
127
+ def preserve_dropped(chunks, stored, *, key=lambda c: c["section"], only=None):
128
+ """Re-attach the last-good chunks of items this run failed to produce.
129
+
130
+ `key` maps a chunk to the item it belongs to; `only` restricts preservation
131
+ to a set of item identities (pass the ones that actually errored, so items
132
+ dropped ON PURPOSE -- cancelled memos, retired manuals -- are not
133
+ resurrected). Returns (chunks, preserved identities).
134
+ """
135
+ def identity(chunk):
136
+ # A chunk written by an older schema may not carry the identity field
137
+ # at all. It simply is not preservable -- but crashing here would take
138
+ # down the rebuild at the exact moment the guard exists to protect it.
139
+ try:
140
+ return key(chunk)
141
+ except (KeyError, IndexError, TypeError):
142
+ return None
143
+
144
+ fresh = {identity(c) for c in chunks}
145
+ by_item = {}
146
+ for chunk in stored:
147
+ item = identity(chunk)
148
+ if item is not None:
149
+ by_item.setdefault(item, []).append(chunk)
150
+ dropped = sorted(item for item in by_item
151
+ if item not in fresh and (only is None or item in only))
152
+ for item in dropped:
153
+ chunks.extend(by_item[item])
154
+ return chunks, dropped
155
+
156
+
157
+ def write_corpus(path, chunks, *, indent=2, allow_shrink=False,
158
+ ratio=CORPUS_SHRINK_GUARD):
159
+ """Write `chunks` to `path`, unless that would gut the stored corpus.
160
+
161
+ Returns True if written, False if refused -- callers exit non-zero on False
162
+ so a broken scrape fails loudly instead of quietly emptying a file.
163
+ """
164
+ stored = stored_chunks(path)
165
+ if not allow_shrink and not safe_to_write(len(chunks), len(stored), ratio):
166
+ print(f" REFUSING to write: {len(chunks)} chunks would replace "
167
+ f"{len(stored)} already in {path.name}. The upstream source "
168
+ f"likely changed shape -- check the scrape before writing "
169
+ f"(re-run with --allow-shrink once the drop is understood).")
170
+ return False
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ # Write-then-rename, not write-in-place: a run killed mid-write (Ctrl-C on
173
+ # the scheduled refresh, a full disk) would otherwise leave truncated JSON
174
+ # behind, and stored_chunks reads unparseable as empty -- which disables
175
+ # the guard above on the very next run, when it is needed most.
176
+ tmp = path.with_name(path.name + ".tmp")
177
+ tmp.write_text(json.dumps(chunks, ensure_ascii=False, indent=indent),
178
+ encoding="utf-8")
179
+ os.replace(tmp, path)
180
+ return True
181
+
182
+
183
+ def uniquify_ids(chunks):
184
+ """Rename duplicate chunk ids in place (second occurrence -> id-b, -c...).
185
+
186
+ Duplicate ids silently orphan chunks downstream: the embeddings loader
187
+ keys rows by id, so one twin gets vectors and the other degrades to
188
+ BM25-only with nothing but a stderr note. Generators whose source
189
+ numbering can repeat (a manual's TOC line and body heading both reading
190
+ '5.1'; delegation items re-using an instrument number) run their output
191
+ through this before writing."""
192
+ seen = {}
193
+ for c in chunks:
194
+ n = seen.get(c["id"], 0)
195
+ seen[c["id"]] = n + 1
196
+ if n:
197
+ c["id"] = f"{c['id']}-{chr(ord('a') + n)}"
198
+ return chunks
canlex/agreement.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ },
28
+ }
29
+
30
+ _SKIP_HEADINGS = {"table of contents", "note to readers", "page details",
31
+ "on this page"}
32
+ _CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
33
+ _ARTICLE = re.compile(r"Article\s+(\S+?)\s*[:–-]\s*(.+)", re.I)
34
+
35
+
36
+ def _fetch(url, dest, force=False):
37
+ """Download a page. canada.ca blocks Python's HTTP client at the TLS layer,
38
+ so fetch via PowerShell's (.NET) HTTP stack, which the site accepts."""
39
+ return fetch_cached(url, dest, force=force, powershell=True)
40
+
41
+
42
+ _PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
43
+
44
+
45
+ def _split(text, target=_PIECE_TARGET):
46
+ """Split long text into ~target-sized pieces at line boundaries."""
47
+ return split_lines(text, target)
48
+
49
+
50
+ def _block_text(heading):
51
+ """Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
52
+ lines = []
53
+ for sib in heading.find_next_siblings():
54
+ if sib.name in ("h2", "h3"):
55
+ break
56
+ if sib.name in ("ul", "ol"):
57
+ for li in sib.find_all("li", recursive=False):
58
+ item = _norm(li.get_text(" ", strip=True))
59
+ if item:
60
+ lines.append(f"- {item}")
61
+ elif sib.name in _CONTENT_TAGS:
62
+ text = _norm(sib.get_text(" ", strip=True))
63
+ if text:
64
+ lines.append(text)
65
+ return "\n".join(lines)
66
+
67
+
68
+ def parse_agreement(html, code):
69
+ """Parse a collective agreement page into one chunk per Article / Appendix."""
70
+ meta = AGREEMENTS[code]
71
+ soup = BeautifulSoup(html, "html.parser")
72
+ main = soup.find("main")
73
+ if main is None:
74
+ return []
75
+
76
+ # The first <time datetime> is the agreement's expiry; the dateModified one
77
+ # is the page's date.
78
+ expiry = ""
79
+ for t in main.find_all("time"):
80
+ if t.get("datetime") and t.get("property") != "dateModified":
81
+ expiry = _norm(t.get("datetime"))
82
+ break
83
+
84
+ for tag in main.find_all(["section", "div"]):
85
+ tag.unwrap() # flatten so each heading and its content become siblings
86
+
87
+ chunks = []
88
+ current_part = ""
89
+ sec_idx = 0
90
+ for h in main.find_all(["h2", "h3"]):
91
+ if "wb-inv" in (h.get("class") or []):
92
+ continue
93
+ text = _norm(h.get_text(" ", strip=True)).lstrip("*").strip()
94
+ if not text or text.lower() in _SKIP_HEADINGS:
95
+ continue
96
+ if h.name == "h2":
97
+ current_part = text
98
+ body = _block_text(h)
99
+ if not body:
100
+ continue
101
+
102
+ article = _ARTICLE.match(text)
103
+ if article:
104
+ number = article.group(1).rstrip(":.")
105
+ note = article.group(2).strip()
106
+ citation = f"{meta['cite']}, Article {number}"
107
+ elif re.match(r"Appendix\b", text, re.I):
108
+ number, note = text, ""
109
+ citation = f"{meta['cite']}, {text}"
110
+ else:
111
+ # Memoranda of Agreement/Understanding have long titles: keep them in
112
+ # the note only, so the rendered header does not repeat them.
113
+ number, note = "", text
114
+ citation = meta["cite"]
115
+
116
+ sec_idx += 1
117
+ # Split a long article so each piece fits the embed window; pieces share
118
+ # the base id and take a '-p<k>' suffix so the diversity cap groups them.
119
+ pieces = _split(body)
120
+ for k, piece in enumerate(pieces, start=1):
121
+ base = f"agreement-{code}-{sec_idx}"
122
+ chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
123
+ pnote = note if len(pieces) == 1 else f"{note} (part {k})".strip()
124
+ chunks.append({
125
+ "id": chunk_id,
126
+ "doc_type": "agreement",
127
+ "act_code": code,
128
+ "act_short": meta["short"],
129
+ "act_name": meta["name"],
130
+ "section": number,
131
+ "marginal_note": pnote,
132
+ "part": current_part if h.name == "h3" else "",
133
+ "division": "",
134
+ "heading": "",
135
+ "text": piece,
136
+ "history": "",
137
+ "last_amended": "",
138
+ "current_to": expiry,
139
+ "citation": citation,
140
+ "source_url": meta["url"],
141
+ })
142
+ return chunks
143
+
144
+
145
+ def preserve_failed(chunks, failures, stored):
146
+ """Re-attach the last-good chunks of agreements this run could not rebuild.
147
+
148
+ Ingestion is a full rebuild, so an agreement whose page fails to fetch --
149
+ or whose canada.ca layout changes so that nothing parses out of it --
150
+ disappears from the corpus. An agreement that is still in force is better
151
+ served from the copy we already had than not at all. Restricted to the
152
+ codes that actually failed, so one removed from AGREEMENTS on purpose is
153
+ not resurrected. Returns (chunks, preserved codes).
154
+ """
155
+ failed = {code for code, _why in failures}
156
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
157
+ only=failed)
158
+
159
+
160
+ def ingest(force=False, allow_shrink=False):
161
+ all_chunks, failures = [], []
162
+ for code, meta in AGREEMENTS.items():
163
+ print(f"Ingesting {code} ({meta['short']})...")
164
+ try:
165
+ html = _fetch(meta["url"], AGREEMENT_DIR / f"{code}.html", force=force)
166
+ chunks = parse_agreement(html, code)
167
+ print(f" {len(chunks)} chunks")
168
+ all_chunks.extend(chunks)
169
+ if not chunks:
170
+ # A page that parses to nothing is a redesign upstream, not an
171
+ # empty agreement -- same class of failure as a dead fetch.
172
+ failures.append((code, "no content parsed"))
173
+ except Exception as exc:
174
+ failures.append((code, f"{type(exc).__name__}: {exc}"))
175
+ print(f" FAILED {code}: {type(exc).__name__}: {exc}")
176
+
177
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
178
+ stored_chunks(OUT_FILE))
179
+ if preserved:
180
+ print(f" kept the last-good copy of {len(preserved)} agreement(s): "
181
+ f"{', '.join(preserved)}")
182
+ if not write_corpus(OUT_FILE, all_chunks, indent=2,
183
+ allow_shrink=allow_shrink):
184
+ return False
185
+ print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
186
+ return True
187
+
188
+
189
+ def main():
190
+ ok = ingest(force="--force" in sys.argv,
191
+ allow_shrink="--allow-shrink" in sys.argv)
192
+ sys.exit(0 if ok else 1)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
canlex/amps.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
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)
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):
110
+ html = _fetch(INDEX_URL, RAW / "index.html").decode("utf-8", "replace")
111
+ links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
112
+ key=lambda t: t[1])
113
+ if limit:
114
+ links = links[:limit]
115
+ print(f"amps: {len(links)} contravention pages on the index")
116
+ chunks, failed = [], []
117
+ for path, code in links:
118
+ if path.startswith("http"):
119
+ url = path
120
+ elif path.startswith("/"):
121
+ url = BASE + path
122
+ else:
123
+ url = INDEX_URL.rsplit("/", 1)[0] + "/" + path
124
+ try:
125
+ page = _fetch(url, RAW / f"{code}.html").decode("utf-8", "replace")
126
+ chunks.append(parse_page(page, code, url))
127
+ except Exception as exc:
128
+ failed.append((code, f"{type(exc).__name__}: {exc}"))
129
+ out = PROCESSED_DIR / "amps.json"
130
+ # Printed before the write so a refusal still shows what went wrong.
131
+ if failed:
132
+ print(f"amps: {len(failed)} failed: {failed[:5]}")
133
+ if not limit:
134
+ chunks, preserved = preserve_failed(chunks, failed, stored_chunks(out))
135
+ if preserved:
136
+ print(f"amps: kept the last-good copy of {len(preserved)} "
137
+ f"unfetchable contravention(s): {', '.join(preserved)}")
138
+ # The guard applies to --limit runs too: a smoke test over five
139
+ # contraventions must not be able to write a five-page corpus over the real
140
+ # one. indent=2 matches what is already on disk -- changing it would
141
+ # rewrite every line of the file.
142
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
143
+ return False
144
+ print(f"amps: {len(chunks)} contraventions -> {out.name}")
145
+ return True
146
+
147
+
148
+ def main():
149
+ ok = build(allow_shrink="--allow-shrink" in sys.argv)
150
+ sys.exit(0 if ok else 1)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
canlex/benefits.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (fetch_cached, norm_ws, preserve_dropped, split_lines,
39
+ stored_chunks, write_corpus)
40
+
41
+ from .config import PROCESSED_DIR, RAW_DIR
42
+
43
+ RAW = RAW_DIR / "benefits"
44
+ OUT = PROCESSED_DIR / "benefits.json"
45
+
46
+ _CHUNK_CHARS = 1800 # target characters per chunk
47
+
48
+ # Each plan: its booklet, and how to read it. PDF booklets give the page
49
+ # indices of the table of contents and the first body page (everything before
50
+ # it is cover + TOC). The HTML booklet is parsed at its h2/h3 headings.
51
+ # 'edition' is the booklet's own date (for HTML it is read from the page's
52
+ # "Date modified"); bump a PDF edition and re-run when a new booklet is posted.
53
+ SOURCES = {
54
+ "PSHCP": {
55
+ "code": "PSHCP", "format": "pdf",
56
+ "name": "Public Service Health Care Plan",
57
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
58
+ "welcome-sites/pshcp/PSHCP-member-booklet.pdf"),
59
+ "label": "Member Booklet",
60
+ "toc_pages": [2, 3, 4],
61
+ "body_start": 5,
62
+ "edition": "June 2024",
63
+ },
64
+ "PSDCP": {
65
+ "code": "PSDCP", "format": "pdf",
66
+ "name": "Public Service Dental Care Plan",
67
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
68
+ "welcome-sites/psdcp/psdcp-member-booklet.pdf"),
69
+ "label": "Member Booklet",
70
+ "toc_pages": [2, 3],
71
+ "body_start": 4,
72
+ "edition": "May 2026",
73
+ },
74
+ "PDSP": {
75
+ "code": "PDSP", "format": "pdf",
76
+ "name": "Pensioners' Dental Services Plan",
77
+ "url": ("https://www.canadalife.com/content/dam/rfp/welcome-sites/"
78
+ "pdsp/pdsp-member-booklet.pdf"),
79
+ "label": "Member Booklet",
80
+ "toc_pages": [2, 3],
81
+ "body_start": 4,
82
+ "edition": "November 2024",
83
+ },
84
+ "PSMIP": {
85
+ "code": "PSMIP", "format": "html",
86
+ "name": "Public Service Management Insurance Plan",
87
+ "url": ("https://www.canada.ca/en/treasury-board-secretariat/services/"
88
+ "benefit-plans/management-insurance-plan/main-plan-booklet.html"),
89
+ "label": "Main Plan Booklet",
90
+ # The canada.ca page exposes no machine-readable date (its <time> tags are
91
+ # content dates, not a page revision). Rather than render currency as
92
+ # 'n/a', state plainly that the source is undated so a reader knows to
93
+ # verify rather than assume staleness.
94
+ "edition": "undated (TBS web edition)",
95
+ },
96
+ }
97
+
98
+ # canada.ca section labels that are navigation/footer, not plan content.
99
+ _SKIP_HEADINGS = {"on this page", "page details", "from:", "table of contents"}
100
+
101
+ # A table-of-contents line: optional dot-leaders, the heading text, then the
102
+ # printed page number. The heading must end in a letter or ')' so a bare page
103
+ # number or a dot run alone is not mistaken for a heading.
104
+ _TOC_LINE = re.compile(r"^[\s.]*(.*?[A-Za-z)])\s*\.*\s*(\d{1,3})\s*$")
105
+
106
+
107
+ def _fetch(url, dest):
108
+ return fetch_cached(url, dest, timeout=90, pause=0)
109
+
110
+
111
+ def _clean(text):
112
+ """Tidy extracted PDF text: turn bullets into dashes, collapse runs of
113
+ spaces, and trim excess blank lines. Apostrophes, accents and en-dashes
114
+ extract as proper Unicode, so no transliteration is needed."""
115
+ text = text.replace("•", "\n- ").replace("\xa0", " ")
116
+ text = re.sub(r"[ \t]+", " ", text)
117
+ text = re.sub(r"\n[ \t]+", "\n", text)
118
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
119
+
120
+
121
+ def _page_texts(pdf_bytes):
122
+ reader = PdfReader(io.BytesIO(pdf_bytes))
123
+ return [p.extract_text() or "" for p in reader.pages]
124
+
125
+
126
+ def parse_toc(page_texts, toc_pages):
127
+ """Return the ordered, de-duplicated list of section headings from the
128
+ table of contents."""
129
+ seen, headings = set(), []
130
+ for i in toc_pages:
131
+ if i >= len(page_texts):
132
+ continue
133
+ for line in page_texts[i].split("\n"):
134
+ line = line.strip()
135
+ if not line or "Table of Contents" in line:
136
+ continue
137
+ m = _TOC_LINE.match(line)
138
+ if not m:
139
+ continue
140
+ title = re.sub(r"\s+", " ", m.group(1)).strip(" .")
141
+ if len(title) > 2 and title.lower() not in seen:
142
+ seen.add(title.lower())
143
+ headings.append(title)
144
+ return headings
145
+
146
+
147
+ def segment(body, headings):
148
+ """Split the body text at each heading, in order, returning a list of
149
+ (heading, section_text). A heading the extractor split across lines is
150
+ matched with flexible whitespace; a heading not found in the body is
151
+ skipped (its text folds into the preceding section)."""
152
+ cuts = []
153
+ pos = 0
154
+ for h in headings:
155
+ pattern = re.compile(r"\s*".join(re.escape(w) for w in h.split()), re.I)
156
+ m = pattern.search(body, pos)
157
+ if m:
158
+ cuts.append((h, m.start()))
159
+ pos = m.end()
160
+ sections = []
161
+ for j, (h, start) in enumerate(cuts):
162
+ end = cuts[j + 1][1] if j + 1 < len(cuts) else len(body)
163
+ sections.append((h, body[start:end].strip()))
164
+ return sections
165
+
166
+
167
+ def _split(text, target=_CHUNK_CHARS):
168
+ """Split a long section into ~target-sized pieces at line boundaries,
169
+ folding small leading/trailing remnants into a neighbour so a section
170
+ never yields a stub chunk."""
171
+ return split_lines(text, target, fold_stubs=True)
172
+
173
+
174
+ def _slug(heading, used):
175
+ """A short, unique, citable section locator from a heading."""
176
+ base = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "section"
177
+ slug, n = base, 2
178
+ while slug in used:
179
+ slug = f"{base}-{n}"
180
+ n += 1
181
+ used.add(slug)
182
+ return slug
183
+
184
+
185
+ def _emit(sections, src):
186
+ """Build CanLex chunk dicts from a list of (heading, section_text), shared
187
+ by the PDF and HTML paths. Skips pure section-divider headings and
188
+ sub-splits any section over the chunk budget."""
189
+ chunks = []
190
+ used_slugs = set()
191
+ label = src["label"]
192
+ for heading, text in sections:
193
+ # Skip pure section dividers -- a heading whose only content is the
194
+ # heading itself (its substance lives in the child sections below it).
195
+ # Compare with whitespace collapsed, since a PDF extractor often breaks
196
+ # a heading across lines ("...Plan \n(PSDCP)").
197
+ norm_text = norm_ws(text)
198
+ norm_head = norm_ws(heading)
199
+ remainder = (norm_text[len(norm_head):]
200
+ if norm_text.lower().startswith(norm_head.lower())
201
+ else norm_text)
202
+ if len(remainder.strip()) < 40:
203
+ continue
204
+ slug = _slug(heading, used_slugs)
205
+ pieces = _split(text)
206
+ for k, piece in enumerate(pieces, start=1):
207
+ section = slug if len(pieces) == 1 else f"{slug}-{k}"
208
+ note = heading if len(pieces) == 1 else f"{heading} (part {k})"
209
+ chunks.append({
210
+ "id": f"benefits-{src['code']}-{section}",
211
+ "doc_type": "benefits",
212
+ "act_code": src["code"],
213
+ "act_short": src["code"],
214
+ "act_name": f"{src['name']} — {label}",
215
+ "section": section,
216
+ "marginal_note": note,
217
+ "part": f"{src['name']} {label}",
218
+ "division": "",
219
+ "heading": "",
220
+ "text": piece,
221
+ "history": "",
222
+ "last_amended": "",
223
+ "current_to": src["edition"],
224
+ "citation": (f"{src['code']} {label}"
225
+ + (f" ({src['edition']})" if src["edition"] else "")
226
+ + f", “{heading}”"),
227
+ "source_url": src["url"],
228
+ })
229
+ return chunks
230
+
231
+
232
+ def parse_pdf_booklet(pdf_bytes, src):
233
+ """Segment a PDF booklet by its table-of-contents headings."""
234
+ pages = _page_texts(pdf_bytes)
235
+ headings = parse_toc(pages, src["toc_pages"])
236
+ body = _clean("\n".join(pages[src["body_start"]:]))
237
+ return _emit(segment(body, headings), src)
238
+
239
+
240
+ def _html_block_text(heading):
241
+ """Readable text from an h2/h3 up to the next heading of the same or higher
242
+ level; lists become dash items."""
243
+ lines = []
244
+ for sib in heading.find_next_siblings():
245
+ if sib.name in ("h2", "h3"):
246
+ break
247
+ if sib.name in ("ul", "ol"):
248
+ for li in sib.find_all("li", recursive=False):
249
+ item = norm_ws(li.get_text(" ", strip=True))
250
+ if item:
251
+ lines.append(f"- {item}")
252
+ elif sib.name in ("p", "div", "table", "section", "h4", "h5"):
253
+ text = norm_ws(sib.get_text(" ", strip=True))
254
+ if text:
255
+ lines.append(text)
256
+ return "\n".join(lines)
257
+
258
+
259
+ def parse_html_booklet(html, src):
260
+ """Segment the canada.ca HTML booklet at its h2/h3 headings."""
261
+ soup = BeautifulSoup(html, "html.parser")
262
+ main = soup.find("main") or soup
263
+ # Read the page's "Date modified" so the booklet's currency is recorded.
264
+ if not src["edition"]:
265
+ t = main.find("time", property="dateModified") or soup.find(
266
+ "time", property="dateModified")
267
+ if t:
268
+ src = {**src, "edition": t.get_text(strip=True)}
269
+ sections = []
270
+ for h in main.find_all(["h2", "h3"]):
271
+ title = norm_ws(h.get_text(" ", strip=True))
272
+ if not title or title.lower() in _SKIP_HEADINGS:
273
+ continue
274
+ body = _html_block_text(h)
275
+ if body:
276
+ sections.append((title, f"{title}\n{body}"))
277
+ return _emit(sections, src)
278
+
279
+
280
+ def _fetch_html(url, dest):
281
+ """Fetch a canada.ca page via PowerShell -- the site blocks Python's HTTP
282
+ client at the TLS layer, but accepts PowerShell's .NET stack."""
283
+ return fetch_cached(url, dest, powershell=True)
284
+
285
+
286
+ def preserve_failed(chunks, failures, stored):
287
+ """Re-attach the last-good chunks of plans this run could not rebuild.
288
+
289
+ Ingestion is a full rebuild of all four booklets into one file, so one plan
290
+ failing -- Canada Life moves a PDF, canada.ca times out, a re-typeset
291
+ booklet no longer segments -- silently deletes that plan's coverage while
292
+ the other three look healthy. A plan's chunks all carry its code in
293
+ act_code, so the stored copy can be restored intact. Restricted to plans
294
+ that actually failed, so a plan removed from SOURCES on purpose (a wound-up
295
+ plan) is not resurrected. Returns (chunks, preserved plan codes).
296
+ """
297
+ failed = {code for code, _why in failures}
298
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
299
+ only=failed)
300
+
301
+
302
+ def build(allow_shrink=False):
303
+ all_chunks, failures = [], []
304
+ for src in SOURCES.values():
305
+ print(f"Ingesting {src['code']} {src['label'].lower()} ...")
306
+ try:
307
+ if src["format"] == "pdf":
308
+ pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf")
309
+ chunks = parse_pdf_booklet(pdf, src)
310
+ else:
311
+ html = _fetch_html(src["url"], RAW / f"{src['code']}.html"
312
+ ).decode("utf-8", "replace")
313
+ chunks = parse_html_booklet(html, src)
314
+ except Exception as exc:
315
+ print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
316
+ failures.append((src["code"], f"{type(exc).__name__}: {exc}"))
317
+ continue
318
+ if not chunks:
319
+ # A booklet that downloads fine but segments to nothing has failed
320
+ # just as surely as one that 404s -- that is how the CBSA index
321
+ # loss looked -- so it counts as a failure rather than a plan that
322
+ # legitimately has no content.
323
+ print(f" !! {src['code']}: no sections parsed")
324
+ failures.append((src["code"], "no sections parsed"))
325
+ continue
326
+ all_chunks.extend(chunks)
327
+ print(f" {len(chunks)} chunks")
328
+
329
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
330
+ stored_chunks(OUT))
331
+ if preserved:
332
+ print(f" kept the last-good copy of {len(preserved)} booklet(s): "
333
+ f"{', '.join(preserved)}")
334
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
335
+ return False
336
+ print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
337
+ f"booklet(s) -> {OUT}")
338
+ return True
339
+
340
+
341
+ def main():
342
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()
canlex/caselaw.py ADDED
@@ -0,0 +1,1279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest leading Canadian court and tribunal decisions as section-level chunks.
2
+
3
+ Sources: the official Lexum decisions databases of the Supreme Court of Canada
4
+ (decisions.scc-csc.ca), the Federal Court of Appeal (decisions.fca-caf.gc.ca)
5
+ and the Federal Court (decisions.fct-cf.gc.ca) -- a decision's text sits inside
6
+ an iframe, fetched by appending ?iframe=true to its URL -- plus the in-force
7
+ jurisprudential guides the Immigration and Refugee Board publishes on its own
8
+ site (irb-cisr.gc.ca). This ingests a *curated* set of leading decisions; it is
9
+ deliberately not a comprehensive scrape.
10
+
11
+ py -m canlex.caselaw
12
+ """
13
+ import re
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ from bs4 import BeautifulSoup
20
+
21
+ from ._common import (MONTHS as _MONTHS, norm_ws as _norm, preserve_dropped,
22
+ stored_chunks, write_corpus)
23
+ from .config import PROCESSED_DIR, RAW_DIR
24
+
25
+ # Each court or tribunal's Lexum decisions database: (display name, item-URL
26
+ # template). All five run the same Lexum platform -- same iframe trick, metadata
27
+ # block and bracketed paragraph numbers -- so one parser serves them all.
28
+ COURTS = {
29
+ "scc": ("Supreme Court of Canada",
30
+ "https://decisions.scc-csc.ca/scc-csc/scc-csc/en/item/{id}/index.do"),
31
+ "fca": ("Federal Court of Appeal",
32
+ "https://decisions.fca-caf.gc.ca/fca-caf/decisions/en/item/{id}/index.do"),
33
+ "fc": ("Federal Court",
34
+ "https://decisions.fct-cf.gc.ca/fc-cf/decisions/en/item/{id}/index.do"),
35
+ "fpslreb": ("Federal Public Sector Labour Relations and Employment Board",
36
+ "https://decisions.fpslreb-crtespf.gc.ca/fpslreb-crtespf/d/en/"
37
+ "item/{id}/index.do"),
38
+ "onca": ("Court of Appeal for Ontario",
39
+ "https://coadecisions.ontariocourts.ca/coa/coa/en/item/{id}/"
40
+ "index.do"),
41
+ "cirb": ("Canada Industrial Relations Board",
42
+ "https://decisia.lexum.com/cirb-ccri/cirb-ccri/en/item/{id}/index.do"),
43
+ }
44
+ _RAW = RAW_DIR / "caselaw"
45
+ OUT = PROCESSED_DIR / "caselaw.json"
46
+
47
+ # A normal browser User-Agent: the courts' Lexum sites denylist a few crawler
48
+ # UAs, while robots.txt otherwise permits the decision pages. Politeness comes
49
+ # from the throttle below and from caching every fetched page on disk.
50
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
51
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
52
+ _THROTTLE = 30.0 # seconds between fetches -- Lexum Decisia CAPTCHA-gates high-volume access
53
+ _CHUNK_CHARS = 1800 # target characters per chunk
54
+
55
+ # Marks the post-reasons apparatus (appended legislation, solicitors list),
56
+ # which is not part of the judgment's reasons.
57
+ _APPARATUS = re.compile(
58
+ r"^\s*(APPENDIX\b|Solicitors?\s+for\b|NAMES\s+OF\s+COUNSEL\b)", re.I)
59
+
60
+ # Curated leading cases on Canadian border / immigration / customs / Charter
61
+ # law. 'court' selects a database in COURTS; 'id' is the verified Lexum item ID;
62
+ # 'short' and 'topic' are curated. The case name, citation and date are parsed
63
+ # from the page. FCA decisions later reversed by the SCC are deliberately
64
+ # omitted -- CanLex serves good law, and the SCC versions are already listed.
65
+ CASES = [
66
+ # --- Supreme Court of Canada ---
67
+ {"court": "scc", "id": 18078, "short": "Vavilov",
68
+ "topic": "Standard of review on judicial review; the reasonableness "
69
+ "standard for administrative decisions"},
70
+ {"court": "scc", "id": 20081, "short": "Mason",
71
+ "topic": "Inadmissibility under IRPA s. 34(1)(e) for acts of violence "
72
+ "endangering safety in Canada; reasonableness review"},
73
+ {"court": "scc", "id": 18510, "short": "Fraser",
74
+ "topic": "Adverse-effect discrimination under Charter s. 15(1); the "
75
+ "RCMP job-sharing pension rules' disproportionate impact on "
76
+ "women; substantive equality methodology"},
77
+ {"court": "scc", "id": 16146, "short": "Igloo Vikski",
78
+ "topic": "Customs tariff classification methodology; how the General "
79
+ "Rules for the Interpretation of the Harmonized System apply "
80
+ "(Rules 1 and 2(b)); the SCC anchor for tariff-classification "
81
+ "disputes"},
82
+ {"court": "scc", "id": 16803, "short": "Tran",
83
+ "topic": "Serious criminality and inadmissibility under IRPA s. 36; the "
84
+ "meaning of a term of imprisonment and an offence punishable by"},
85
+ {"court": "scc", "id": 15647, "short": "B010",
86
+ "topic": "Inadmissibility for people smuggling under IRPA s. 37(1)(b); "
87
+ "organized criminality"},
88
+ {"court": "scc", "id": 15648, "short": "Appulonappa",
89
+ "topic": "The human smuggling offence in IRPA s. 117; constitutional "
90
+ "overbreadth and humanitarian aid to asylum seekers"},
91
+ {"court": "scc", "id": 14419, "short": "Febles",
92
+ "topic": "Exclusion from refugee protection for a serious non-political "
93
+ "crime under Article 1F(b) of the Refugee Convention"},
94
+ {"court": "scc", "id": 13184, "short": "Ezokola",
95
+ "topic": "Complicity and exclusion from refugee protection for "
96
+ "international crimes under Article 1F(a)"},
97
+ {"court": "scc", "id": 15665, "short": "Kanthasamy",
98
+ "topic": "Humanitarian and compassionate relief under IRPA s. 25; the "
99
+ "best interests of a child"},
100
+ {"court": "scc", "id": 13137, "short": "Agraira",
101
+ "topic": "Ministerial relief from inadmissibility on security grounds; "
102
+ "the national interest under IRPA"},
103
+ {"court": "scc", "id": 6901, "short": "Khosa",
104
+ "topic": "Standard of review of immigration decisions; judicial review "
105
+ "of a removal order"},
106
+ {"court": "scc", "id": 2345, "short": "Charkaoui",
107
+ "topic": "Security certificates and immigration detention; the Charter "
108
+ "and procedural fairness"},
109
+ {"court": "scc", "id": 1937, "short": "Suresh",
110
+ "topic": "Deportation to a risk of torture; Charter s. 7 and removal on "
111
+ "security grounds"},
112
+ {"court": "scc", "id": 17759, "short": "Chhina",
113
+ "topic": "Habeas corpus as a remedy for immigration detention; review of "
114
+ "lengthy detention"},
115
+ {"court": "scc", "id": 1717, "short": "Baker",
116
+ "topic": "Procedural fairness in administrative decisions; humanitarian "
117
+ "and compassionate review; the duty to give reasons"},
118
+ {"court": "scc", "id": 39, "short": "Singh",
119
+ "topic": "Charter s. 7 rights of refugee claimants; the right to an oral "
120
+ "hearing"},
121
+ {"court": "scc", "id": 377, "short": "Simmons",
122
+ "topic": "Customs searches at the border; Charter s. 8 and the reasonable "
123
+ "expectation of privacy on entry to Canada"},
124
+ {"court": "scc", "id": 1694, "short": "Monney",
125
+ "topic": "Border detention for a customs search; reasonable suspicion "
126
+ "and the Customs Act"},
127
+ {"court": "scc", "id": 986, "short": "Dehghani",
128
+ "topic": "Charter rights at a port of entry; secondary examination and "
129
+ "the right to counsel"},
130
+ {"court": "scc", "id": 1627, "short": "Pushpanathan",
131
+ "topic": "Exclusion from refugee protection under Article 1F(c) for acts "
132
+ "contrary to the purposes of the United Nations"},
133
+ {"court": "scc", "id": 1023, "short": "Ward",
134
+ "topic": "The refugee definition; a particular social group; the "
135
+ "availability of state protection"},
136
+ {"court": "scc", "id": 855, "short": "Chiarelli",
137
+ "topic": "Charter s. 7 fundamental justice and the deportation of "
138
+ "permanent residents for serious criminality"},
139
+ {"court": "scc", "id": 1940, "short": "Chieu",
140
+ "topic": "Immigration Appeal Division appeals from a removal order; "
141
+ "foreign hardship is a relevant consideration"},
142
+ {"court": "scc", "id": 2284, "short": "Medovarski",
143
+ "topic": "The objectives of IRPA; a non-citizen has no Charter s. 7 "
144
+ "right to remain in Canada"},
145
+ {"court": "scc", "id": 2273, "short": "Mugesera",
146
+ "topic": "Inadmissibility for crimes against humanity; incitement to "
147
+ "genocide and the elements of the offence"},
148
+ {"court": "scc", "id": 13643, "short": "Harkat",
149
+ "topic": "Constitutionality of the IRPA security-certificate regime and "
150
+ "the special advocate scheme"},
151
+ {"court": "scc", "id": 7899, "short": "Németh",
152
+ "topic": "Extradition and the refugee principle of non-refoulement; the "
153
+ "Minister's duty when surrendering a protected person"},
154
+ {"court": "scc", "id": 7944, "short": "Mavi",
155
+ "topic": "Sponsorship-undertaking debt; the procedural fairness owed "
156
+ "before the Crown enforces a sponsorship debt"},
157
+ {"court": "scc", "id": 12904, "short": "Pham",
158
+ "topic": "Collateral immigration consequences as a factor in fixing a "
159
+ "criminal sentence"},
160
+ {"court": "scc", "id": 1299, "short": "Chan",
161
+ "topic": "Refugee protection; membership in a particular social group "
162
+ "and a well-founded fear of persecution"},
163
+ {"court": "scc", "id": 1425, "short": "Jacques",
164
+ "topic": "Border searches; reasonable suspicion for a vehicle stop near "
165
+ "the border under the Customs Act"},
166
+ {"court": "scc", "id": 2198, "short": "Martineau",
167
+ "topic": "Whether an ascertained-forfeiture proceeding under the Customs "
168
+ "Act is penal and engages the right against self-incrimination"},
169
+ {"court": "scc", "id": 21317, "short": "Carignan",
170
+ "topic": "Powers of arrest without warrant under Criminal Code s. 495; an "
171
+ "arrest that contravenes the s. 495(2) limits can ground a breach "
172
+ "of the Charter s. 9 right against arbitrary arrest, despite "
173
+ "s. 495(3)"},
174
+ {"court": "scc", "id": 14502, "short": "Fearon",
175
+ "topic": "Searches of cell phones incident to arrest; the tailored "
176
+ "common-law framework under Charter s. 8 -- the doctrinal "
177
+ "companion to the border device-search cases (Canfield, Pike)"},
178
+ {"court": "scc", "id": 19957, "short": "CCRf",
179
+ "topic": "Constitutionality of the Safe Third Country Agreement; Charter "
180
+ "ss. 7 and 15 review of designating the United States as a safe "
181
+ "country for refugee claimants"},
182
+ {"court": "scc", "id": 2507, "short": "Hydro-Quebec",
183
+ "topic": "Duty to accommodate and undue hardship; the employer must "
184
+ "arrange the workplace and duties to allow the employee to work, "
185
+ "but is not required to fundamentally alter the conditions of work"},
186
+ {"court": "scc", "id": 16679, "short": "Elk Valley",
187
+ "topic": "Duty to accommodate addiction in safety-sensitive workplaces; "
188
+ "the prima facie discrimination test and dismissal for breach of "
189
+ "a no-free-accident drug and alcohol policy"},
190
+ {"court": "scc", "id": 910, "short": "Renaud",
191
+ "topic": "Tripartite duty to accommodate in unionized workplaces; the "
192
+ "shared obligations of the employer, the union and the employee "
193
+ "to the point of undue hardship"},
194
+ {"court": "scc", "id": 13106, "short": "Irving",
195
+ "topic": "Random alcohol testing in safety-sensitive workplaces; balancing "
196
+ "the employer's safety interests against employee privacy under a "
197
+ "collective agreement"},
198
+ {"court": "scc", "id": 14577, "short": "MPAO",
199
+ "topic": "Charter s. 2(d) freedom of association and collective bargaining; "
200
+ "the exclusion of RCMP members from a meaningful labour relations "
201
+ "regime is unconstitutional"},
202
+ {"court": "scc", "id": 14610, "short": "SFL",
203
+ "topic": "Constitutional right to strike under Charter s. 2(d); essential "
204
+ "services legislation that prohibits strike action without a "
205
+ "meaningful alternative dispute-resolution mechanism"},
206
+ {"court": "scc", "id": 2306, "short": "Ferras",
207
+ "topic": "Extradition under the record-of-the-case procedure; Charter s. 7 "
208
+ "requires the extradition judge to weigh the sufficiency and "
209
+ "reliability of the evidence before committal"},
210
+
211
+ # --- Federal Court of Appeal ---
212
+ {"court": "fca", "id": 345651, "short": "Jane Doe",
213
+ "topic": "Judicial review of 2017 PSLREB 55; compensation for a "
214
+ "victim of workplace sexual assault in the federal public "
215
+ "service; remedial authority of the Board"},
216
+ {"court": "fca", "id": 127201, "short": "Patanguli",
217
+ "topic": "A de novo hearing before the Board cures procedural defects "
218
+ "in the employer's investigation (applying Tipple); dismissal "
219
+ "of a CIC employee for cheating in a staffing process"},
220
+ {"court": "fc", "id": 525537, "short": "Marentette",
221
+ "topic": "Workplace violence and harassment investigations under the "
222
+ "Canada Labour Code; procedural fairness owed to the "
223
+ "complainant in the investigation process"},
224
+ {"court": "fca", "id": 33278, "short": "Cha",
225
+ "topic": "Scope of s. 44(1) inadmissibility reports and Minister's "
226
+ "delegate removal orders at the port of entry; officers and "
227
+ "delegates have no discretion to weigh humanitarian factors "
228
+ "for a foreign national's ordinary criminality; the content of "
229
+ "procedural fairness at the report stage"},
230
+ {"court": "fca", "id": 143136, "short": "Huruglica",
231
+ "topic": "The Refugee Appeal Division reviews the Refugee Protection "
232
+ "Division for correctness and makes its own independent assessment"},
233
+ {"court": "fca", "id": 143152, "short": "Singh (new evidence)",
234
+ "topic": "Admitting new evidence at the Refugee Appeal Division under "
235
+ "IRPA s. 110(4)"},
236
+ {"court": "fca", "id": 37663, "short": "Hernandez Febles",
237
+ "topic": "Exclusion from refugee protection for a serious non-political "
238
+ "crime, Refugee Convention Article 1F(b)"},
239
+ {"court": "fca", "id": 36253, "short": "Jayasekara",
240
+ "topic": "The test for a serious crime under Article 1F(b) exclusion "
241
+ "from refugee protection"},
242
+ {"court": "fca", "id": 35786, "short": "Raza",
243
+ "topic": "Pre-removal risk assessment; the test for new evidence under "
244
+ "IRPA s. 113(a)"},
245
+ {"court": "fca", "id": 99694, "short": "Najafi",
246
+ "topic": "Security inadmissibility under IRPA s. 34(1)(b), subversion "
247
+ "against a government"},
248
+ {"court": "fca", "id": 108889, "short": "Kanagendren",
249
+ "topic": "Security inadmissibility for membership under IRPA s. 34(1)(f); "
250
+ "membership requires no complicity analysis"},
251
+ {"court": "fca", "id": 35313, "short": "Sittampalam",
252
+ "topic": "Organized criminality under IRPA s. 37(1)(a); the broad meaning "
253
+ "of a criminal organization"},
254
+ {"court": "fca", "id": 31607, "short": "Thanabalasingham",
255
+ "topic": "Immigration detention review; the Minister's onus and the "
256
+ "weight owed to prior detention decisions"},
257
+ {"court": "fca", "id": 305100, "short": "Lunyamila",
258
+ "topic": "Immigration detention where a detainee will not cooperate with "
259
+ "their own removal"},
260
+ {"court": "fca", "id": 483607, "short": "Brown",
261
+ "topic": "The constitutionality of immigration detention; the Charter "
262
+ "and lengthy detention"},
263
+ {"court": "fca", "id": 36347, "short": "Baron",
264
+ "topic": "Deferral of removal by an enforcement officer; the narrow "
265
+ "scope of the discretion to defer"},
266
+ {"court": "fca", "id": 520921, "short": "Galindo Camayo",
267
+ "topic": "Reasonableness review of an immigration decision and the role "
268
+ "of certified questions on appeal"},
269
+ {"court": "fca", "id": 501244, "short": "Best Buy",
270
+ "topic": "Customs tariff classification; methodology and appeals from "
271
+ "the Canadian International Trade Tribunal"},
272
+ {"court": "fca", "id": 466027, "short": "Honey Fashions",
273
+ "topic": "Customs; judicial review of the Canada Border Services "
274
+ "Agency's exercise of discretion"},
275
+ {"court": "fca", "id": 419470, "short": "Hociung",
276
+ "topic": "Whether gold coins are currency or monetary instruments that "
277
+ "must be reported on import under the PCMLTFA cross-border regime"},
278
+ {"court": "fca", "id": 32333, "short": "Poshteh",
279
+ "topic": "Inadmissibility for membership in a terrorist organization "
280
+ "under IRPA s. 34(1)(f); relevance of the claimant's age"},
281
+ {"court": "fca", "id": 35576, "short": "Hinzman",
282
+ "topic": "Refugee and humanitarian and compassionate claims by United "
283
+ "States military deserters"},
284
+ {"court": "fca", "id": 35611, "short": "Thamotharem",
285
+ "topic": "Refugee Protection Division hearing procedure; order of "
286
+ "questioning and fettering of discretion by guidelines"},
287
+ {"court": "fca", "id": 37227, "short": "Toussaint",
288
+ "topic": "Access to the Interim Federal Health Program for an indigent "
289
+ "applicant without immigration status"},
290
+ {"court": "fca", "id": 31447, "short": "Rahaman",
291
+ "topic": "Refugee claims and the meaning of a 'no credible basis' "
292
+ "finding under IRPA"},
293
+ {"court": "fca", "id": 521840, "short": "Rodriguez Anzola",
294
+ "topic": "Criminal inadmissibility under IRPA s. 36(1)(b) for a "
295
+ "conviction abroad; whether an immigration decision-maker may "
296
+ "consider that the defence of duress was effectively unavailable "
297
+ "in the foreign jurisdiction"},
298
+
299
+ # --- Federal Court ---
300
+ {"court": "fc", "id": 64594, "short": "Goburdhun",
301
+ "topic": "Inadmissibility for misrepresentation under IRPA s. 40; a "
302
+ "frequently-cited summary of the governing principles"},
303
+ {"court": "fc", "id": 492842, "short": "Garcia",
304
+ "topic": "Criminal inadmissibility; the effect of withdrawn charges and "
305
+ "a claim of self-defence"},
306
+ {"court": "fc", "id": 483303, "short": "Kaur",
307
+ "topic": "Procedural fairness; the right to a meaningful opportunity to "
308
+ "answer a visa officer's concerns"},
309
+ {"court": "fc", "id": 56900, "short": "Nguyen",
310
+ "topic": "Customs Act; judicial review of the amount set for a penalty "
311
+ "or ascertained forfeiture"},
312
+ {"court": "fc", "id": 57473, "short": "Almrei",
313
+ "topic": "Reasonableness of an IRPA security certificate after "
314
+ "Charkaoui; the test on re-determination"},
315
+ {"court": "fc", "id": 48199, "short": "Wang",
316
+ "topic": "Innocent-misrepresentation exception to IRPA s. 40(1)(a); the "
317
+ "section reaches indirect misrepresentations made by another "
318
+ "party, subject to a narrow carve-out where the applicant had no "
319
+ "knowledge of and no control over the misrepresentation"},
320
+ {"court": "fc", "id": 47039, "short": "Bellido",
321
+ "topic": "Materiality of a misrepresentation under IRPA s. 40(1)(a); a "
322
+ "misrepresentation need not be intentional, deliberate or "
323
+ "negligent so long as it could have induced an error in the "
324
+ "administration of the Act"},
325
+
326
+ # --- Federal courts: PCMLTFA cross-border currency forfeiture ---
327
+ {"court": "fca", "id": 36092, "short": "Sellathurai",
328
+ "topic": "PCMLTFA cross-border currency forfeiture; on a s. 30 appeal the "
329
+ "issue is whether the s. 12 reporting duty was contravened, and "
330
+ "the claimant must satisfy the Minister that the seized funds "
331
+ "are of legitimate origin"},
332
+ {"court": "fca", "id": 35921, "short": "Dag",
333
+ "topic": "PCMLTFA currency forfeiture; the limited scope of review of "
334
+ "the Minister's decision and the privative clause"},
335
+ {"court": "fca", "id": 35595, "short": "Tourki",
336
+ "topic": "PCMLTFA; the structure of the s. 30 appeal to the Federal "
337
+ "Court against the forfeiture of seized currency"},
338
+ # Williams, 2017 FCA 252 (item 304112) interpreted PCMLTFA s. 13, the
339
+ # 'election not to proceed' exception to the reporting duty. Parliament
340
+ # repealed s. 13 in 2018 (S.C. 2018, c. 27, s. 174), so Williams is no
341
+ # longer good law on a live provision and is excluded from the corpus.
342
+ {"court": "fc", "id": 37898, "short": "Dokaj",
343
+ "topic": "PCMLTFA; the Federal Court's jurisdiction on a s. 30 appeal is "
344
+ "limited to whether the s. 12 reporting duty was contravened"},
345
+ {"court": "fc", "id": 62413, "short": "Da Huang",
346
+ "topic": "PCMLTFA currency forfeiture; partial return of seized funds "
347
+ "where only part is shown to be of legitimate origin"},
348
+
349
+ # --- Federal Public Sector Labour Relations and Employment Board ---
350
+ # 2026-07 gap audit: the governing authorities the corpus's own
351
+ # decisions apply, identified per doctrinal domain and verified
352
+ # against the issuing courts' records before ingestion.
353
+ {"court": "scc", "id": 7799, "short": "Grant",
354
+ "topic": "The governing s. 24(2) exclusion framework and the "
355
+ "controlling definition of physical and psychological "
356
+ "detention"},
357
+ {"court": "scc", "id": 2015, "short": "Jarvis",
358
+ "topic": "The predominant-purpose test: when a regulatory audit "
359
+ "becomes a penal investigation engaging full Charter "
360
+ "protections"},
361
+ {"court": "scc", "id": 1924, "short": "Golden",
362
+ "topic": "Strip searches are never routine: independent reasonable "
363
+ "grounds and a reasonable manner required"},
364
+ {"court": "scc", "id": 13264, "short": "Chehil",
365
+ "topic": "The reasonable-suspicion standard: an objectively "
366
+ "discernible constellation of factors assessed in their "
367
+ "totality"},
368
+ {"court": "scc", "id": 5274, "short": "Hunter",
369
+ "topic": "The s. 8 foundation: reasonable expectation of privacy and "
370
+ "the presumptive warrant requirement"},
371
+ {"court": "scc", "id": 7800, "short": "Suberu",
372
+ "topic": "Section 10(b) attaches immediately on detention; when an "
373
+ "investigative encounter crystallizes into detention"},
374
+ {"court": "scc", "id": 4627, "short": "Kang-Brown",
375
+ "topic": "Sniffer-dog deployment is a s. 8 search, grounded on "
376
+ "reasonable suspicion"},
377
+ {"court": "scc", "id": 1492, "short": "Collins",
378
+ "topic": "The tripartite s. 8 reasonableness test: authorized by "
379
+ "law, a reasonable law, conducted reasonably"},
380
+ {"court": "scc", "id": 15478, "short": "Guindon",
381
+ "topic": "When an administrative monetary penalty engages Charter s. "
382
+ "11 protections (it ordinarily does not)"},
383
+ {"court": "scc", "id": 13327, "short": "Vu",
384
+ "topic": "Computers attract a distinct, heightened privacy interest "
385
+ "requiring specific search authorization"},
386
+ {"court": "scc", "id": 13265, "short": "MacKenzie",
387
+ "topic": "Reasonable suspicion applied to a vehicle sniffer-dog "
388
+ "search on traveller-profile indicators"},
389
+ {"court": "scc", "id": 21114, "short": "Pepa",
390
+ "topic": "IAD appeal rights under s. 63(2) for a foreign national "
391
+ "whose permanent resident visa expired after entry"},
392
+ {"court": "scc", "id": 1874, "short": "Mattel",
393
+ "topic": "Customs valuation under s. 48: royalties and licence fees "
394
+ "are dutiable only as a condition of the sale"},
395
+ {"court": "scc", "id": 266, "short": "Wigglesworth",
396
+ "topic": "The two-part test for Charter s. 11: proceedings criminal "
397
+ "by nature or carrying a true penal consequence"},
398
+ {"court": "scc", "id": 1276, "short": "Weber",
399
+ "topic": "Exclusive jurisdiction: disputes arising from the "
400
+ "collective agreement go to grievance arbitration, not the "
401
+ "courts"},
402
+ {"court": "scc", "id": 2211, "short": "Vaughan",
403
+ "topic": "Courts decline jurisdiction over federal public-service "
404
+ "workplace disputes covered by the statutory grievance "
405
+ "scheme"},
406
+ {"court": "scc", "id": 1876, "short": "McKinley",
407
+ "topic": "Dishonesty is not automatically just cause for dismissal: "
408
+ "the contextual, proportional approach"},
409
+ {"court": "scc", "id": 5271, "short": "Gagnon",
410
+ "topic": "The five governing principles of the union's duty of fair "
411
+ "representation"},
412
+ {"court": "scc", "id": 1877, "short": "Noel (DFR)",
413
+ "topic": "The duty of fair representation restated: arbitrary, "
414
+ "discriminatory, bad-faith or seriously negligent conduct"},
415
+ {"court": "scc", "id": 1724, "short": "Meiorin",
416
+ "topic": "The unified three-step bona fide occupational requirement "
417
+ "test for workplace standards and accommodation"},
418
+ {"court": "scc", "id": 96, "short": "Fraser (loyalty)",
419
+ "topic": "The federal public servant's duty of loyalty balanced "
420
+ "against freedom of expression"},
421
+ {"court": "scc", "id": 106, "short": "Cardinal",
422
+ "topic": "Procedural fairness binds every public authority; a breach "
423
+ "voids the decision regardless of the likely outcome"},
424
+ {"court": "scc", "id": 2574, "short": "CJL v NEB",
425
+ "topic": "The reasonable-apprehension-of-bias test: what an informed "
426
+ "person, viewing the matter realistically, would conclude"},
427
+ {"court": "scc", "id": 2660, "short": "Harrison",
428
+ "topic": "The Carltona doctrine: ministerial powers are "
429
+ "presumptively exercisable by responsible departmental "
430
+ "officials"},
431
+ {"court": "scc", "id": 2446, "short": "Maple Lodge",
432
+ "topic": "Fettering of discretion: policy guidelines cannot be "
433
+ "applied as binding rules excluding relevant considerations"},
434
+ {"court": "scc", "id": 1879, "short": "Mount Sinai",
435
+ "topic": "Legitimate expectations are procedural only; public-law "
436
+ "estoppel against a minister is narrow and yields to "
437
+ "statute"},
438
+ {"court": "scc", "id": 1107, "short": "Mobil Oil",
439
+ "topic": "The futility exception: relief for a fairness breach may "
440
+ "be withheld where the outcome was legally inevitable"},
441
+ {"court": "scc", "id": 15438, "short": "Strickland",
442
+ "topic": "Discretionary refusal of judicial review and the adequate- "
443
+ "alternative-remedy framework"},
444
+ {"court": "scc", "id": 1808, "short": "Blencoe",
445
+ "topic": "Administrative delay is an abuse of process only on "
446
+ "significant prejudice from unacceptable delay"},
447
+ {"court": "scc", "id": 19439, "short": "Abrametz",
448
+ "topic": "The modern three-part test for inordinate administrative "
449
+ "delay, with proportionate remedies"},
450
+ {"court": "scc", "id": 20730, "short": "Auer",
451
+ "topic": "Vavilov reasonableness, with a presumption of validity, "
452
+ "governs vires challenges to subordinate legislation"},
453
+ {"court": "scc", "id": 431, "short": "Prassad",
454
+ "topic": "Administrative tribunals are masters of their own "
455
+ "procedure, subject only to fairness"},
456
+ {"court": "fca", "id": 214260, "short": "Sharma",
457
+ "topic": "Section 44 reports and referrals for permanent residents: "
458
+ "very limited discretion, no duty to weigh H&C factors"},
459
+ {"court": "fca", "id": 407446, "short": "Sidhu",
460
+ "topic": "Misrepresentation under s. 40: the duty of candour and the "
461
+ "narrow innocent-misrepresentation exception"},
462
+ {"court": "fca", "id": 33323, "short": "Ragupathy",
463
+ "topic": "The s. 115(2)(a) danger-opinion framework: danger "
464
+ "assessment, then risk, then balancing"},
465
+ {"court": "fca", "id": 521220, "short": "Obazughanmwen",
466
+ "topic": "Section 44 officers and delegates have no mandate for "
467
+ "detailed H&C or abuse-of-process inquiries"},
468
+ {"court": "fca", "id": 35977, "short": "Nagalingam",
469
+ "topic": "The s. 115(2)(b) refoulement branch: assessing the nature "
470
+ "and severity of the acts committed"},
471
+ {"court": "fca", "id": 423316, "short": "Revell",
472
+ "topic": "An inadmissibility determination and s. 44 referral do not "
473
+ "engage Charter s. 7, even for long-term permanent "
474
+ "residents"},
475
+ {"court": "fca", "id": 143934, "short": "Bermudez",
476
+ "topic": "Cessation applications: the hearings officer has no "
477
+ "discretion to weigh H&C factors or a child's best "
478
+ "interests"},
479
+ {"court": "fca", "id": 31594, "short": "Coomaraswamy",
480
+ "topic": "Vacation of refugee protection under s. 109: establishing "
481
+ "misrepresentation; only untainted evidence on the second "
482
+ "branch"},
483
+ {"court": "fca", "id": 36788, "short": "Zeng",
484
+ "topic": "The three-question test for Article 1E exclusion: status "
485
+ "substantially similar to nationals of a third country"},
486
+ {"court": "fca", "id": 32011, "short": "Li (s. 97)",
487
+ "topic": "The s. 97 protected-person standard is balance of "
488
+ "probabilities, unlike the lower s. 96 threshold"},
489
+ {"court": "fca", "id": 36349, "short": "Li (detention)",
490
+ "topic": "Anticipated future length of detention is assessed on "
491
+ "proceedings conducted expeditiously"},
492
+ {"court": "fca", "id": 36729, "short": "C.B. Powell",
493
+ "topic": "Prematurity: the Customs Act re-determination and CITT "
494
+ "route must be exhausted before judicial review"},
495
+ {"court": "fca", "id": 181427, "short": "Bri-Chem",
496
+ "topic": "CBSA must follow CITT precedent when processing "
497
+ "corrections; relitigating settled rulings is an abuse of "
498
+ "process"},
499
+ {"court": "fca", "id": 407467, "short": "Chen",
500
+ "topic": "A s. 135 Customs Act action tests only whether the "
501
+ "contravention occurred; officer conduct belongs to other "
502
+ "recourse"},
503
+ {"court": "fca", "id": 36351, "short": "Fritz Marketing",
504
+ "topic": "Challenges to Detailed Adjustment Statements proceed "
505
+ "through the Customs Act appeal route, not judicial review"},
506
+ {"court": "fca", "id": 108541, "short": "Skechers",
507
+ "topic": "Transaction value: research cost-sharing payments to the "
508
+ "vendor form part of the price paid or payable"},
509
+ {"court": "fca", "id": 231365, "short": "Heyser",
510
+ "topic": "Termination on revocation of reliability status must rest "
511
+ "on legitimate grounds, reviewable by the Board"},
512
+ {"court": "fca", "id": 308603, "short": "CPR (fairness)",
513
+ "topic": "Procedural fairness on judicial review: whether the party "
514
+ "knew the case to meet and had a full chance to respond"},
515
+ {"court": "fc", "id": 308992, "short": "McAlpin",
516
+ "topic": "The s. 44 discretion synthesis: any H&C discretion in "
517
+ "criminality cases is very limited, if it exists at all"},
518
+ {"court": "fc", "id": 54573, "short": "Frazee",
519
+ "topic": "Whether an employer measure labelled administrative is in "
520
+ "substance discipline: corrective intent and impact"},
521
+ {"court": "onca", "id": 14196, "short": "Chaudhary",
522
+ "topic": "Habeas corpus is available for lengthy immigration "
523
+ "detention of uncertain duration"},
524
+ # Second tier from the 2026-07 gap audit, added at the maintainer's
525
+ # direction: the strongest of each reviewer's deliberate exclusions.
526
+ {"court": "scc", "id": 7865, "short": "Nolet",
527
+ "topic": "Dual-purpose vehicle searches: a valid regulatory search may "
528
+ "shift to criminal investigation; the Charter consequences of "
529
+ "each step assessed on its own footing"},
530
+ {"court": "scc", "id": 19043, "short": "Horrocks",
531
+ "topic": "Exclusive labour-arbitration jurisdiction over human-rights "
532
+ "disputes arising from the collective agreement; the Weber "
533
+ "framework applied to competing statutory tribunals"},
534
+ {"court": "scc", "id": 2075, "short": "Parry Sound",
535
+ "topic": "Statutory employment and human-rights protections are read "
536
+ "into every collective agreement and enforceable at "
537
+ "arbitration"},
538
+ {"court": "scc", "id": 16827, "short": "AJC v Canada",
539
+ "topic": "Management rights are exercised subject to reasonableness "
540
+ "and consistency with the collective agreement (standby "
541
+ "scheduling of counsel)"},
542
+ {"court": "scc", "id": 2291, "short": "Hilewitz",
543
+ "topic": "Medical inadmissibility for excessive demand requires "
544
+ "individualized assessment of the applicant's actual "
545
+ "circumstances, including family support"},
546
+ {"court": "fca", "id": 363658, "short": "Tapambwa",
547
+ "topic": "Restricted PRRA under s. 112(3): no revisiting of the "
548
+ "exclusion finding; the scope of protection for excluded "
549
+ "persons"},
550
+ # The CIU's published "Important Decisions" list (ciu-sdi.ca), ingested
551
+ # 2026-07-23; topics adapted from the union's own subject lines.
552
+ {"court": "fpslreb", "id": 358837, "short": "Bartlett",
553
+ "topic": "Hours of work and variable shift scheduling under the FB "
554
+ "collective agreement (Border Services officers)"},
555
+ {"court": "fpslreb", "id": 359582, "short": "Legros",
556
+ "topic": "Age discrimination grievance against the CBSA"},
557
+ {"court": "fpslreb", "id": 359605, "short": "Bétournay",
558
+ "topic": "Breach of trust, disguised disciplinary action and "
559
+ "termination (CRA)"},
560
+ {"court": "fpslreb", "id": 359635, "short": "Frezza",
561
+ "topic": "Bad faith and disguised discipline in the federal public "
562
+ "service (DND)"},
563
+ {"court": "fpslreb", "id": 360491, "short": "PSAC v TB (CBSA) 2018-66",
564
+ "topic": "Policy grievance: interpretation of the FB collective "
565
+ "agreement (PSAC v Treasury Board / CBSA)"},
566
+ {"court": "fpslreb", "id": 361700, "short": "PSAC v TB (CBSA) 2018-82",
567
+ "topic": "Policy grievance: interpretation of the FB collective "
568
+ "agreement (PSAC v Treasury Board / CBSA)"},
569
+ {"court": "fpslreb", "id": 361701, "short": "Michaud",
570
+ "topic": "Suspension grievance in the federal public service (CRA)"},
571
+ {"court": "fpslreb", "id": 407654, "short": "Hyslop",
572
+ "topic": "Suspension grievance of a CBSA employee"},
573
+ {"court": "fpslreb", "id": 407686, "short": "Girard",
574
+ "topic": "Suspension and the effective date of termination (CRA)"},
575
+ {"court": "fpslreb", "id": 420718, "short": "Edwards",
576
+ "topic": "Sick leave, personal-needs leave and disability "
577
+ "discrimination under the FB collective agreement (CBSA)"},
578
+ {"court": "fpslreb", "id": 420308, "short": "Touchette",
579
+ "topic": "Proportionality of discipline; suspension of a CBSA "
580
+ "employee reduced"},
581
+ {"court": "fpslreb", "id": 521306, "short": "Guay-Bassett",
582
+ "topic": "Scheduling, designated paid holidays and sick leave under "
583
+ "the FB collective agreement (CBSA)"},
584
+ {"court": "fpslreb", "id": 520990, "short": "Menzies",
585
+ "topic": "Progressive discipline of a CBSA border services officer; the "
586
+ "lock-step approach to discipline rejected"},
587
+ {"court": "fpslreb", "id": 521231, "short": "Kline",
588
+ "topic": "Bad-faith termination of a CBSA employee; reinstatement and "
589
+ "damages"},
590
+ {"court": "fpslreb", "id": 521195, "short": "Sousa Dias",
591
+ "topic": "Discipline and termination grievance of a CBSA employee"},
592
+ {"court": "fpslreb", "id": 521082, "short": "Anderson",
593
+ "topic": "CBSA grievance; interpretation of the FB-group collective "
594
+ "agreement"},
595
+ {"court": "fpslreb", "id": 520948, "short": "Burlacu",
596
+ "topic": "CBSA; occupational health and safety and staffing"},
597
+ {"court": "fpslreb", "id": 483604, "short": "Malik",
598
+ "topic": "Discipline and termination grievance of a CBSA employee"},
599
+ {"court": "fpslreb", "id": 500554, "short": "Andruszkiewicz",
600
+ "topic": "Unfair labour practice complaint involving the CBSA"},
601
+ {"court": "fpslreb", "id": 359013, "short": "PSAC v TB (CBSA)",
602
+ "topic": "Policy grievance; collective agreement interpretation at the "
603
+ "CBSA"},
604
+ {"court": "fpslreb", "id": 359065, "short": "Martin-Ivie",
605
+ "topic": "Occupational health and safety; the arming and safety of CBSA "
606
+ "border officers"},
607
+ {"court": "fpslreb", "id": 358886, "short": "Basra (2012)",
608
+ "topic": "Discipline and termination grievance; a later proceeding in "
609
+ "the leading Basra line"},
610
+ {"court": "fpslreb", "id": 358025, "short": "Basra (2007)",
611
+ "topic": "The foundational Basra decision on discipline and the burden "
612
+ "of proof in a grievance"},
613
+ {"court": "fpslreb", "id": 358150, "short": "Quadrini",
614
+ "topic": "Unfair labour practice and freedom of expression in the "
615
+ "federal public service"},
616
+ {"court": "fpslreb", "id": 358180, "short": "Pepper",
617
+ "topic": "Discipline and termination; frequently-cited principles on "
618
+ "just cause"},
619
+ {"court": "fpslreb", "id": 358097, "short": "Richmond",
620
+ "topic": "Classification grievance in the federal public service"},
621
+ {"court": "fpslreb", "id": 358890, "short": "Baldasaro and Thiessen",
622
+ "topic": "Hours of work and overtime under a collective agreement"},
623
+ {"court": "fpslreb", "id": 358203, "short": "PSAC v TB (pay)",
624
+ "topic": "Collective agreement and pay administration policy grievance"},
625
+ {"court": "fpslreb", "id": 360456, "short": "Kinhnicki",
626
+ "topic": "Occupational health and safety; a refusal to work in a customs "
627
+ "context"},
628
+
629
+ # --- Court of Appeal for Ontario (own Decisia database) ---
630
+ {"court": "onca", "id": 22592, "short": "Pike",
631
+ "topic": "Border searches of personal electronic devices; following and "
632
+ "extending Canfield, Customs Act s. 99(1)(a) is unconstitutional "
633
+ "under Charter s. 8 insofar as it permits suspicionless device "
634
+ "searches -- a reasonable-suspicion requirement is read in "
635
+ "(suspended declaration of invalidity)"},
636
+
637
+ # --- Canada Industrial Relations Board ---
638
+ {"court": "cirb", "id": 519772, "short": "Watson",
639
+ "topic": "Duty of fair representation and a mandatory vaccination policy "
640
+ "under the Canada Labour Code"},
641
+ {"court": "cirb", "id": 5478, "short": "McRaeJackson",
642
+ "topic": "The leading test for the duty of fair representation under "
643
+ "s. 37 of the Canada Labour Code"},
644
+ {"court": "cirb", "id": 5491, "short": "Securicor",
645
+ "topic": "Certification and bargaining-unit determination under the "
646
+ "Canada Labour Code"},
647
+ {"court": "cirb", "id": 5593, "short": "Dover Industries",
648
+ "topic": "Successor rights on the sale of a business under the Canada "
649
+ "Labour Code"},
650
+ {"court": "cirb", "id": 301063, "short": "Swissport",
651
+ "topic": "Unfair labour practice complaint under the Canada Labour Code"},
652
+ {"court": "cirb", "id": 5599, "short": "Cooney Transport",
653
+ "topic": "Related-employer (common-employer) declaration under the "
654
+ "Canada Labour Code"},
655
+ ]
656
+
657
+ # Provincial appellate decisions, sourced from CanLII as cached plain text.
658
+ # CanLII fronts its pages with DataDome bot protection, so these CANNOT be
659
+ # fetched automatically: open the CanLII page in a real browser, select-all,
660
+ # and save the page text to data/raw/caselaw/<file>. Because there is no
661
+ # parseable metadata block, the name / citation / date / court are curated
662
+ # here rather than parsed. The paragraph text still parses the same way --
663
+ # CanLII renders the same bracketed [N] paragraph numbers as Lexum.
664
+ CANLII_CASES = [
665
+ {"file": "canlii-2020abca383.txt",
666
+ "canlii_path": "en/ab/abca/doc/2020/2020abca383/2020abca383.html",
667
+ "short": "Canfield",
668
+ "name": "R v Canfield",
669
+ "cite": "2020 ABCA 383",
670
+ "report": "395 CCC (3d) 483",
671
+ "date": "2020-10-29",
672
+ "court_name": "Court of Appeal of Alberta",
673
+ "topic": "Border searches of personal electronic devices; Customs Act "
674
+ "s. 99(1)(a) held unconstitutional under Charter s. 8 insofar as "
675
+ "it imposes no limits on device searches at the border (leave to "
676
+ "appeal to the SCC refused; binding in Alberta and persuasive "
677
+ "elsewhere)"},
678
+ {"file": "canlii-2021canlii95825.txt",
679
+ "canlii_path": "en/ca/irb/doc/2021/2021canlii95825/2021canlii95825.html",
680
+ "short": "TC1-05038",
681
+ "name": "IRB Jurisprudential Guide TC1-05038 — X (Re)",
682
+ "cite": "2021 CanLII 95825 (CA IRB)",
683
+ "report": "",
684
+ "date": "2021-08-30",
685
+ "court_name": "Immigration and Refugee Board — Refugee Appeal Division",
686
+ "topic": "RAD jurisprudential guide on the 'generalized risk' exception "
687
+ "to protection under IRPA s. 97(1)(b)(ii) in claims involving "
688
+ "gang targeting, extortion or other criminality (in force; "
689
+ "identified as a guide July 2022)"},
690
+ # The foreign-conviction equivalency line -- the FCA authorities the
691
+ # curated us_equivalency dataset applies. Pre-Lexum era, so CanLII-only:
692
+ # the raw text arrives via the manual browser-capture workflow and
693
+ # ingestion skips gracefully until each file exists.
694
+ {"file": "canlii-1981-brannson.txt",
695
+ "canlii_path": "en/ca/fca/doc/1980/1980canlii4197/1980canlii4197.html",
696
+ "short": "Brannson",
697
+ "name": "Brannson v Canada (Minister of Employment and Immigration)",
698
+ "cite": "[1981] 2 FC 141 (CA)",
699
+ "report": "1980 CanLII 4197",
700
+ "date": "1980-06-05",
701
+ "court_name": "Federal Court of Appeal",
702
+ "topic": "Foreign-conviction equivalency: compare the essential elements "
703
+ "of the foreign and Canadian offences; the foreign offence's "
704
+ "wording and the evidence may both be examined (first of the "
705
+ "equivalency trilogy)"},
706
+ {"file": "canlii-1987-hill.txt",
707
+ "canlii_path": "en/ca/fca/doc/1987/1987canlii9881/1987canlii9881.html",
708
+ "short": "Hill",
709
+ "name": "Hill v Canada (Minister of Employment and Immigration)",
710
+ "cite": "(1987), 73 NR 315 (FCA)",
711
+ "report": "1987 CanLII 9881; 1 Imm LR (2d) 1",
712
+ "date": "1987-01-29",
713
+ "court_name": "Federal Court of Appeal",
714
+ "topic": "The governing three-branch equivalency test: (1) compare the "
715
+ "precise wording and essential elements of the two provisions; "
716
+ "(2) examine the evidence of what was actually done against the "
717
+ "Canadian offence's elements; or (3) both combined"},
718
+ # 2026-07 gap audit, pre-Decisia authorities: text arrives via the
719
+ # browser-capture workflow (or a direct PDF fetch for Wm. Scott);
720
+ # ingestion skips gracefully until each file exists.
721
+ {"file": "canlii-1991-rasaratnam.txt",
722
+ "canlii_path": "en/ca/fca/doc/1991/1991canlii13517/1991canlii13517.html",
723
+ "short": "Rasaratnam", "name": "Rasaratnam v Canada (MEI)",
724
+ "cite": "[1992] 1 FC 706 (CA)", "report": "1991 CanLII 13517",
725
+ "date": "1991-12-05", "court_name": "Federal Court of Appeal",
726
+ "topic": "The two-prong internal flight alternative test: no serious "
727
+ "possibility of persecution in the IFA, and reasonableness of "
728
+ "relocation"},
729
+ {"file": "canlii-1993-thirunavukkarasu.txt",
730
+ "canlii_path": "en/ca/fca/doc/1993/1993canlii3011/1993canlii3011.html",
731
+ "short": "Thirunavukkarasu", "name": "Thirunavukkarasu v Canada (MEI)",
732
+ "cite": "[1994] 1 FC 589 (CA)", "report": "1993 CanLII 3011",
733
+ "date": "1993-11-10", "court_name": "Federal Court of Appeal",
734
+ "topic": "The reasonableness prong of the IFA test: an objective "
735
+ "standard, with notice required before an IFA is raised"},
736
+ {"file": "canlii-2000-ranganathan.txt",
737
+ "canlii_path": "en/ca/fca/doc/2000/2000canlii16789/2000canlii16789.html",
738
+ "short": "Ranganathan", "name": "Ranganathan v Canada (MCI)",
739
+ "cite": "[2001] 2 FC 164 (CA)", "report": "2000 CanLII 16789",
740
+ "date": "2000-12-21", "court_name": "Federal Court of Appeal",
741
+ "topic": "The very high threshold for an unreasonable IFA: conditions "
742
+ "jeopardizing life and safety in travelling or relocating"},
743
+ {"file": "canlii-1994-sahin.txt",
744
+ "canlii_path": "en/ca/fct/doc/1994/1994canlii3521/1994canlii3521.html",
745
+ "short": "Sahin", "name": "Sahin v Canada (MCI)",
746
+ "cite": "[1995] 1 FC 214 (TD)", "report": "1994 CanLII 3521",
747
+ "date": "1994-10-14", "court_name": "Federal Court (Trial Division)",
748
+ "topic": "The s. 7 factors for continued immigration detention -- the "
749
+ "source of IRPR s. 248: reasons, length, future duration, "
750
+ "delay attribution, alternatives"},
751
+ {"file": "canlii-1979-maldonado.txt",
752
+ "canlii_path": "en/ca/fca/doc/1979/1979canlii4098/1979canlii4098.html",
753
+ "short": "Maldonado", "name": "Maldonado v Canada (MEI)",
754
+ "cite": "[1980] 2 FC 302 (CA)", "report": "1979 CanLII 4098",
755
+ "date": "1979-11-19", "court_name": "Federal Court of Appeal",
756
+ "topic": "The presumption that a claimant's sworn testimony is true "
757
+ "unless there is reason to doubt it"},
758
+ {"file": "canlii-1989-adjei.txt",
759
+ "canlii_path": "en/ca/fca/doc/1989/1989canlii5184/1989canlii5184.html",
760
+ "short": "Adjei", "name": "Adjei v Canada (MEI)",
761
+ "cite": "[1989] 2 FC 680 (CA)", "report": "1989 CanLII 9466",
762
+ "date": "1989-01-27", "court_name": "Federal Court of Appeal",
763
+ "topic": "The s. 96 standard of proof: a reasonable chance or serious "
764
+ "possibility of persecution, less than balance of probabilities"},
765
+ {"file": "canlii-1988-toth.txt",
766
+ "canlii_path": "en/ca/fca/doc/1988/1988canlii1420/1988canlii1420.html",
767
+ "short": "Toth", "name": "Toth v Canada (MEI)",
768
+ "cite": "(1988), 86 NR 302 (FCA)", "report": "1988 CanLII 1420",
769
+ "date": "1988-06-21", "court_name": "Federal Court of Appeal",
770
+ "topic": "The tripartite stay test on motions to stay removal: serious "
771
+ "issue, irreparable harm, balance of convenience"},
772
+ {"file": "canlii-1980-burchill.txt",
773
+ "canlii_path": "en/ca/fca/doc/1980/1980canlii4207/1980canlii4207.html",
774
+ "short": "Burchill", "name": "Burchill v Canada (Attorney General)",
775
+ "cite": "[1981] 1 FC 109 (CA)", "report": "1980 CanLII 4207",
776
+ "date": "1980-05-20", "court_name": "Federal Court of Appeal",
777
+ "topic": "A grievor cannot change the nature of a grievance when it "
778
+ "reaches adjudication"},
779
+ {"file": "canlii-1992-lidder.txt",
780
+ "canlii_path": "en/ca/fca/doc/1992/1992canlii14712/1992canlii14712.html",
781
+ "short": "Lidder", "name": "Canada (MEI) v Lidder",
782
+ "cite": "[1992] 2 FC 621 (CA)", "report": "1992 CanLII 14712",
783
+ "date": "1992-03-10", "court_name": "Federal Court of Appeal",
784
+ "topic": "Estoppel and legitimate expectations cannot defeat mandatory "
785
+ "statutory requirements: an officer's representation cannot "
786
+ "confer a status or bypass conditions the Act imposes"},
787
+ # Tipple ([1985] FCJ No 818) is QuickLaw-only -- no CanLII record
788
+ # exists. Its de-novo-cures-defects rule enters the corpus through
789
+ # Patanguli, 2015 FCA 291 (CASES entry), which quotes and applies it.
790
+ {"file": "canlii-1976-wmscott.txt",
791
+ "canlii_path": "en/bc/bclrb/doc/1976/1976canlii1017/1976canlii1017.html",
792
+ "short": "Wm. Scott", "name": "Wm. Scott & Company Ltd v CFAWU Local P-162",
793
+ "cite": "[1977] 1 Can LRBR 1 (BCLRB)", "report": "",
794
+ "date": "1976-10-26",
795
+ "court_name": "British Columbia Labour Relations Board",
796
+ "topic": "The three-question discipline framework: was there cause; was "
797
+ "the penalty excessive; what lesser penalty should substitute"},
798
+ {"file": "canlii-1996-li.txt",
799
+ "canlii_path": "en/ca/fca/doc/1996/1996canlii4086/1996canlii4086.html",
800
+ "short": "Li",
801
+ "name": "Li v Canada (Minister of Citizenship and Immigration)",
802
+ "cite": "[1997] 1 FC 235 (CA)",
803
+ "report": "1996 CanLII 4086",
804
+ "date": "1996-08-07",
805
+ "court_name": "Federal Court of Appeal",
806
+ "topic": "Equivalency compares the essential elements of the offences, "
807
+ "not procedural or evidentiary rules; defences available under "
808
+ "the foreign law are part of the comparison"},
809
+ ]
810
+
811
+ # In-force jurisprudential guides of the Immigration and Refugee Board's Refugee
812
+ # Appeal Division -- decisions the Board designates as models that members apply
813
+ # to similar cases or explain why not. Full text is hosted on the IRB's own
814
+ # site; 'topic' is curated, while the date and paragraphs are parsed from the
815
+ # page. (A fourth in-force guide, TC1-05038, is hosted only on CanLII.)
816
+ IRB_GUIDES = [
817
+ {"file": "MB8-00025",
818
+ "url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/mb8-00025.aspx",
819
+ "topic": "Exclusion from refugee protection under Article 1E where the "
820
+ "claimant has protected status in a third country (Haiti / Brazil)"},
821
+ {"file": "TB7-01837",
822
+ "url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/TB7-01837.aspx",
823
+ "topic": "Persecution of Ahmadis in Pakistan; state protection and the "
824
+ "availability of an internal flight alternative"},
825
+ {"file": "TB4-05778",
826
+ "url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/TB4-05778.aspx",
827
+ "topic": "Whether a North Korean refugee claimant has deemed citizenship "
828
+ "of South Korea and protection available there"},
829
+ ]
830
+
831
+
832
+ def _get(url, cache_name):
833
+ """Fetch a page, caching the raw HTML under data/raw/caselaw.
834
+
835
+ Retries once on HTTP 403/429 -- the Lexum hosts rate-limit by IP.
836
+ """
837
+ cache = _RAW / cache_name
838
+ if cache.exists():
839
+ return cache.read_text(encoding="utf-8")
840
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
841
+ text = None
842
+ for attempt in range(2):
843
+ time.sleep(_THROTTLE if attempt == 0 else 25.0)
844
+ try:
845
+ with urllib.request.urlopen(req, timeout=60) as resp:
846
+ text = resp.read().decode("utf-8", errors="replace")
847
+ break
848
+ except urllib.error.HTTPError as exc:
849
+ if exc.code in (403, 429) and attempt == 0:
850
+ continue
851
+ raise
852
+ _RAW.mkdir(parents=True, exist_ok=True)
853
+ cache.write_text(text, encoding="utf-8")
854
+ return text
855
+
856
+
857
+ def _fetch(court, item_id):
858
+ """Return a Lexum court decision's iframe HTML."""
859
+ url = COURTS[court][1].format(id=item_id) + "?iframe=true"
860
+ return _get(url, f"{court}-{item_id}.html")
861
+
862
+
863
+ def _irb_date(texts):
864
+ """Pull the ISO 'Date of decision' from an IRB decision's front matter.
865
+
866
+ The label and the date sometimes sit in separate elements, so the search
867
+ runs over the joined text rather than block by block.
868
+ """
869
+ m = re.search(r"Date of decision:?\s*([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})",
870
+ " ".join(texts))
871
+ if m and m.group(1).lower() in _MONTHS:
872
+ return (f"{m.group(3)}-{_MONTHS[m.group(1).lower()]:02d}-"
873
+ f"{int(m.group(2)):02d}")
874
+ return ""
875
+
876
+
877
+ def _metadata(soup):
878
+ """Return (case_name, {label: value}) from the decision's metadata block."""
879
+ box = soup.find("div", class_="metadata")
880
+ if not box:
881
+ return "", {}
882
+ title = box.find("h3", class_="title")
883
+ name = _norm(title.get_text()) if title else ""
884
+ fields = {}
885
+ for row in box.find_all("tr"):
886
+ label = row.find("td", class_="label")
887
+ value = row.find("td", class_="metadata")
888
+ if label and value:
889
+ fields[_norm(label.get_text()).lower()] = _norm(value.get_text())
890
+ return name, fields
891
+
892
+
893
+ def _body(soup):
894
+ """Locate the container holding the decision text.
895
+
896
+ Handles the Lexum court pages and the IRB's SharePoint pages, whose text
897
+ sits in a 'RichHtmlField' rich-text div.
898
+ """
899
+ return (soup.find(id="document-content")
900
+ or soup.find("div", class_="documentcontent")
901
+ or soup.find("div", class_="WordSection1")
902
+ or soup.find("div", id=lambda v: v and "RichHtmlField" in v)
903
+ or soup.find("div", class_="ms-rtestate-field")
904
+ or soup.body or soup)
905
+
906
+
907
+ def _paragraphs(soup):
908
+ """Return (is_numbered, [(label, text), ...]) for the judgment body.
909
+
910
+ Modern judgments open each paragraph with a bracketed number "[N]". They are
911
+ detected by content -- a run of sequentially numbered <p> blocks -- so the
912
+ parser does not depend on Word style names, which vary by court and era.
913
+ Every <p> between one numbered opener and the next belongs to that paragraph.
914
+ Older, unnumbered decisions fall back to taking every <p> in document order.
915
+ """
916
+ body = _body(soup)
917
+ for aside in body.find_all("aside", class_="wb-fnote"):
918
+ aside.decompose() # drop IRB/WET footnote blocks
919
+ blocks = [p for p in body.find_all("p")
920
+ if "MsoFootnoteText" not in (p.get("class") or [])]
921
+ texts = [p.get_text() for p in blocks]
922
+
923
+ # Some pre-2009 board pages (e.g. PSSRB 2003) carry the reasons in bare
924
+ # <div> blocks with no <p> markup at all, so the <p> strategy collects
925
+ # only header furniture. When it captured almost none of the body's
926
+ # text, hand the whole body to the plain-text parser instead -- the same
927
+ # one the CanLII paste captures use, with its own numbering detection
928
+ # and apparatus cutoff.
929
+ if sum(len(t) for t in texts) < 0.2 * len(body.get_text()):
930
+ return _text_paragraphs(body.get_text("\n"))
931
+
932
+ # Drop the post-reasons apparatus (appended legislation, solicitors list);
933
+ # it is not part of the reasons and would otherwise swell the last paragraph.
934
+ for i, raw in enumerate(texts):
935
+ if _APPARATUS.match(raw):
936
+ texts = texts[:i]
937
+ break
938
+
939
+ # A paragraph opens with its number: "[12]" (most decisions) or a bare "12"
940
+ # followed by wide tab spacing (pre-2009 decisions). The brackets are
941
+ # self-identifying; a bare number must have 2+ trailing spaces, which
942
+ # rejects quoted enumerations ("2. ..."). The sequential check rejects
943
+ # stray bracketed years like "[1998]".
944
+ openers = {} # block index -> paragraph number
945
+ expected = 1
946
+ for i, raw in enumerate(texts):
947
+ match = (re.match(r"\s*\[\s*(\d+)\s*\]", raw)
948
+ or re.match(r"\s*(\d+)\s{2,}\S", raw))
949
+ if match:
950
+ n = int(match.group(1))
951
+ if expected <= n <= expected + 2: # sequential, small-gap tolerant
952
+ openers[i] = n
953
+ expected = n + 1
954
+
955
+ if len(openers) < 5: # unnumbered: take every <p>
956
+ paras = [(str(j), _norm(t)) for j, t in enumerate(texts, start=1)]
957
+ return False, [(n, t) for n, t in paras if len(t) > 1]
958
+
959
+ paras, num, buf = [], None, []
960
+ for i, raw in enumerate(texts):
961
+ if i in openers:
962
+ if num is not None:
963
+ paras.append((str(num), _norm(" ".join(buf))))
964
+ num = openers[i]
965
+ buf = [re.sub(r"^\s*\[?\s*\d+\s*\]?\s*", "", raw)]
966
+ elif num is not None:
967
+ buf.append(raw)
968
+ if num is not None:
969
+ paras.append((str(num), _norm(" ".join(buf))))
970
+ return True, [(n, t) for n, t in paras if t]
971
+
972
+
973
+ def _split_text(text, limit):
974
+ """Split text longer than `limit` into pieces, breaking on a sentence or
975
+ word boundary so no single chunk blows the retrieval/reranker budget."""
976
+ if len(text) <= limit:
977
+ return [text]
978
+ pieces, start = [], 0
979
+ while start < len(text):
980
+ if len(text) - start <= limit:
981
+ pieces.append(text[start:])
982
+ break
983
+ window = text[start:start + limit]
984
+ cut = window.rfind(". ")
985
+ cut = cut + 1 if cut > limit // 2 else window.rfind(" ")
986
+ if cut <= 0:
987
+ cut = limit
988
+ pieces.append(text[start:start + cut])
989
+ start += cut
990
+ return [p.strip() for p in pieces if p.strip()]
991
+
992
+
993
+ def _chunk(paras):
994
+ """Group consecutive paragraphs into ~_CHUNK_CHARS-sized chunks, first
995
+ splitting any single paragraph that on its own exceeds the budget."""
996
+ units = []
997
+ for label, text in paras:
998
+ for piece in _split_text(text, _CHUNK_CHARS):
999
+ units.append((label, piece))
1000
+ chunks, buf, size = [], [], 0
1001
+ for label, text in units:
1002
+ if buf and size + len(text) > _CHUNK_CHARS:
1003
+ chunks.append(buf)
1004
+ buf, size = [], 0
1005
+ buf.append((label, text))
1006
+ size += len(text)
1007
+ if buf:
1008
+ chunks.append(buf)
1009
+ return chunks
1010
+
1011
+
1012
+ # The post-reasons apparatus in plain-text (CanLII) decisions: the hearing
1013
+ # date, filing formula, counsel list, or signature block -- and everything
1014
+ # after them (endnotes, the CanLII page footer). The "(signed)" marker is how
1015
+ # IRB decisions end; their endnotes reuse bracketed [N] numbering and would
1016
+ # otherwise collide with the paragraph sequence once N catches up.
1017
+ _TEXT_APPARATUS = re.compile(
1018
+ r"^\s*(Appeal heard on\b|Memorandum filed\b|Appearances:|\(signed\)|"
1019
+ r"APPENDIX\b|Solicitors?\s+for\b|NAMES\s+OF\s+COUNSEL\b)", re.I)
1020
+
1021
+
1022
+ def _text_paragraphs(text):
1023
+ """Return (is_numbered, [(label, text), ...]) for a plain-text judgment.
1024
+
1025
+ The text analogue of _paragraphs() for decisions cached as page text
1026
+ rather than HTML: physical lines replace <p> blocks. A line opening with a
1027
+ sequential bracketed number "[N]" starts paragraph N; lines before [1]
1028
+ (style of cause, coram) and everything from the first apparatus marker on
1029
+ (counsel, endnotes, site footer) are dropped."""
1030
+ lines = [ln.strip() for ln in text.splitlines()]
1031
+
1032
+ for i, ln in enumerate(lines):
1033
+ if _TEXT_APPARATUS.match(ln):
1034
+ lines = lines[:i]
1035
+ break
1036
+
1037
+ openers = {} # line index -> paragraph number
1038
+ expected = 1
1039
+ for i, ln in enumerate(lines):
1040
+ match = re.match(r"\[\s*(\d+)\s*\]", ln)
1041
+ if match:
1042
+ n = int(match.group(1))
1043
+ if expected <= n <= expected + 2:
1044
+ openers[i] = n
1045
+ expected = n + 1
1046
+
1047
+ def _unnumbered():
1048
+ paras = [(str(j), _norm(ln)) for j, ln in enumerate(lines, start=1)]
1049
+ return False, [(n, t) for n, t in paras if len(t) > 1]
1050
+
1051
+ if len(openers) < 5:
1052
+ return _unnumbered()
1053
+
1054
+ paras, num, buf = [], None, []
1055
+ for i, ln in enumerate(lines):
1056
+ if i in openers:
1057
+ if num is not None:
1058
+ paras.append((str(num), _norm(" ".join(buf))))
1059
+ num = openers[i]
1060
+ buf = [re.sub(r"^\[\s*\d+\s*\]\s*", "", ln)]
1061
+ elif num is not None and ln:
1062
+ buf.append(ln)
1063
+ if num is not None:
1064
+ paras.append((str(num), _norm(" ".join(buf))))
1065
+ paras = [(n, t) for n, t in paras if t]
1066
+
1067
+ # A pre-2001 report-style decision has unnumbered prose reasons but a
1068
+ # trailing footnote block whose own [1]..[N] sequence the detector will
1069
+ # happily latch onto (Li: 26 footnotes, 4% of the text). If the
1070
+ # "numbered paragraphs" cover only a sliver of the document, the real
1071
+ # reasons are the unnumbered prose -- fall back to it.
1072
+ if sum(len(t) for _, t in paras) < 0.2 * len(text):
1073
+ return _unnumbered()
1074
+ return True, paras
1075
+
1076
+
1077
+ def _canlii_chunks(case, text):
1078
+ """Build CanLex chunk dicts for one CanLII cached-text decision."""
1079
+ citation = f"{case['name']}, {case['cite']}"
1080
+ source_url = f"https://www.canlii.org/{case['canlii_path']}"
1081
+ modern, paras = _text_paragraphs(text)
1082
+ chunks = []
1083
+ for i, group in enumerate(_chunk(paras), start=1):
1084
+ if modern:
1085
+ first, last = group[0][0], group[-1][0]
1086
+ locator = (f"para {first}" if first == last
1087
+ else f"paras {first}–{last}")
1088
+ else:
1089
+ locator = f"excerpt {i}"
1090
+ chunks.append({
1091
+ "id": f"canlii-{case['cite'].replace(' ', '').lower()}-{i}",
1092
+ "doc_type": "caselaw",
1093
+ "act_code": case["cite"],
1094
+ "act_short": case["short"],
1095
+ "act_name": case["name"],
1096
+ "section": "",
1097
+ "citation": citation,
1098
+ "marginal_note": locator,
1099
+ "heading": case["topic"],
1100
+ "part": case["court_name"],
1101
+ "division": "",
1102
+ "text": "\n\n".join(t for _, t in group),
1103
+ "current_to": case["date"],
1104
+ "last_amended": "",
1105
+ "history": case.get("report", ""),
1106
+ "source_url": source_url,
1107
+ })
1108
+ return chunks, citation, len(paras)
1109
+
1110
+
1111
+ def _decision_chunks(case, soup):
1112
+ """Build CanLex chunk dicts for one decision."""
1113
+ court_name, item_tmpl = COURTS[case["court"]]
1114
+ name, fields = _metadata(soup)
1115
+ name = name or case["short"]
1116
+ cite = (fields.get("neutral citation") or fields.get("citation")
1117
+ or fields.get("report") or "")
1118
+ report = fields.get("report", "")
1119
+ date = fields.get("date") or fields.get("decision rendered") or ""
1120
+ citation = f"{name}, {cite}" if cite else name
1121
+ item_url = item_tmpl.format(id=case["id"])
1122
+ modern, paras = _paragraphs(soup)
1123
+ chunks = []
1124
+ for i, group in enumerate(_chunk(paras), start=1):
1125
+ if modern:
1126
+ first, last = group[0][0], group[-1][0]
1127
+ locator = (f"para {first}" if first == last
1128
+ else f"paras {first}–{last}")
1129
+ else:
1130
+ locator = f"excerpt {i}"
1131
+ chunks.append({
1132
+ "id": f"{case['court']}-{case['id']}-{i}",
1133
+ "doc_type": "caselaw",
1134
+ "act_code": cite or f"{case['court'].upper()} item {case['id']}",
1135
+ "act_short": case["short"],
1136
+ "act_name": name,
1137
+ "section": "",
1138
+ "citation": citation,
1139
+ "marginal_note": locator,
1140
+ "heading": case["topic"],
1141
+ "part": court_name,
1142
+ "division": "",
1143
+ "text": "\n\n".join(t for _, t in group),
1144
+ "current_to": date,
1145
+ "last_amended": "",
1146
+ "history": report if report and report != cite else "",
1147
+ "source_url": item_url,
1148
+ })
1149
+ return chunks, citation, len(paras)
1150
+
1151
+
1152
+ def _irb_chunks(guide, soup):
1153
+ """Build CanLex chunk dicts for one IRB jurisprudential guide."""
1154
+ cite = f"IRB Jurisprudential Guide {guide['file']}"
1155
+ date = _irb_date(p.get_text() for p in _body(soup).find_all("p"))
1156
+ modern, paras = _paragraphs(soup)
1157
+ chunks = []
1158
+ for i, group in enumerate(_chunk(paras), start=1):
1159
+ if modern:
1160
+ first, last = group[0][0], group[-1][0]
1161
+ locator = (f"para {first}" if first == last
1162
+ else f"paras {first}–{last}")
1163
+ else:
1164
+ locator = f"excerpt {i}"
1165
+ chunks.append({
1166
+ "id": f"irb-{guide['file']}-{i}",
1167
+ "doc_type": "caselaw",
1168
+ "act_code": guide["file"],
1169
+ "act_short": guide["file"],
1170
+ "act_name": cite,
1171
+ "section": "",
1172
+ "citation": cite,
1173
+ "marginal_note": locator,
1174
+ "heading": guide["topic"],
1175
+ "part": "Immigration and Refugee Board — Refugee Appeal Division",
1176
+ "division": "",
1177
+ "text": "\n\n".join(t for _, t in group),
1178
+ "current_to": date,
1179
+ "last_amended": "",
1180
+ "history": "",
1181
+ "source_url": guide["url"],
1182
+ })
1183
+ return chunks, cite, len(paras)
1184
+
1185
+
1186
+ def build(allow_shrink=False):
1187
+ """Fetch, parse and chunk every curated decision into caselaw.json.
1188
+
1189
+ Returns True when the corpus was written, False when the write was refused
1190
+ -- a Lexum CAPTCHA wall or an upstream re-platforming makes every fetch
1191
+ fail at once, and the rebuild-then-write shape would turn that into an
1192
+ emptied caselaw.json.
1193
+ """
1194
+ # Every path that drops a decision records it here, keyed the way the
1195
+ # chunks are (act_short): Lexum throttles, CanLII captures go missing and
1196
+ # court templates shift, so on any given run some decisions fail while the
1197
+ # rest succeed -- and the rebuild-then-write shape would quietly delete
1198
+ # exactly those. `failed` is what preserve_dropped is allowed to restore,
1199
+ # so decisions removed on purpose (deleted from CASES) stay gone.
1200
+ all_chunks, failed = [], set()
1201
+ for case in CASES:
1202
+ try:
1203
+ soup = BeautifulSoup(_fetch(case["court"], case["id"]),
1204
+ "html.parser")
1205
+ except Exception as exc:
1206
+ print(f" !! {case['short']}: fetch failed -- "
1207
+ f"{type(exc).__name__}: {exc}")
1208
+ failed.add(case["short"])
1209
+ continue
1210
+ chunks, citation, n_paras = _decision_chunks(case, soup)
1211
+ if not chunks:
1212
+ print(f" !! {case['short']} ({case['court']} item "
1213
+ f"{case['id']}): 0 chunks -- check parsing")
1214
+ failed.add(case["short"])
1215
+ continue
1216
+ all_chunks.extend(chunks)
1217
+ print(f" {case['court']:4s} {case['short']:20s} {n_paras:4d} paras -> "
1218
+ f"{len(chunks):3d} chunks {citation}")
1219
+ for case in CANLII_CASES:
1220
+ cache = _RAW / case["file"]
1221
+ if not cache.exists():
1222
+ print(f" !! {case['short']}: {cache} missing -- CanLII blocks "
1223
+ f"automated fetches (DataDome). Open https://www.canlii.org/"
1224
+ f"{case['canlii_path']} in a browser and save the page text "
1225
+ f"to that path, then re-run.")
1226
+ failed.add(case["short"])
1227
+ continue
1228
+ text = cache.read_text(encoding="utf-8-sig")
1229
+ chunks, citation, n_paras = _canlii_chunks(case, text)
1230
+ if not chunks:
1231
+ print(f" !! {case['short']} ({case['file']}): 0 chunks -- "
1232
+ f"check parsing")
1233
+ failed.add(case["short"])
1234
+ continue
1235
+ all_chunks.extend(chunks)
1236
+ print(f" cnli {case['short']:20s} {n_paras:4d} paras -> "
1237
+ f"{len(chunks):3d} chunks {citation}")
1238
+ for guide in IRB_GUIDES:
1239
+ try:
1240
+ soup = BeautifulSoup(_get(guide["url"], f"irb-{guide['file']}.html"),
1241
+ "html.parser")
1242
+ except Exception as exc:
1243
+ print(f" !! {guide['file']}: fetch failed -- "
1244
+ f"{type(exc).__name__}: {exc}")
1245
+ failed.add(guide["file"])
1246
+ continue
1247
+ chunks, citation, n_paras = _irb_chunks(guide, soup)
1248
+ if not chunks:
1249
+ print(f" !! {guide['file']}: 0 chunks -- check parsing")
1250
+ failed.add(guide["file"])
1251
+ continue
1252
+ all_chunks.extend(chunks)
1253
+ print(f" irb {guide['file']:20s} {n_paras:4d} paras -> "
1254
+ f"{len(chunks):3d} chunks {citation}")
1255
+ # act_short is the per-decision identity in this corpus (section is "" for
1256
+ # every case-law chunk), and it is unique across all three lists.
1257
+ all_chunks, preserved = preserve_dropped(all_chunks, stored_chunks(OUT),
1258
+ key=lambda c: c["act_short"],
1259
+ only=failed)
1260
+ if preserved:
1261
+ print(f" kept the last-good copy of {len(preserved)} decision(s) "
1262
+ f"that failed this run: {', '.join(preserved)}")
1263
+ # indent=1 is what this corpus was written with; changing it would rewrite
1264
+ # every line of caselaw.json for no content change.
1265
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
1266
+ return False
1267
+ print(f"\n{len(all_chunks)} case-law chunks from "
1268
+ f"{len(CASES) + len(CANLII_CASES) + len(IRB_GUIDES)} decisions "
1269
+ f"-> {OUT}")
1270
+ return True
1271
+
1272
+
1273
+ def main():
1274
+ ok = build(allow_shrink="--allow-shrink" in sys.argv)
1275
+ sys.exit(0 if ok else 1)
1276
+
1277
+
1278
+ if __name__ == "__main__":
1279
+ main()
canlex/charter.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
104
+ html = fetch_cached(URL, RAW / "FullText.html").decode("utf-8", "replace")
105
+ chunks = parse(html)
106
+ # indent=2 is what charter.json already carries -- changing it would
107
+ # rewrite every line of the file for nothing.
108
+ if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
109
+ return False
110
+ print(f"charter: {len(chunks)} sections -> {OUT_FILE.name}")
111
+ return True
112
+
113
+
114
+ def main():
115
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
canlex/citator.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+
15
+ from .config import ROOT, DATA_DIR
16
+
17
+ API = "https://api.canlii.org/v1"
18
+ KEY_FILE = ROOT / "canlii_key.txt"
19
+ _PLACEHOLDER = "PASTE-YOUR-CANLII-API-KEY-ON-THIS-LINE"
20
+ _DBMAP_FILE = DATA_DIR / "citator_dbmap.json"
21
+ _CACHE_FILE = DATA_DIR / "citator_cache.json"
22
+ _THROTTLE = 3.0 # seconds between CanLII API calls (the API rate-limits hard)
23
+ _MAX_LIST = 20 # items shown per citator list (lists can run to thousands)
24
+ _CASE_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/doc/\d+/([a-z0-9-]+)")
25
+ _DB_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/?$")
26
+
27
+ # A neutral citation, e.g. "2019 SCC 65", and the CanLII URL segment per
28
+ # court/tribunal. The tribunal entries cover the boards whose decisions the
29
+ # corpus itself carries, so the natural loop (search result -> canlex_case)
30
+ # works for every citation a result can display.
31
+ _NEUTRAL = re.compile(
32
+ r"\b(\d{4})\s+(SCC|FCA|FC|FPSLREB|CIRB|ONCA|ABCA)\s+(\d+)\b",
33
+ re.IGNORECASE)
34
+ _CANLII_SEG = {"scc": "scc", "fca": "fca", "fc": "fct",
35
+ "fpslreb": "pslreb", "cirb": "cirb",
36
+ "onca": "onca", "abca": "abca"}
37
+ _PROVINCE_SEG = {"onca": "on", "abca": "ab"} # others are federal ('ca')
38
+
39
+
40
+ def canlii_url_from_citation(text):
41
+ """Build a canlii.org case URL from a neutral citation, or '' if none found.
42
+
43
+ Works for the Supreme Court, Federal Court of Appeal, Federal Court, the
44
+ FPSLREB and CIRB labour boards, and the Ontario and Alberta Courts of
45
+ Appeal -- e.g. "2019 SCC 65" -> .../en/ca/scc/doc/2019/2019scc65/...
46
+ """
47
+ m = _NEUTRAL.search(text)
48
+ if not m:
49
+ return ""
50
+ year, court, num = m.group(1), m.group(2).lower(), m.group(3)
51
+ doc = f"{year}{court}{num}"
52
+ juris = _PROVINCE_SEG.get(court, "ca")
53
+ return (f"https://www.canlii.org/en/{juris}/{_CANLII_SEG[court]}/doc/"
54
+ f"{year}/{doc}/{doc}.html")
55
+
56
+
57
+ def api_key():
58
+ """Return the configured CanLII API key, or '' if not set.
59
+
60
+ The CANLII_API_KEY environment variable is checked first -- the remote
61
+ deployment injects the key as a secret rather than shipping the file.
62
+ A local run falls back to canlii_key.txt.
63
+ """
64
+ env_key = os.environ.get("CANLII_API_KEY", "").strip()
65
+ if env_key and env_key != _PLACEHOLDER:
66
+ return env_key
67
+ if not KEY_FILE.exists():
68
+ return ""
69
+ key = KEY_FILE.read_text(encoding="utf-8").strip()
70
+ return "" if not key or key == _PLACEHOLDER else key
71
+
72
+
73
+ def _load_json(path):
74
+ try:
75
+ return json.loads(path.read_text(encoding="utf-8"))
76
+ except Exception:
77
+ return {}
78
+
79
+
80
+ class Citator:
81
+ """Live CanLII citator with on-disk caching and rate-limit throttling."""
82
+
83
+ def __init__(self):
84
+ self.key = api_key()
85
+ if not self.key:
86
+ raise RuntimeError(f"No CanLII API key -- put your key in {KEY_FILE}.")
87
+ self._dbmap = _load_json(_DBMAP_FILE) # URL court segment -> databaseId
88
+ self._cache = _load_json(_CACHE_FILE) # case URL -> report
89
+
90
+ def _get(self, path):
91
+ """Throttled GET against the CanLII API, retrying on HTTP 429."""
92
+ for attempt in range(3):
93
+ time.sleep(_THROTTLE if attempt == 0 else 15.0)
94
+ sep = "&" if "?" in path else "?"
95
+ url = f"{API}/{path}{sep}api_key={self.key}"
96
+ try:
97
+ with urllib.request.urlopen(url, timeout=45) as resp:
98
+ return json.loads(resp.read().decode("utf-8"))
99
+ except urllib.error.HTTPError as exc:
100
+ if exc.code == 429 and attempt < 2:
101
+ continue
102
+ raise
103
+ raise RuntimeError("CanLII API rate limit reached; retry shortly.")
104
+
105
+ def _ensure_dbmap(self):
106
+ if self._dbmap:
107
+ return
108
+ data = self._get("caseBrowse/en/")
109
+ dbmap = {}
110
+ for db in data.get("caseDatabases", []):
111
+ match = _DB_URL.search(db.get("url") or "")
112
+ if match and db.get("databaseId"):
113
+ dbmap[match.group(1)] = db["databaseId"]
114
+ self._dbmap = dbmap
115
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
116
+ _DBMAP_FILE.write_text(json.dumps(dbmap), encoding="utf-8")
117
+
118
+ def _citator(self, db, case_id, kind):
119
+ data = self._get(f"caseCitator/en/{db}/{case_id}/{kind}")
120
+ items = data.get(kind, [])
121
+ return {"total": len(items), "items": items[:_MAX_LIST]}
122
+
123
+ def case_report(self, case_url):
124
+ """Return a citation-graph report for a case.
125
+
126
+ Accepts a full canlii.org case URL, or a neutral citation (e.g.
127
+ "2019 SCC 65") for a Supreme Court / Federal Court of Appeal / Federal
128
+ Court decision.
129
+ """
130
+ if not _CASE_URL.search(case_url):
131
+ case_url = canlii_url_from_citation(case_url) or case_url
132
+ if case_url in self._cache:
133
+ return self._cache[case_url]
134
+ match = _CASE_URL.search(case_url)
135
+ if not match:
136
+ return {"error": "Provide a full canlii.org case URL, or a neutral "
137
+ "citation such as '2019 SCC 65' (Supreme Court, Federal "
138
+ "Court of Appeal, or Federal Court)."}
139
+ self._ensure_dbmap()
140
+ segment, case_id = match.group(1), match.group(2)
141
+ db = self._dbmap.get(segment)
142
+ if not db:
143
+ return {"error": f"Unrecognized CanLII court segment '{segment}'."}
144
+ report = {
145
+ "meta": self._get(f"caseBrowse/en/{db}/{case_id}/"),
146
+ "citingCases": self._citator(db, case_id, "citingCases"),
147
+ "citedCases": self._citator(db, case_id, "citedCases"),
148
+ "citedLegislations": self._citator(db, case_id, "citedLegislations"),
149
+ }
150
+ self._cache[case_url] = report
151
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
152
+ _CACHE_FILE.write_text(json.dumps(self._cache, ensure_ascii=False),
153
+ encoding="utf-8")
154
+ return report
155
+
156
+
157
+ def main():
158
+ if len(sys.argv) < 2:
159
+ print('usage: python -m canlex.citator "<canlii-case-url>"')
160
+ return
161
+ report = Citator().case_report(sys.argv[1])
162
+ if "error" in report:
163
+ print("ERROR:", report["error"])
164
+ return
165
+ meta = report["meta"]
166
+ print(f"{meta.get('title')} -- {meta.get('citation')} ({meta.get('decisionDate')})")
167
+ print(f" cited by: {report['citingCases']['total']}")
168
+ print(f" cites: {report['citedCases']['total']}")
169
+ print(f" legislation cited: {report['citedLegislations']['total']}")
170
+ for item in report["citedLegislations"]["items"][:6]:
171
+ print(f" - {item.get('title')} ({item.get('citation')})")
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
canlex/ciu.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
21
+ """
22
+ import json
23
+ import re
24
+ import urllib.request
25
+
26
+ from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
27
+ from .config import PROCESSED_DIR, RAW_DIR
28
+
29
+ RAW = RAW_DIR / "ciu"
30
+ VSSA_PAGE = "https://ciu-sdi.ca/en/vssa/"
31
+ _DOCS_BASE = "https://www.ciu-sdi.ca/wp-content/uploads/documents/"
32
+
33
+ # (code, act_short, act_name, url, lang)
34
+ DOCS = [
35
+ ("ciu-bylaws", "CIU By-Laws",
36
+ "CIU National By-Laws (adopted by CIU National Conventions)",
37
+ _DOCS_BASE + "bylaws_en.pdf", "en"),
38
+ ("ciu-bylaws", "CIU By-Laws",
39
+ "Règlements nationaux du SDI (adoptés par les congrès)",
40
+ _DOCS_BASE + "bylaws_fr.pdf", "fr"),
41
+ ("ciu-regs", "CIU Regulations",
42
+ "CIU Regulations (adopted by the CIU National Board of Directors)",
43
+ _DOCS_BASE + "ciu-regs-en.pdf", "en"),
44
+ ("ciu-branch-rules", "CIU Branch Rules",
45
+ "CIU Branch Rules (adopted by the CIU National Board of Directors)",
46
+ _DOCS_BASE + "branch-rules-en.pdf", "en"),
47
+ ("ciu-local-mods", "CIU Local Modifications",
48
+ "Local Modifications to CIU Branch Rules",
49
+ _DOCS_BASE + "local_mod_branch_rules_en.pdf", "en"),
50
+ ("ciu-elections-kit", "CIU Elections Kit",
51
+ "CIU Branch Elections Kit",
52
+ "https://www.ciu-sdi.ca/wp-content/uploads/elections/2023/EN/"
53
+ "elections-kit.pdf", "en"),
54
+ ("psac-constitution", "PSAC Constitution",
55
+ "PSAC Constitution and Regulations (November 2024 edition)",
56
+ "https://psacunion.ca/sites/psac/files/"
57
+ "psac.constitution.november_2024_edition.eng_v002.pdf", "en"),
58
+ ("ciu-guide-grievance", "CIU Grievance Guide",
59
+ "CIU Guide to Grievance Handling",
60
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
61
+ "guide_to_grievance_handling-e.pdf", "en"),
62
+ ("ciu-guide-ee", "CIU EE Toolkit",
63
+ "CIU Employment Equity Toolkit",
64
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
65
+ "EMPLOYMENT-EQUITY-Tool-Kit-e.pdf", "en"),
66
+ ("ciu-guide-transition", "CIU Transition Guide",
67
+ "Workers in Transition: a practical guide about gender transition",
68
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
69
+ "workers_in_transition_guide_-_en_-_web.pdf", "en"),
70
+ ]
71
+
72
+ # Governance headings: "BY-LAW 5", "REGULATION 12", "RULE 3", "ARTICLE 7",
73
+ # "SECTION 25", "PART B" -- at line start, optionally followed by a title.
74
+ _HEADING = re.compile(
75
+ r"^(BY-?LAW|R[EÈ]GLEMENT|REGULATION|RULE|ARTICLE|SECTION|PART|ANNEX(?:E)?"
76
+ r"|APPENDIX)\s+([0-9]+[0-9A-Za-z.\-]*|[A-Z])\b\s*[-–—:]?\s*(.{0,80})",
77
+ re.IGNORECASE)
78
+ _DATE = re.compile(r"\b(20\d{2}|19\d{2})\b")
79
+
80
+
81
+ def _pdf_pages(path):
82
+ from pypdf import PdfReader
83
+ pages = []
84
+ for page in PdfReader(str(path)).pages:
85
+ try:
86
+ pages.append(page.extract_text() or "")
87
+ except Exception:
88
+ pages.append("")
89
+ return pages
90
+
91
+
92
+ def _currency(pages):
93
+ """Newest adoption/amendment year on the cover pages."""
94
+ years = _DATE.findall("\n".join(pages[:3]))
95
+ return max(years) if years else ""
96
+
97
+
98
+ def _doc_chunks(code, short, name, url, lang, pages):
99
+ text = "\n".join(pages)
100
+ lines = text.split("\n")
101
+ currency = _currency(pages)
102
+ marks = []
103
+ for i, l in enumerate(lines):
104
+ m = _HEADING.match(l.strip())
105
+ if m:
106
+ marks.append((i, f"{m.group(1).title()} {m.group(2)}",
107
+ _norm(m.group(3)) or ""))
108
+ segments = []
109
+ if len(marks) >= 6:
110
+ for j, (i, label, title) in enumerate(marks):
111
+ end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
112
+ body = "\n".join(p for p in (_norm(x) for x in lines[i:end]) if p)
113
+ if len(body) > 120:
114
+ segments.append((label, title, body))
115
+ if not segments:
116
+ for k, piece in enumerate(split_lines(text, 1800), start=1):
117
+ body = "\n".join(p for p in (_norm(x) for x in piece.split("\n")) if p)
118
+ if len(body) > 120:
119
+ segments.append((f"part {k}", "", body))
120
+ chunks = []
121
+ fr = "-fr" if lang == "fr" else ""
122
+ for n, (label, title, body) in enumerate(segments, start=1):
123
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
124
+ total = len(pieces)
125
+ for k, piece in enumerate(pieces, start=1):
126
+ suffix = "" if total == 1 else f"-p{k}"
127
+ note = "" if total == 1 else f" (part {k} of {total})"
128
+ chunk = {
129
+ "id": f"{code}{fr}-{n}{suffix}",
130
+ "doc_type": "union",
131
+ "act_code": code.upper(),
132
+ "act_short": short,
133
+ "act_name": name,
134
+ "section": label,
135
+ "marginal_note": title,
136
+ "part": short,
137
+ "division": "",
138
+ "heading": "",
139
+ "text": piece,
140
+ "history": "",
141
+ "last_amended": currency,
142
+ "in_force": "",
143
+ "status": "in force",
144
+ "current_to": currency,
145
+ "citation": f"{short}, {label}"
146
+ + (f" ({title})" if title else "") + note,
147
+ "source_url": url,
148
+ }
149
+ if lang == "fr":
150
+ chunk["lang"] = "fr"
151
+ chunks.append(chunk)
152
+ return chunks
153
+
154
+
155
+ def _vssa_links():
156
+ req = urllib.request.Request(VSSA_PAGE, headers={"User-Agent": "Mozilla/5.0"})
157
+ with urllib.request.urlopen(req, timeout=60) as r:
158
+ html = r.read().decode("utf-8", "replace")
159
+ links = sorted(set(re.findall(
160
+ r'href="(https?://[^"]*?/uploads/vssa/([^"/]+)\.pdf)"', html)))
161
+ return links
162
+
163
+
164
+ def _vssa_chunks(url, stem, pages):
165
+ site = _norm(stem.replace("-", " "))
166
+ text = "\n".join(p for p in (_norm(x) for x in "\n".join(pages).split("\n")) if p)
167
+ if len(text) < 60:
168
+ return []
169
+ out = []
170
+ pieces = split_lines(text, 1800)
171
+ total = len(pieces)
172
+ for k, piece in enumerate(pieces, start=1):
173
+ suffix = "" if total == 1 else f"-p{k}"
174
+ note = "" if total == 1 else f" (part {k} of {total})"
175
+ out.append({
176
+ "id": f"vssa-{stem.lower()}{suffix}",
177
+ "doc_type": "union",
178
+ "act_code": "VSSA",
179
+ "act_short": "VSSA",
180
+ "act_name": "Variable Shift Schedule Agreements (by work site)",
181
+ "section": site,
182
+ "marginal_note": f"VSSA — {site}",
183
+ "part": "Variable Shift Schedule Agreements",
184
+ "division": "",
185
+ "heading": "",
186
+ "text": piece,
187
+ "history": "",
188
+ "last_amended": "",
189
+ "in_force": "",
190
+ "status": "in force",
191
+ "current_to": "",
192
+ "citation": f"VSSA — {site}{note}",
193
+ "source_url": url,
194
+ })
195
+ return out
196
+
197
+
198
+ def build():
199
+ RAW.mkdir(parents=True, exist_ok=True)
200
+ all_chunks = []
201
+ for code, short, name, url, lang in DOCS:
202
+ fr = "-fr" if lang == "fr" else ""
203
+ dest = RAW / f"{code}{fr}.pdf"
204
+ try:
205
+ fetch_cached(url, dest, pause=0.8)
206
+ chunks = _doc_chunks(code, short, name, url, lang,
207
+ _pdf_pages(dest))
208
+ except Exception as exc:
209
+ print(f" !! {short} ({lang}): {type(exc).__name__}: {exc}")
210
+ continue
211
+ print(f" {short:24} ({lang}): {len(chunks)} chunks")
212
+ all_chunks.extend(chunks)
213
+ try:
214
+ links = _vssa_links()
215
+ except Exception as exc:
216
+ links = []
217
+ print(f" !! VSSA index: {type(exc).__name__}: {exc}")
218
+ n_vssa = 0
219
+ for url, stem in links:
220
+ dest = RAW / f"vssa-{stem.lower()}.pdf"
221
+ try:
222
+ fetch_cached(url, dest, pause=0.6)
223
+ chunks = _vssa_chunks(url, stem, _pdf_pages(dest))
224
+ except Exception as exc:
225
+ print(f" !! VSSA {stem}: {type(exc).__name__}: {exc}")
226
+ continue
227
+ all_chunks.extend(chunks)
228
+ n_vssa += 1
229
+ print(f" VSSA sites: {n_vssa}")
230
+ uniquify_ids(all_chunks)
231
+ out = PROCESSED_DIR / "ciu.json"
232
+ out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
233
+ encoding="utf-8")
234
+ print(f"ciu: {len(all_chunks)} chunks -> {out.name}")
235
+ return all_chunks
236
+
237
+
238
+ if __name__ == "__main__":
239
+ build()
canlex/commentary.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sys
16
+
17
+ from ._common import write_corpus
18
+ from .config import DATA_DIR, PROCESSED_DIR
19
+
20
+ CURATED = DATA_DIR / "curated" / "us_dispositions.json"
21
+ EQUIV = DATA_DIR / "curated" / "us_equivalency.json"
22
+ OUT = PROCESSED_DIR / "commentary.json"
23
+
24
+ ACT_CODE = "US-DISP"
25
+ ACT_SHORT = "US Dispositions Helper"
26
+ ACT_NAME = ("US criminal dispositions and the IRPA 'conviction' concept "
27
+ "(curated CanLex commentary)")
28
+
29
+ BANNER = ("CURATED ANALYSIS -- commentary compiled for CanLex, not a source "
30
+ "of law. Verify against the cited authorities before relying on it.")
31
+
32
+ _STATUS_LABEL = {
33
+ "settled": "Settled by binding authority",
34
+ "judicially-considered": "Judicially considered (persuasive authority)",
35
+ "guidance-only": "IRCC guidance only -- no judicial authority located",
36
+ "no-authority": "NO AUTHORITY LOCATED -- reasoned interpretation only",
37
+ }
38
+
39
+ # Verdicts assess the COMPLETED disposition; the methodology chunk explains
40
+ # the rule each one derives from. 'depends' is the legacy spelling of
41
+ # fact-specific, kept for backwards compatibility.
42
+ _VERDICT_LABEL = {
43
+ "yes": "YES", "likely-yes": "LIKELY YES", "likely-no": "LIKELY NO",
44
+ "no": "NO", "fact-specific": "FACT-SPECIFIC",
45
+ "depends": "FACT-SPECIFIC",
46
+ }
47
+
48
+
49
+ def _vlabel(v):
50
+ return _VERDICT_LABEL.get(v, str(v).upper())
51
+
52
+
53
+ def _entry_text(e, max_states=None):
54
+ """Render one disposition entry as a readable, retrieval-friendly block.
55
+
56
+ With the 51-jurisdiction survey an entry can carry dozens of state rows;
57
+ max_states caps how many render inline (the corpus keeps full per-state
58
+ detail in separate per-state chunks so retrieval still reaches every
59
+ row). None renders everything -- the tool uses that for direct lookups."""
60
+ lines = [BANNER, ""]
61
+ lines.append(f"Disposition: {'; '.join(e['names'])}")
62
+ lines.append(f"Is the COMPLETED disposition a conviction for IRPA s. 36: "
63
+ f"{_vlabel(e['is_conviction'])}")
64
+ lines.append(f"Authority status: {_STATUS_LABEL[e['status']]}")
65
+ if e.get("bottom_line"):
66
+ lines.append("")
67
+ lines.append(f"BOTTOM LINE: {e['bottom_line']}")
68
+ lines.append("")
69
+ lines.append(e["analysis"])
70
+ if e.get("state_variations"):
71
+ named = [v for v in e["state_variations"]
72
+ if v["state"].lower() != "general"]
73
+ shown = named if max_states is None else named[:max_states]
74
+ lines.append("")
75
+ if max_states is not None and len(named) > len(shown):
76
+ from collections import Counter
77
+ counts = Counter(v.get("is_conviction", "depends") for v in named)
78
+ lines.append(f"State-by-state coverage: {len(named)} jurisdictions "
79
+ f"({', '.join(f'{k}: {n}' for k, n in counts.most_common())}) "
80
+ f"-- full per-state detail in the per-state entries.")
81
+ else:
82
+ lines.append("State variations:")
83
+ for v in shown:
84
+ flag = (f" (conviction: {_vlabel(v['is_conviction'])})"
85
+ if v.get("is_conviction") else "")
86
+ lines.append(f"- {v['state']}{flag}: {v['note']}")
87
+ for v in e["state_variations"]:
88
+ if v["state"].lower() == "general":
89
+ lines.append(f"- General: {v['note']}")
90
+ if e.get("authorities"):
91
+ lines.append("")
92
+ lines.append("Authorities:")
93
+ for a in e["authorities"]:
94
+ pin = f", {a['pin']}" if a.get("pin") else ""
95
+ lines.append(f"- {a['cite']} ({a['court']}{pin}): {a['holding']}")
96
+ if e.get("guidance"):
97
+ lines.append("")
98
+ lines.append("IRCC guidance (cited by reference; not reproduced here):")
99
+ for g in e["guidance"]:
100
+ lines.append(f"- {g['ref']}: {g['note']}")
101
+ if e.get("interpretation"):
102
+ lines.append("")
103
+ lines.append("INTERPRETATION (no direct authority -- this is CanLex's "
104
+ "reasoned view from the governing principles; treat it as "
105
+ "a starting point, not an answer): "
106
+ + e["interpretation"])
107
+ return "\n".join(lines)
108
+
109
+
110
+ def build(allow_shrink=False):
111
+ data = json.loads(CURATED.read_text(encoding="utf-8"))
112
+ chunks = []
113
+ for e in data.get("methodology", []):
114
+ chunks.append({
115
+ "id": f"commentary-method-{e['id']}",
116
+ "doc_type": "commentary",
117
+ "act_code": ACT_CODE,
118
+ "act_short": ACT_SHORT,
119
+ "act_name": ACT_NAME,
120
+ "section": e["id"],
121
+ "marginal_note": e["title"],
122
+ "part": "Methodology",
123
+ "division": "",
124
+ "heading": e["title"],
125
+ "text": BANNER + "\n\n" + e["text"],
126
+ "history": "",
127
+ "last_amended": "",
128
+ "current_to": data.get("reviewed", ""),
129
+ "citation": f"{ACT_SHORT} — {e['title']}",
130
+ "source_url": "",
131
+ })
132
+ for e in data.get("dispositions", []):
133
+ chunks.append({
134
+ "id": f"commentary-disp-{e['id']}",
135
+ "doc_type": "commentary",
136
+ "act_code": ACT_CODE,
137
+ "act_short": ACT_SHORT,
138
+ "act_name": ACT_NAME,
139
+ "section": e["id"],
140
+ "marginal_note": e["names"][0],
141
+ "part": "US dispositions",
142
+ "division": "",
143
+ "heading": (f"Is a US {e['names'][0]} a conviction for IRPA "
144
+ f"s. 36? ({_vlabel(e['is_conviction'])})"),
145
+ "text": _entry_text(e, max_states=6),
146
+ "history": "",
147
+ "last_amended": "",
148
+ "current_to": data.get("reviewed", ""),
149
+ "citation": f"{ACT_SHORT} — {e['names'][0]}",
150
+ "source_url": "",
151
+ })
152
+
153
+ # One chunk per jurisdiction: every disposition row for that state, so a
154
+ # query naming a state ("Georgia first offender act", "Missouri SIS")
155
+ # retrieves that state's page directly.
156
+ by_state = {}
157
+ for e in data.get("dispositions", []):
158
+ for v in e.get("state_variations", []):
159
+ st = v["state"]
160
+ if st.lower() == "general":
161
+ continue
162
+ flag = _vlabel(v.get("is_conviction") or e["is_conviction"])
163
+ by_state.setdefault(st, []).append(
164
+ f"- {e['names'][0]} (conviction: {flag}): {v['note']}")
165
+ for st in sorted(by_state):
166
+ body = (BANNER + "\n\n"
167
+ + f"US dispositions — {st}: whether each disposition type is a "
168
+ f"conviction for IRPA s. 36, under {st} law.\n\n"
169
+ + "\n".join(by_state[st])
170
+ + "\n\nThe act branch (IRPA s. 36(1)(c)/(2)(c)) can apply even "
171
+ "where a disposition is not a conviction. See the "
172
+ "per-disposition entries for the governing analysis and "
173
+ "authorities.")
174
+ slug = st.lower().replace(" ", "-")
175
+ chunks.append({
176
+ "id": f"commentary-state-{slug}",
177
+ "doc_type": "commentary",
178
+ "act_code": ACT_CODE,
179
+ "act_short": ACT_SHORT,
180
+ "act_name": ACT_NAME,
181
+ "section": f"state-{slug}",
182
+ "marginal_note": f"US dispositions — {st}",
183
+ "part": "US dispositions by state",
184
+ "division": "",
185
+ "heading": (f"{st}: criminal dispositions vs the IRPA "
186
+ f"'conviction' concept"),
187
+ "text": body,
188
+ "history": "",
189
+ "last_amended": "",
190
+ "current_to": data.get("reviewed", ""),
191
+ "citation": f"{ACT_SHORT} — {st}",
192
+ "source_url": "",
193
+ })
194
+ # --- equivalency pairings (step 2), same banner discipline
195
+ n_pairings = 0
196
+ if EQUIV.exists():
197
+ eq = json.loads(EQUIV.read_text(encoding="utf-8"))
198
+ for m in eq.get("methodology", []):
199
+ chunks.append({
200
+ "id": f"commentary-method-{m['id']}",
201
+ "doc_type": "commentary",
202
+ "act_code": ACT_CODE,
203
+ "act_short": ACT_SHORT,
204
+ "act_name": ACT_NAME,
205
+ "section": m["id"],
206
+ "marginal_note": m["title"],
207
+ "part": "Methodology",
208
+ "division": "",
209
+ "heading": m["title"],
210
+ "text": BANNER + "\n\n" + m["text"],
211
+ "history": "",
212
+ "last_amended": "",
213
+ "current_to": eq.get("reviewed", ""),
214
+ "citation": f"{ACT_SHORT} — {m['title']}",
215
+ "source_url": "",
216
+ })
217
+ for p in eq.get("pairings", []):
218
+ n_pairings += 1
219
+ lines = [BANNER, "",
220
+ f"US offence: {'; '.join(p['us_terms'][:5])}",
221
+ f"Canadian equivalent: {p['canadian_offence']}",
222
+ f"Maximum penalty (verified): {p['penalty']}",
223
+ f"Inadmissibility branch: {p['branch']}", "",
224
+ p["analysis"]]
225
+ if p.get("caveats"):
226
+ lines.append("")
227
+ lines.append("Caveats:")
228
+ lines += [f"- {c}" for c in p["caveats"]]
229
+ if p.get("authorities"):
230
+ lines.append("")
231
+ lines.append("Authorities:")
232
+ lines += [f"- {a['cite']} ({a['court']}): {a['holding']}"
233
+ for a in p["authorities"]]
234
+ chunks.append({
235
+ "id": f"commentary-equiv-{p['id']}",
236
+ "doc_type": "commentary",
237
+ "act_code": ACT_CODE,
238
+ "act_short": ACT_SHORT,
239
+ "act_name": ACT_NAME,
240
+ "section": f"equiv-{p['id']}",
241
+ "marginal_note": f"Equivalency: {p['us_terms'][0]}",
242
+ "part": "US offence equivalency",
243
+ "division": "",
244
+ "heading": (f"What does a US {p['us_terms'][0]} conviction "
245
+ f"equate to in Canada?"),
246
+ "text": "\n".join(lines),
247
+ "history": "",
248
+ "last_amended": "",
249
+ "current_to": eq.get("reviewed", ""),
250
+ "citation": f"{ACT_SHORT} — equivalency: {p['us_terms'][0]}",
251
+ "source_url": "",
252
+ })
253
+
254
+ # A full re-render of the curated files, so a truncated or half-edited
255
+ # us_dispositions.json (or an equivalency file that moved) collapses the
256
+ # chunk count and the write would replace good analysis with the remnant.
257
+ # indent=1 is the stored file's format -- changing it rewrites every line.
258
+ if not write_corpus(OUT, chunks, indent=1, allow_shrink=allow_shrink):
259
+ return False
260
+ print(f"{len(chunks)} commentary chunks "
261
+ f"({len(data.get('dispositions', []))} dispositions, "
262
+ f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
263
+ f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
264
+ return True
265
+
266
+
267
+ def main():
268
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
269
+
270
+
271
+ if __name__ == "__main__":
272
+ main()
canlex/config.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "CRC-604": {
76
+ "short": "Import Control List", "name": "Import Control List",
77
+ "kind": "regulation",
78
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.,_c._604.xml",
79
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.,_c._604/"},
80
+ "SOR-89-202": {
81
+ "short": "Export Control List", "name": "Export Control List",
82
+ "kind": "regulation",
83
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-89-202.xml",
84
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-89-202/"},
85
+ "SOR-2003-323": {
86
+ "short": "Presentation of Persons Regs",
87
+ "name": "Presentation of Persons (2003) Regulations", "kind": "regulation",
88
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-323.xml",
89
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-323/"},
90
+ "SOR-86-1072": {
91
+ "short": "Duty Free Shop Regs", "name": "Duty Free Shop Regulations",
92
+ "kind": "regulation",
93
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1072.xml",
94
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1072/"},
95
+ "SOR-86-1065": {
96
+ "short": "Sufferance Warehouses Regs",
97
+ "name": "Customs Sufferance Warehouses Regulations", "kind": "regulation",
98
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1065.xml",
99
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1065/"},
100
+ "S-8.35": {
101
+ "short": "Luxury Tax Act", "name": "Select Luxury Items Tax Act", "kind": "act",
102
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-8.35.xml",
103
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-8.35/"},
104
+ "C-1.4": {
105
+ "short": "CBSA Act", "name": "Canada Border Services Agency Act", "kind": "act",
106
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-1.4.xml",
107
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"},
108
+ "S-15": {
109
+ "short": "SIMA", "name": "Special Import Measures Act", "kind": "act",
110
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-15.xml",
111
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-15/"},
112
+ "E-19": {
113
+ "short": "EIPA", "name": "Export and Import Permits Act", "kind": "act",
114
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-19.xml",
115
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-19/"},
116
+ "P-19.32": {
117
+ "short": "Preclearance Act", "name": "Preclearance Act, 2016", "kind": "act",
118
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-19.32.xml",
119
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-19.32/"},
120
+ # Customs Act operational regulations -- the procedural detail behind the
121
+ # bare Act (reporting, accounting, exemptions, and the designated-provisions
122
+ # regulation that is the legal hook for every AMPS monetary penalty).
123
+ "SOR-86-873": {
124
+ "short": "Reporting of Imported Goods Regs",
125
+ "name": "Reporting of Imported Goods Regulations", "kind": "regulation",
126
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-873.xml",
127
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-873/"},
128
+ "SOR-86-1062": {
129
+ "short": "Accounting for Imported Goods Regs",
130
+ "name": "Accounting for Imported Goods and Payment of Duties Regulations",
131
+ "kind": "regulation",
132
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1062.xml",
133
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1062/"},
134
+ "SOR-2002-336": {
135
+ "short": "Designated Provisions (Customs) Regs",
136
+ "name": "Designated Provisions (Customs) Regulations", "kind": "regulation",
137
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-336.xml",
138
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-336/"},
139
+ "SOR-96-46": {
140
+ "short": "Customs Bonded Warehouses Regs",
141
+ "name": "Customs Bonded Warehouses Regulations", "kind": "regulation",
142
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-46.xml",
143
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-46/"},
144
+ "SOR-98-58": {
145
+ "short": "Returning Persons Exemption Regs",
146
+ "name": "Returning Persons Exemption Regulations", "kind": "regulation",
147
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-58.xml",
148
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-58/"},
149
+
150
+ # --- Criminal & financial crime ---
151
+ "C-46": {
152
+ "short": "Criminal Code", "name": "Criminal Code", "kind": "act",
153
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-46.xml",
154
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-46/"},
155
+ "F-11.6": {
156
+ "short": "Firearms Act", "name": "Firearms Act", "kind": "act",
157
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.6.xml",
158
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11.6/"},
159
+ # Made under Criminal Code s. 117.15: the model-by-model prohibited/
160
+ # restricted/non-restricted classifications (incl. the May 2020
161
+ # SOR/2020-96 prohibitions). Nearly all substance is in its Schedules --
162
+ # this entry is why the schedule parser exists.
163
+ "SOR-98-462": {
164
+ "short": "Firearms Classification Regs",
165
+ "name": ("Regulations Prescribing Certain Firearms and Other Weapons, "
166
+ "Components and Parts of Weapons, Accessories, Cartridge "
167
+ "Magazines, Ammunition and Projectiles as Prohibited, "
168
+ "Restricted or Non-Restricted"), "kind": "regulation",
169
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-462.xml",
170
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-462/"},
171
+ "P-24.501": {
172
+ "short": "PCMLTFA",
173
+ "name": "Proceeds of Crime (Money Laundering) and Terrorist Financing Act",
174
+ "kind": "act",
175
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-24.501.xml",
176
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-24.501/"},
177
+ "SOR-2002-412": {
178
+ "short": "Cross-border Currency Reporting Regs",
179
+ "name": "Cross-border Currency and Monetary Instruments Reporting Regulations",
180
+ "kind": "regulation",
181
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-412.xml",
182
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-412/"},
183
+ "S-6.9": {
184
+ "short": "SCIDA", "name": "Security of Canada Information Disclosure Act",
185
+ "kind": "act",
186
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.9.xml",
187
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.9/"},
188
+
189
+ # --- Controlled substances & cannabis ---
190
+ "C-38.8": {
191
+ "short": "CDSA", "name": "Controlled Drugs and Substances Act", "kind": "act",
192
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.8.xml",
193
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.8/"},
194
+ "SOR-97-234": {
195
+ "short": "CDSA Police Enforcement Regs",
196
+ "name": "Controlled Drugs and Substances Act (Police Enforcement) Regulations",
197
+ "kind": "regulation",
198
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-234.xml",
199
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-234/"},
200
+ "SOR-2000-217": {
201
+ "short": "Benzodiazepines Regs",
202
+ "name": "Benzodiazepines and Other Targeted Substances Regulations",
203
+ "kind": "regulation",
204
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2000-217.xml",
205
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2000-217/"},
206
+ "C.R.C.,_c._1041": {
207
+ "short": "Narcotic Control Regs", "name": "Narcotic Control Regulations",
208
+ "kind": "regulation",
209
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._1041.xml",
210
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._1041/"},
211
+ "SOR-2012-230": {
212
+ "short": "New Classes of Practitioners Regs",
213
+ "name": "New Classes of Practitioners Regulations", "kind": "regulation",
214
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-230.xml",
215
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-230/"},
216
+ "SOR-2002-359": {
217
+ "short": "Precursor Control Regs", "name": "Precursor Control Regulations",
218
+ "kind": "regulation",
219
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-359.xml",
220
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-359/"},
221
+ "SOR-97-229": {
222
+ "short": "Precursor & Substance Exemption Regs",
223
+ "name": "Regulations Exempting Certain Precursors and Controlled Substances "
224
+ "from the Application of the Controlled Drugs and Substances Act",
225
+ "kind": "regulation",
226
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-229.xml",
227
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-229/"},
228
+ "C-24.5": {
229
+ "short": "Cannabis Act", "name": "Cannabis Act", "kind": "act",
230
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-24.5.xml",
231
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-24.5/"},
232
+ "SOR-2018-144": {
233
+ "short": "Cannabis Regs", "name": "Cannabis Regulations", "kind": "regulation",
234
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-144.xml",
235
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-144/"},
236
+
237
+ # --- Food, health, agriculture ---
238
+ "F-27": {
239
+ "short": "Food and Drugs Act", "name": "Food and Drugs Act", "kind": "act",
240
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-27.xml",
241
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-27/"},
242
+ "C.R.C.,_c._870": {
243
+ "short": "Food and Drug Regs", "name": "Food and Drug Regulations",
244
+ "kind": "regulation",
245
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._870.xml",
246
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._870/"},
247
+ "H-3.3": {
248
+ "short": "Health of Animals Act", "name": "Health of Animals Act", "kind": "act",
249
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-3.3.xml",
250
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-3.3/"},
251
+ "C.R.C.,_c._296": {
252
+ "short": "Health of Animals Regs", "name": "Health of Animals Regulations",
253
+ "kind": "regulation",
254
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._296.xml",
255
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._296/"},
256
+ "P-14.8": {
257
+ "short": "Plant Protection Act", "name": "Plant Protection Act", "kind": "act",
258
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-14.8.xml",
259
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-14.8/"},
260
+ "SOR-95-212": {
261
+ "short": "Plant Protection Regs", "name": "Plant Protection Regulations",
262
+ "kind": "regulation",
263
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-95-212.xml",
264
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-95-212/"},
265
+ "SOR-2018-108": {
266
+ "short": "Safe Food for Canadians Regs",
267
+ "name": "Safe Food for Canadians Regulations", "kind": "regulation",
268
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-108.xml",
269
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-108/"},
270
+ "A-8.8": {
271
+ "short": "AAAMPA",
272
+ "name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
273
+ "kind": "act",
274
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-8.8.xml",
275
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/"},
276
+ "Q-1.1": {
277
+ "short": "Quarantine Act", "name": "Quarantine Act", "kind": "act",
278
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Q-1.1.xml",
279
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/q-1.1/"},
280
+ # CITES enforcement at the border -- the basis for seizures of protected
281
+ # wildlife, ivory and exotic-species goods.
282
+ "W-8.5": {
283
+ "short": "WAPPRIITA",
284
+ "name": "Wild Animal and Plant Protection and Regulation of "
285
+ "International and Interprovincial Trade Act", "kind": "act",
286
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/W-8.5.xml",
287
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/w-8.5/"},
288
+ "SOR-96-263": {
289
+ "short": "WAPTR",
290
+ "name": "Wild Animal and Plant Trade Regulations", "kind": "regulation",
291
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-263.xml",
292
+ "web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-263/"},
293
+
294
+ # --- Labour & public service ---
295
+ # CHRA grounds the accommodation/discrimination case law already in the
296
+ # corpus (Renaud, Hydro-Québec, Elk Valley); GECA governs injury-on-duty
297
+ # compensation interlocking with the FB agreement's IOD leave.
298
+ "H-6": {
299
+ "short": "CHRA", "name": "Canadian Human Rights Act", "kind": "act",
300
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-6.xml",
301
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-6/"},
302
+ "G-5": {
303
+ "short": "GECA", "name": "Government Employees Compensation Act",
304
+ "kind": "act",
305
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/G-5.xml",
306
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/g-5/"},
307
+ "L-2": {
308
+ "short": "Canada Labour Code", "name": "Canada Labour Code", "kind": "act",
309
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/L-2.xml",
310
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/l-2/"},
311
+ "P-33.3": {
312
+ "short": "FPSLRA", "name": "Federal Public Sector Labour Relations Act",
313
+ "kind": "act",
314
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.3.xml",
315
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.3/"},
316
+ "P-33.35": {
317
+ "short": "FPSLREB Act",
318
+ "name": "Federal Public Sector Labour Relations and Employment Board Act",
319
+ "kind": "act",
320
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.35.xml",
321
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.35/"},
322
+ "P-33.01": {
323
+ "short": "PSEA", "name": "Public Service Employment Act", "kind": "act",
324
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.01.xml",
325
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.01/"},
326
+ "P-36": {
327
+ "short": "PSSA", "name": "Public Service Superannuation Act", "kind": "act",
328
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-36.xml",
329
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-36/"},
330
+
331
+ # --- Privacy ---
332
+ "P-21": {
333
+ "short": "Privacy Act", "name": "Privacy Act", "kind": "act",
334
+ "xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-21.xml",
335
+ "web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-21/"},
336
+ }
canlex/delegation.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
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)
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):
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
+ chunks = parse_cbsa(html, src)
470
+ elif src["kind"] == "html-cbsa-narrative":
471
+ html = _fetch(src["url"], RAW / f"{src['code']}.html")
472
+ chunks = parse_cbsa_narrative(html, src)
473
+ elif src["kind"] == "pdf-ircc":
474
+ pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
475
+ powershell=True)
476
+ chunks = parse_ircc(pdf, src)
477
+ else:
478
+ chunks = []
479
+ except Exception as exc:
480
+ print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
481
+ failures.append((src["act_code"], f"{type(exc).__name__}: {exc}"))
482
+ continue
483
+ if not chunks:
484
+ failures.append((src["act_code"], "no content parsed"))
485
+ all_chunks.extend(chunks)
486
+ print(f" {len(chunks)} chunks")
487
+ uniquify_ids(all_chunks)
488
+
489
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
490
+ stored_chunks(OUT))
491
+ if preserved:
492
+ print(f" kept the last-good copy of {len(preserved)} instrument(s): "
493
+ f"{', '.join(preserved)}")
494
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
495
+ return False
496
+ print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
497
+ f"instrument(s) -> {OUT}")
498
+ return True
499
+
500
+
501
+ def main():
502
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
503
+
504
+
505
+ if __name__ == "__main__":
506
+ 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,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, fetch_cached, norm_ws as _norm,
17
+ preserve_dropped, split_lines, stored_chunks,
18
+ 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
+ return _pdf_clean("\n".join((p.extract_text() or "") for p in reader.pages))
188
+ except Exception:
189
+ return ""
190
+
191
+
192
+ def _split(text, target=3000):
193
+ """Split long PDF text into ~target-sized pieces at line boundaries."""
194
+ return split_lines(text, target)
195
+
196
+
197
+ def parse_pdf_memo(html, url):
198
+ """Fallback for memos whose HTML page is only a stub linking to a PDF."""
199
+ soup = BeautifulSoup(html, "html.parser")
200
+ main = soup.find("main")
201
+ if main is None:
202
+ return []
203
+ pdf_href = next((a["href"] for a in main.find_all("a", href=True)
204
+ if a["href"].lower().endswith(".pdf")), None)
205
+ if not pdf_href:
206
+ return []
207
+ pdf_url = urljoin(url, pdf_href)
208
+
209
+ match = _URL_NUMBER.search(url)
210
+ number = match.group(1).upper() if match else url
211
+ h1 = main.find("h1")
212
+ topic = _norm(h1.get_text(" ", strip=True)) if h1 else ""
213
+ topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "", topic, flags=re.I)
214
+ dm = main.find("time", attrs={"property": "dateModified"})
215
+ date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
216
+
217
+ pdf_bytes = _fetch(pdf_url, DMEMO_DIR / "pdf" / pdf_url.rsplit("/", 1)[-1])
218
+ text = _pdf_text(pdf_bytes)
219
+ if not text:
220
+ return []
221
+
222
+ parts = _split(text)
223
+ chunks = []
224
+ for i, part in enumerate(parts, 1):
225
+ label = topic or number
226
+ if len(parts) > 1:
227
+ label = f"{label} (part {i})"
228
+ chunks.append({
229
+ "id": f"dmemo-{number}-pdf{i}",
230
+ "doc_type": "memorandum",
231
+ "act_code": "D-Memo",
232
+ "act_short": "D-Memo",
233
+ "act_name": "CBSA D-Memoranda",
234
+ "section": number,
235
+ "marginal_note": label,
236
+ "part": topic,
237
+ "division": "",
238
+ "heading": "",
239
+ "text": part,
240
+ "history": "",
241
+ "last_amended": date,
242
+ "current_to": date,
243
+ "citation": f"Memorandum {number}",
244
+ "source_url": url,
245
+ })
246
+ return chunks
247
+
248
+
249
+ def preserve_failed(chunks, failures, stored):
250
+ """Re-attach the last-good chunks of memos that could not be fetched.
251
+
252
+ Ingestion is a full rebuild, so anything that fails a fetch disappears from
253
+ the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
254
+ whose pages 404, and a transient network error looks the same -- in both
255
+ cases dropping guidance that is still in force is worse than serving the
256
+ copy we already had. Restricted to memos that actually failed, so the
257
+ cancelled ones (filtered out before fetching) are not resurrected.
258
+ Returns (chunks, preserved memo numbers).
259
+ """
260
+ failed = set()
261
+ for url, _why in failures:
262
+ match = _URL_NUMBER.search(url)
263
+ if match:
264
+ failed.add(match.group(1).upper())
265
+ return preserve_dropped(chunks, stored,
266
+ key=lambda c: c["section"].upper(), only=failed)
267
+
268
+
269
+ def ingest(force=False, limit=None, allow_shrink=False):
270
+ urls = memo_urls(force=force)
271
+ if limit:
272
+ urls = urls[:limit]
273
+ print(f"Ingesting {len(urls)} D-Memoranda...")
274
+ all_chunks, failures = [], []
275
+ for i, url in enumerate(urls, 1):
276
+ try:
277
+ html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
278
+ chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
279
+ if chunks:
280
+ all_chunks.extend(chunks)
281
+ else:
282
+ failures.append((url, "no content parsed"))
283
+ except Exception as exc:
284
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
285
+ if i % 50 == 0:
286
+ print(f" {i}/{len(urls)} ...")
287
+ if failures:
288
+ print(f" {len(failures)} memos with no content / errors:")
289
+ for url, why in failures[:15]:
290
+ print(f" - {url.rsplit('/', 1)[-1]}: {why}")
291
+
292
+ if not limit:
293
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
294
+ stored_chunks(OUT_FILE))
295
+ if preserved:
296
+ print(f" kept the last-good copy of {len(preserved)} unfetchable "
297
+ f"memo(s): {', '.join(preserved)}")
298
+ # The guard applies to --limit runs too: a smoke test over five memos must
299
+ # not be able to write a five-memo corpus over the real one.
300
+ if not write_corpus(OUT_FILE, all_chunks, allow_shrink=allow_shrink):
301
+ return False
302
+ print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
303
+ f"-> {OUT_FILE.name}")
304
+ return True
305
+
306
+
307
+ def ingest_fr(force=False, limit=None, allow_shrink=False):
308
+ """The French editions (Mémorandums D), mirroring ingest().
309
+
310
+ Same pages at -fra.html, same WET markup, same parser; the chunks are
311
+ post-processed with lang='fr', a dmemo-fr- id prefix and the French
312
+ citation so language-routed retrieval (index.query_lang) serves them
313
+ only to French queries."""
314
+ try:
315
+ urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
316
+ DMEMO_DIR / "_datasource-fra.json",
317
+ force=force))
318
+ except Exception as exc:
319
+ print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
320
+ urls = []
321
+ if limit:
322
+ urls = urls[:limit]
323
+ print(f"Ingesting {len(urls)} Mémorandums D (français)...")
324
+ all_chunks, failures = [], []
325
+ for i, url in enumerate(urls, 1):
326
+ try:
327
+ html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
328
+ chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
329
+ if chunks:
330
+ all_chunks.extend(chunks)
331
+ else:
332
+ failures.append((url, "no content parsed"))
333
+ except Exception as exc:
334
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
335
+ if i % 50 == 0:
336
+ print(f" {i}/{len(urls)} ...")
337
+ for c in all_chunks:
338
+ c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
339
+ c["lang"] = "fr"
340
+ c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
341
+ if failures:
342
+ print(f" {len(failures)} mémorandums with no content / errors "
343
+ f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
344
+ if not limit:
345
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
346
+ stored_chunks(OUT_FILE_FR))
347
+ if preserved:
348
+ print(f" kept the last-good copy of {len(preserved)} "
349
+ f"unfetchable mémorandum(s)")
350
+ if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
351
+ return False
352
+ print(f" {len(all_chunks)} section-chunks from "
353
+ f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
354
+ return True
355
+
356
+
357
+ def main():
358
+ force = "--force" in sys.argv
359
+ limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
360
+ if a.startswith("--limit=")), None)
361
+ runner = ingest_fr if "--fr" in sys.argv else ingest
362
+ ok = runner(force=force, limit=limit,
363
+ allow_shrink="--allow-shrink" in sys.argv)
364
+ sys.exit(0 if ok else 1)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()
canlex/embed.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import json
9
+ import os
10
+
11
+ import numpy as np
12
+ import onnxruntime as ort
13
+ from huggingface_hub import hf_hub_download
14
+ from tokenizers import Tokenizer
15
+
16
+ from .config import PROCESSED_DIR
17
+
18
+ # The embedding model is env-configurable so an alternative (e.g. a multilingual
19
+ # model for French support) can be trialled without code edits; the defaults
20
+ # reproduce the production bge-small-en-v1.5 setup exactly. A model swap means
21
+ # matching three things to that model's training: the HF repo, the pooling
22
+ # ('cls' for BGE, 'mean' for E5/MiniLM), and the query/passage prefixes (BGE
23
+ # prefixes only the query; E5 prefixes both with 'query: ' / 'passage: ').
24
+ EMB_REPO = os.environ.get("CANLEX_EMB_REPO", "Xenova/bge-small-en-v1.5")
25
+ _POOLING = os.environ.get("CANLEX_EMB_POOLING", "cls").lower()
26
+ _QUERY_PREFIX = os.environ.get(
27
+ "CANLEX_EMB_QUERY_PREFIX",
28
+ "Represent this sentence for searching relevant passages: ")
29
+ _PASSAGE_PREFIX = os.environ.get("CANLEX_EMB_PASSAGE_PREFIX", "")
30
+ EMB_PATH = PROCESSED_DIR / "embeddings.npz"
31
+ _MAX_TOKENS = 512
32
+ _MAX_BODY = 2000 # cap embedded body text so long sections stay topically focused
33
+
34
+
35
+ def load_chunks():
36
+ chunks = []
37
+ for path in sorted(PROCESSED_DIR.glob("*.json")):
38
+ chunks.extend(json.loads(path.read_text(encoding="utf-8")))
39
+ return chunks
40
+
41
+
42
+ # A chunk longer than the embed window gets additional vectors for its tail,
43
+ # up to this many per chunk (an 87K-char D-memo section would otherwise need
44
+ # 40+; eight windows cover 16K chars, past which BM25 carries the tail).
45
+ _MAX_WINDOWS = 8
46
+
47
+
48
+ def embed_text(chunk):
49
+ """Compact, retrieval-focused representation of one section (head only)."""
50
+ # The section title is the strongest topical signal, so it is repeated to
51
+ # emphasise it. Title selection is doc_type-aware (see index.topical_title):
52
+ # a D-memo's marginal_note is a generic banner so its actual subject in
53
+ # 'part' is used; a case-law chunk's marginal_note is just the paragraph
54
+ # range so the case proposition in 'heading' is used.
55
+ from .index import topical_title
56
+ note = topical_title(chunk)
57
+ body = chunk["text"][:_MAX_BODY]
58
+ parts = [chunk["act_short"], note, note, chunk["heading"], body]
59
+ return " . ".join(p for p in parts if p)
60
+
61
+
62
+ def embed_texts(chunk):
63
+ """One representation per embedding window of a chunk.
64
+
65
+ The first is the classic head representation; a chunk whose body exceeds
66
+ the embed window additionally gets tail windows (same topical-title
67
+ anchor, successive body slices), so a fact buried mid-memo is reachable
68
+ through semantic recall instead of BM25 alone. All windows share the
69
+ chunk's id -- the index scores a chunk by its best window."""
70
+ from .index import topical_title
71
+ texts = [embed_text(chunk)]
72
+ body = chunk["text"]
73
+ if len(body) > _MAX_BODY:
74
+ note = topical_title(chunk)
75
+ anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
76
+ for start in range(_MAX_BODY, len(body), _MAX_BODY):
77
+ if len(texts) >= _MAX_WINDOWS:
78
+ break
79
+ texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
80
+ return texts
81
+
82
+
83
+ class Embedder:
84
+ """Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
85
+
86
+ No API key; the model is downloaded once and cached. Produces L2-normalized
87
+ vectors, so a dot product between them is cosine similarity.
88
+ """
89
+
90
+ def __init__(self):
91
+ model_path = None
92
+ for name in ("onnx/model_quantized.onnx", "onnx/model.onnx"):
93
+ try:
94
+ model_path = hf_hub_download(EMB_REPO, name)
95
+ break
96
+ except Exception:
97
+ continue
98
+ if model_path is None:
99
+ raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
100
+ tok_path = hf_hub_download(EMB_REPO, "tokenizer.json")
101
+ from .rerank import make_ort_session
102
+ self.session = make_ort_session(model_path)
103
+ self.input_names = {i.name for i in self.session.get_inputs()}
104
+ self.tokenizer = Tokenizer.from_file(tok_path)
105
+ self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
106
+
107
+ def _run(self, texts):
108
+ """Tokenize, run the encoder, CLS-pool and L2-normalize one batch."""
109
+ encs = self.tokenizer.encode_batch(list(texts))
110
+ width = max(len(e.ids) for e in encs)
111
+ input_ids = np.zeros((len(encs), width), dtype=np.int64)
112
+ attention = np.zeros((len(encs), width), dtype=np.int64)
113
+ type_ids = np.zeros((len(encs), width), dtype=np.int64)
114
+ for row, enc in enumerate(encs):
115
+ n = len(enc.ids)
116
+ input_ids[row, :n] = enc.ids
117
+ attention[row, :n] = enc.attention_mask
118
+ type_ids[row, :n] = enc.type_ids
119
+ feed = {"input_ids": input_ids, "attention_mask": attention}
120
+ if "token_type_ids" in self.input_names:
121
+ feed["token_type_ids"] = type_ids
122
+ hidden = np.asarray(self.session.run(None, feed)[0], dtype=np.float32)
123
+ if hidden.ndim != 3:
124
+ pooled = hidden # already pooled
125
+ elif _POOLING == "mean": # E5 / MiniLM
126
+ mask = attention[:, :, None].astype(np.float32)
127
+ pooled = (hidden * mask).sum(1) / np.maximum(mask.sum(1), 1e-9)
128
+ else: # BGE: CLS token
129
+ pooled = hidden[:, 0, :]
130
+ norms = np.linalg.norm(pooled, axis=1, keepdims=True)
131
+ return pooled / np.maximum(norms, 1e-9)
132
+
133
+ def encode(self, texts, batch_size=32):
134
+ """Return L2-normalized embeddings for passages, one row per text."""
135
+ texts = [_PASSAGE_PREFIX + t for t in texts]
136
+ if not texts:
137
+ return np.zeros((0, 384), dtype=np.float32)
138
+ rows = [self._run(texts[i:i + batch_size])
139
+ for i in range(0, len(texts), batch_size)]
140
+ return np.vstack(rows)
141
+
142
+ def encode_query(self, text):
143
+ """Return the L2-normalized embedding for one search query."""
144
+ return self._run([_QUERY_PREFIX + text])[0]
145
+
146
+
147
+ def build():
148
+ chunks = load_chunks()
149
+ if not chunks:
150
+ print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
151
+ return
152
+ texts, ids = [], []
153
+ for c in chunks:
154
+ for t in embed_texts(c):
155
+ texts.append(t)
156
+ ids.append(c["id"])
157
+ print(f"Embedding {len(chunks)} sections as {len(texts)} windows "
158
+ f"with {EMB_REPO} ...")
159
+ vectors = Embedder().encode(texts)
160
+ np.savez(EMB_PATH, ids=np.array(ids), vectors=vectors)
161
+ print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ build()
canlex/enf.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (fetch_cached, norm_ws as _norm, preserve_dropped,
23
+ split_lines, stored_chunks, uniquify_ids, write_corpus)
24
+ from .config import PROCESSED_DIR, RAW_DIR
25
+
26
+ BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
27
+ "resources/manuals/enf/enf{n:02d}-eng.pdf")
28
+ RAW = RAW_DIR / "enf"
29
+ _CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
30
+
31
+ # Chapters whose current edition lives under a non-standard filename in the
32
+ # same directory (verified 2026-07-22 against the OBM Active-manuals index).
33
+ # ENF 3's old enf03-eng.pdf still returns 200 but is a STALE 2015-era copy;
34
+ # ENF 20 (Detention) was renamed enf20a-en.pdf (Aug 2024 edition).
35
+ _URL_OVERRIDES = {
36
+ 3: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
37
+ "resources/manuals/enf/enf-3-admissibility-(en)-final.pdf"),
38
+ 20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
39
+ "resources/manuals/enf/enf20a-en.pdf"),
40
+ }
41
+
42
+ # Chapters IRCC withdrew from public distribution whose only public text is
43
+ # an ATIP-released copy hosted by a third party (meurrensonimmigration.com,
44
+ # verified live 2026-07-22). Ingested at the maintainer's decision
45
+ # (2026-07-23) with an unmistakable UNOFFICIAL banner on every chunk: the
46
+ # text may lag the internal current version, and nothing here is an official
47
+ # publication. ENF 14/OP 19 is the operational manual behind criminal
48
+ # rehabilitation -- directly relevant to canlex_rehabilitation.
49
+ UNOFFICIAL = {
50
+ 2: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2023/06/"
51
+ "ENF2-3.pdf",
52
+ "title": "Evaluating Inadmissibility (unofficial ATIP copy)",
53
+ "note": "ATIP-released copy dated 2021-08-25; retired from canada.ca "
54
+ "in 2018 with no public successor"},
55
+ # ENF 14's ATIP release is a 56-page image scan with no text layer; its
56
+ # text comes from a Windows-OCR pass cached as enf14-ocr.txt (see the
57
+ # OCR fallback in build()). Regenerate the cache with pymupdf
58
+ # rasterization + Windows.Media.Ocr if the source PDF ever changes.
59
+ 14: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2025/03/"
60
+ "Enforcement-Manual-14-%E2%80%93-Rehabilitation.pdf",
61
+ "title": "Criminal Rehabilitation (ENF 14/OP 19, unofficial ATIP "
62
+ "copy)",
63
+ "note": "withdrawn from public distribution ~2007; ATIP-released "
64
+ "scan, text recovered by OCR (last internal change "
65
+ "2008-04-21) -- OCR artifacts possible"},
66
+ }
67
+ _UNOFFICIAL_BANNER = (
68
+ "[UNOFFICIAL ATIP COPY — this chapter was withdrawn from public "
69
+ "distribution; this text comes from an access-to-information release "
70
+ "hosted by a third party and may lag the current internal version. "
71
+ "Treat as historical guidance; verify against internal sources.] ")
72
+
73
+ # Curated titles for the core chapters; anything else gets a parsed or
74
+ # generic title. Only used for display -- retrieval works off the text.
75
+ TITLES = {
76
+ 1: "Inadmissibility",
77
+ 2: "Evaluating inadmissibility",
78
+ 3: "Admissibility hearings and detention review proceedings",
79
+ 4: "Port of entry examinations",
80
+ 5: "Writing 44(1) reports",
81
+ 6: "Review of reports under A44(1)",
82
+ 7: "Investigations and arrests",
83
+ 8: "Deposits and guarantees",
84
+ 9: "Judicial review",
85
+ 10: "Removals",
86
+ 11: "Verifying departure",
87
+ 12: "Search, seizure, fingerprinting and photographing",
88
+ 13: "Examining bank drafts and other negotiable instruments",
89
+ 14: "Investigations and arrests (war crimes)",
90
+ 15: "Obligations of transporters",
91
+ 17: "Maintaining confidence in the immigration program",
92
+ 18: "War crimes and crimes against humanity",
93
+ 19: "Appeals before the Immigration Appeal Division",
94
+ 20: "Detention",
95
+ 21: "Recovery of removal costs",
96
+ 23: "Loss of permanent resident status",
97
+ 27: "Permanent resident card",
98
+ 28: "Ministerial opinions on danger to the public",
99
+ 34: "Alternatives to prosecution",
100
+ }
101
+
102
+ _DATE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
103
+ # A manual heading line: "5. Departmental policy" / "5.1 Authority to ..."
104
+ _HEADING = re.compile(r"^(\d{1,2}(?:\.\d{1,2})?)\.?\s+([A-Z][^\n]{2,90})$")
105
+
106
+
107
+ def _pdf_text(path):
108
+ from pypdf import PdfReader
109
+ reader = PdfReader(str(path))
110
+ pages = []
111
+ for page in reader.pages:
112
+ try:
113
+ pages.append(page.extract_text() or "")
114
+ except Exception:
115
+ pages.append("")
116
+ return pages
117
+
118
+
119
+ def _chunks_for(n, pages, url):
120
+ title = TITLES.get(n, f"ENF {n}")
121
+ head = "\n".join(pages[:3])
122
+ dates = _DATE.findall(head)
123
+ updated = max(dates) if dates else ""
124
+ text = "\n".join(pages)
125
+ lines = text.split("\n")
126
+ # Segment on the manual's own numbered headings when enough are found;
127
+ # PDF extraction is uneven, so fall back to plain size-splitting.
128
+ marks = [(i, m.group(1), _norm(m.group(2)))
129
+ for i, l in enumerate(lines) if (m := _HEADING.match(l.strip()))]
130
+ segments = []
131
+ if len(marks) >= 6:
132
+ for j, (i, num, heading) in enumerate(marks):
133
+ end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
134
+ body = _norm_block(lines[i:end])
135
+ if len(body) > 120:
136
+ segments.append((f"{num}", heading, body))
137
+ if not segments:
138
+ for k, piece in enumerate(split_lines(text, 1800), start=1):
139
+ body = _norm_block(piece.split("\n"))
140
+ if len(body) > 120:
141
+ segments.append((str(k), f"{title} (part {k})", body))
142
+ out = []
143
+ for num, heading, body in segments:
144
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
145
+ total = len(pieces)
146
+ for k, piece in enumerate(pieces, start=1):
147
+ suffix = "" if total == 1 else f"-p{k}"
148
+ note = "" if total == 1 else f" (part {k} of {total})"
149
+ out.append({
150
+ "id": f"enf-{n}-{num.replace('.', '-')}{suffix}",
151
+ "doc_type": "memorandum",
152
+ "act_code": f"ENF-{n}",
153
+ "act_short": f"ENF {n}",
154
+ "act_name": f"ENF {n} — {title}",
155
+ "section": f"ENF {n} s. {num}",
156
+ "marginal_note": heading,
157
+ "part": title,
158
+ "division": "",
159
+ "heading": "",
160
+ "text": piece,
161
+ "history": "",
162
+ "last_amended": updated,
163
+ "current_to": updated,
164
+ "citation": f"ENF {n}, s. {num} ({heading}){note}",
165
+ "source_url": url,
166
+ })
167
+ return out
168
+
169
+
170
+ def _norm_block(lines):
171
+ return "\n".join(p for p in (_norm(l) for l in lines) if p)
172
+
173
+
174
+ _HTTP_404 = re.compile(r"\b404\b")
175
+
176
+
177
+ def is_missing_upstream(exc):
178
+ """True when a fetch failed because the chapter simply is not there (404).
179
+
180
+ _CHAPTERS is a blind range(1, 41) probe, so on a healthy run most numbers
181
+ 404 -- and a chapter IRCC RETIRES starts 404ing too. That is this module's
182
+ documented signal to drop a chapter, so a 404 must not be preserved
183
+ through: doing so would pin every withdrawn chapter in the corpus forever.
184
+ Every other fetch error (TLS, timeout, a dropped connection) is a real
185
+ failure the stored copy should survive. The two fetch paths report
186
+ differently -- urllib raises HTTPError with a status, the PowerShell
187
+ fallback raises CalledProcessError with the status only in its stderr
188
+ prose ("The remote server returned an error: (404) Not Found."), so that
189
+ side is a text match.
190
+ """
191
+ if isinstance(exc, urllib.error.HTTPError):
192
+ return exc.code == 404
193
+ stderr = getattr(exc, "stderr", "") or ""
194
+ if isinstance(stderr, bytes):
195
+ stderr = stderr.decode("utf-8", "replace")
196
+ return bool(_HTTP_404.search(stderr))
197
+
198
+
199
+ def preserve_failed(chunks, failures, stored):
200
+ """Re-attach the last-good chunks of chapters this run could not produce.
201
+
202
+ A chapter that stops fetching for any reason OTHER than a 404 -- a dropped
203
+ network, a TLS refusal, a PDF that suddenly has no text layer -- has not
204
+ been retired, it just did not arrive, and losing guidance that is still in
205
+ force is the worse error. So the stored copy stays and the run says so
206
+ loudly enough to be investigated. Chapters that 404 (retired, or never
207
+ existed) never enter `failures` and are not resurrected.
208
+ Returns (chunks, preserved act_codes).
209
+ """
210
+ failed = {f"ENF-{n}" for n, _why in failures}
211
+ return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
212
+ only=failed)
213
+
214
+
215
+ def build(chapters=_CHAPTERS, allow_shrink=False):
216
+ # Returns the chunks written, or False if the write was refused.
217
+ all_chunks, skipped, failures = [], [], []
218
+ for n in chapters:
219
+ unofficial = UNOFFICIAL.get(n)
220
+ url = (unofficial["url"] if unofficial
221
+ else _URL_OVERRIDES.get(n, BASE.format(n=n)))
222
+ dest = RAW / f"enf{n:02d}.pdf"
223
+ try:
224
+ # The ATIP host is an ordinary WordPress site; canada.ca needs
225
+ # the PowerShell TLS workaround.
226
+ fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0)
227
+ except Exception as exc:
228
+ skipped.append(n)
229
+ if not is_missing_upstream(exc):
230
+ # Not a 404: the chapter may well still be published, so it
231
+ # counts as a failure and its stored chunks are preserved.
232
+ failures.append((n, f"fetch failed: {type(exc).__name__}: {exc}"))
233
+ if dest.exists():
234
+ dest.unlink() # don't cache an error page as a PDF
235
+ continue
236
+ try:
237
+ pages = _pdf_text(dest)
238
+ if sum(len(p) for p in pages) < 500:
239
+ # Image-scan PDF: fall back to the OCR cache (form-feed
240
+ # separated pages), produced offline via Windows OCR.
241
+ ocr = RAW / f"enf{n:02d}-ocr.txt"
242
+ if ocr.exists():
243
+ pages = ocr.read_text(encoding="utf-8").split("\f")
244
+ print(f" ENF {n:2d}: using OCR cache "
245
+ f"({sum(len(p) for p in pages)} chars)")
246
+ else:
247
+ print(f" !! ENF {n}: no text layer and no OCR cache "
248
+ f"({ocr.name}); skipping")
249
+ failures.append((n, f"no text layer and no {ocr.name}"))
250
+ continue
251
+ chunks = _chunks_for(n, pages, url)
252
+ if not chunks:
253
+ # Fetched fine, parsed to nothing: the PDF's heading structure
254
+ # changed under us. That is the shape the D-memo loss took, so
255
+ # treat it as a failure and keep the stored chapter.
256
+ print(f" !! ENF {n}: no chunks parsed; keeping stored copy")
257
+ failures.append((n, "no content parsed"))
258
+ continue
259
+ except Exception as exc:
260
+ print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
261
+ failures.append((n, f"parse failed: {type(exc).__name__}: {exc}"))
262
+ continue
263
+ if unofficial:
264
+ for c in chunks:
265
+ c["act_name"] = f"ENF {n} — {unofficial['title']}"
266
+ c["text"] = _UNOFFICIAL_BANNER + c["text"]
267
+ c["citation"] += " (unofficial ATIP copy)"
268
+ c["history"] = unofficial["note"]
269
+ print(f" ENF {n:2d}: {len(chunks)} chunks"
270
+ + (" [UNOFFICIAL ATIP]" if unofficial else ""))
271
+ all_chunks.extend(chunks)
272
+ uniquify_ids(all_chunks)
273
+ out = PROCESSED_DIR / "enf.json"
274
+ # Preserved chunks keep the ids they were stored with, so they are added
275
+ # after uniquify_ids -- ids embed the chapter number, and a preserved
276
+ # chapter is by definition absent from this run's chunks.
277
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
278
+ stored_chunks(out))
279
+ if preserved:
280
+ print(f" kept the last-good copy of {len(preserved)} chapter(s) "
281
+ f"that failed this run: {', '.join(preserved)}")
282
+ if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
283
+ return False
284
+ print(f"enf: {len(all_chunks)} chunks -> {out.name} "
285
+ f"(chapters absent upstream: {skipped})")
286
+ return all_chunks
287
+
288
+
289
+ def main():
290
+ chunks = build(allow_shrink="--allow-shrink" in sys.argv)
291
+ sys.exit(1 if chunks is False else 0)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()
canlex/eval.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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', ... -- metrics are
14
+ reported per style so a hard new slice cannot silently drag
15
+ the comparable legacy numbers
16
+ holdout optional bool; holdout questions are excluded from parameter
17
+ sweeps (sweep.py) so knob choices cannot fit their noise --
18
+ eval.py reports them separately
19
+ 'no-answer' style items carry no answers: they exist to calibrate the
20
+ confidence signal (a good corpus-coverage score should be LOW on them),
21
+ reported as a separate table, never as misses.
22
+
23
+ py -m canlex.eval
24
+ """
25
+ import json
26
+ import os
27
+ import sys
28
+ from collections import defaultdict
29
+
30
+ from .config import ROOT
31
+ from .index import LegislationIndex
32
+
33
+ QUESTIONS = ROOT / "data" / "eval" / "questions.json"
34
+ EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
35
+
36
+ # The historical baseline slice: styles that existed before the 2026-07
37
+ # expansion. Numbers over this slice stay comparable across commits.
38
+ _LEGACY_STYLES = {"standard"}
39
+
40
+
41
+ def load_questions():
42
+ """The question set with defaults applied; None if the file is absent."""
43
+ if not QUESTIONS.exists():
44
+ return None
45
+ items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
46
+ for item in items:
47
+ item.setdefault("style", "standard")
48
+ item.setdefault("holdout", False)
49
+ return items
50
+
51
+
52
+ def sweep_questions():
53
+ """The slice parameter sweeps may see (sweep.py calls this): everything
54
+ except holdout items and the no-answer calibration set."""
55
+ items = load_questions() or []
56
+ return [i for i in items
57
+ if not i["holdout"] and i["style"] != "no-answer"]
58
+
59
+
60
+ def _matches(result, answers):
61
+ """True if a search result is one of the gold answers (act + section).
62
+
63
+ A gold answer is [act, section]; an empty section matches any chunk of that
64
+ act/case (used for case-law answers, whose chunks carry no section number).
65
+ """
66
+ r_acts = {result.get("act_short", "").lower(),
67
+ result.get("act_code", "").lower()}
68
+ r_sec = result.get("section", "")
69
+ for act, section in answers:
70
+ if act.lower() in r_acts and (section == r_sec or section == ""):
71
+ return True
72
+ return False
73
+
74
+
75
+ def _metrics(ranks):
76
+ n = len(ranks) or 1
77
+ hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
78
+ mrr = sum(1.0 / r for r in ranks if r) / n
79
+ return hit(1), hit(3), hit(5), hit(10), mrr
80
+
81
+
82
+ def _report_line(label, ranks):
83
+ h1, h3, h5, h10, mrr = _metrics(ranks)
84
+ print(f" {label:24} n={len(ranks):<4} Hit@1 {h1:.2f} Hit@3 {h3:.2f} "
85
+ f"Hit@5 {h5:.2f} Hit@10 {h10:.2f} MRR {mrr:.2f}")
86
+
87
+
88
+ def run():
89
+ items = load_questions()
90
+ if items is None:
91
+ print(f"No question set at {QUESTIONS}.", file=sys.stderr)
92
+ return
93
+ # Sweep mode (CANLEX_EVAL_SWEEP=1, set by sweep.py): score only the
94
+ # sweep-visible slice and print the legacy vertical metric block its
95
+ # parser expects -- holdout questions stay blind to every knob choice.
96
+ sweep_mode = bool(os.environ.get("CANLEX_EVAL_SWEEP"))
97
+ if sweep_mode:
98
+ items = [i for i in items
99
+ if not i["holdout"] and i["style"] != "no-answer"]
100
+ index = LegislationIndex()
101
+ ranks = [] # (item, rank); rank 0 = miss
102
+ misses = []
103
+ no_answer = [] # (query, top confidence) for the calibration table
104
+ for item in items:
105
+ if item["style"] == "no-answer":
106
+ results = index.search(item["query"], top_k=3)
107
+ conf = results[0].get("confidence") if results else None
108
+ no_answer.append((item["query"], conf))
109
+ continue
110
+ answers = [tuple(a) for a in item["answers"]]
111
+ results = index.search(item["query"], top_k=EVAL_TOP_K)
112
+ rank = 0
113
+ for i, result in enumerate(results, start=1):
114
+ if _matches(result, answers):
115
+ rank = i
116
+ break
117
+ ranks.append((item, rank))
118
+ if rank == 0 or rank > 5:
119
+ top = results[0] if results else None
120
+ misses.append((item, answers, rank, top))
121
+
122
+ all_ranks = [r for _, r in ranks]
123
+ if sweep_mode:
124
+ h1, h3, h5, h10, mrr = _metrics(all_ranks)
125
+ print(f"CanLex retrieval evaluation -- {len(all_ranks)} questions "
126
+ f"(sweep slice)\n")
127
+ print(f" Hit@1: {h1:.2f}")
128
+ print(f" Hit@3: {h3:.2f}")
129
+ print(f" Hit@5: {h5:.2f}")
130
+ print(f" Hit@10: {h10:.2f}")
131
+ print(f" MRR: {mrr:.2f}")
132
+ return
133
+ legacy = [r for it, r in ranks if it["style"] in _LEGACY_STYLES]
134
+ held = [r for it, r in ranks if it["holdout"]]
135
+ swept = [r for it, r in ranks if not it["holdout"]]
136
+ by_style = defaultdict(list)
137
+ for it, r in ranks:
138
+ by_style[it["style"]].append(r)
139
+
140
+ print(f"CanLex retrieval evaluation -- {len(all_ranks)} scored questions\n")
141
+ _report_line("ALL", all_ranks)
142
+ _report_line("legacy (comparable)", legacy)
143
+ for style in sorted(by_style):
144
+ if style not in _LEGACY_STYLES:
145
+ _report_line(f"style: {style}", by_style[style])
146
+ if held:
147
+ _report_line("holdout (sweep-blind)", held)
148
+ _report_line("sweep-visible", swept)
149
+
150
+ if no_answer:
151
+ print("\nNo-answer calibration (confidence should be LOW here; the "
152
+ "weak-match hedge fires below 0.72):")
153
+ for query, conf in no_answer:
154
+ shown = f"{conf:.3f}" if conf is not None else "n/a"
155
+ flag = "" if (conf is None or conf < 0.72) else " <-- OVER-CONFIDENT"
156
+ print(f" {shown} {query[:70]}{flag}")
157
+
158
+ if misses:
159
+ print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
160
+ for item, answers, rank, top in misses:
161
+ gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
162
+ where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
163
+ got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
164
+ if top else "nothing")
165
+ print(f" [{where}] ({item['style']}) {item['query']}")
166
+ print(f" gold: {gold} | top result: {got}")
167
+ print()
168
+
169
+
170
+ if __name__ == "__main__":
171
+ run()
canlex/index.py ADDED
@@ -0,0 +1,1079 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retrieval (BM25 + semantic) with cross-encoder reranking."""
2
+ import json
3
+ import math
4
+ import os
5
+ import re
6
+ import sys
7
+ import unicodedata
8
+ from collections import Counter, defaultdict
9
+
10
+ import snowballstemmer
11
+
12
+ from .config import PROCESSED_DIR
13
+ from .synonyms import expand_query
14
+
15
+ K1 = 1.5
16
+ B = 0.75
17
+ RRF_K = 60 # reciprocal-rank-fusion damping constant
18
+ W_SEM = 2.0 # weight on the semantic retriever in the fusion (1.0 = equal; eval-tuned)
19
+ CANDIDATES = 80 # hits each retriever contributes to the fusion
20
+ RERANK_POOL = int(os.environ.get("CANLEX_RERANK_POOL", "16"))
21
+ # top fused candidates the cross-encoder rescores. Profiled
22
+ # at ~96% of query latency (the pool is cross-encoded on
23
+ # CPU), so this is THE latency knob; env-sweepable, and any
24
+ # change must be eval-gated (a smaller pool limits which
25
+ # low-fusion candidates the promote-only rerank can lift).
26
+ # Swept 2026-06: 50 -> 16 (with doc cap 3000 -> 1000) held
27
+ # the 159-Q eval exactly (MRR +0.01) at ~5.5x lower latency.
28
+ MN_WEIGHT = float(os.environ.get("CANLEX_MN_WEIGHT", "0.0024"))
29
+ # title-match boost per unit of idf-weighted overlap between
30
+ # the query and a candidate's marginal note (section title)
31
+ MN_CAP = float(os.environ.get("CANLEX_MN_CAP", "0.012"))
32
+ # ceiling on the title-match boost -- it nudges the ranking
33
+ # without overriding a strong base score
34
+ REG_PENALTY = float(os.environ.get("CANLEX_REG_PENALTY", "0.004"))
35
+ # small fusion penalty on regulation sections, so the Act
36
+ # that creates a duty outranks the regulation elaborating it
37
+ # (sweep-tuned 2026-05-23 from 0.008 -> 0.004; see sweep.log)
38
+ NIF_PENALTY = float(os.environ.get("CANLEX_NIF_PENALTY", "0.012"))
39
+ # fusion penalty on not-in-force amendment chunks, so
40
+ # pending law never outranks the live provision it amends
41
+ REPEALED_PENALTY = float(os.environ.get("CANLEX_REPEALED_PENALTY", "0.008"))
42
+ # likewise for repealed provisions (kept so lookups still
43
+ # inform): a repealed section's on-topic title must not
44
+ # beat live law in a close contest -- the CDSA-to-Cannabis
45
+ # -Act migration is the live risk zone
46
+ BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
47
+ # likewise for a collective agreement's back-matter
48
+ # (memoranda, letters of understanding) vs its numbered articles
49
+ # (sweep-tuned 2026-05-23 from 0.008 -> 0.004)
50
+ SOURCE_CAP = 2 # max chunks one case or memorandum may contribute
51
+ RECALL_CAP = int(os.environ.get("CANLEX_RECALL_CAP", "20"))
52
+ # max chunks one capped source (anything _source_key keys:
53
+ # a decision, a memorandum) may contribute to EACH
54
+ # retriever's CANDIDATES slice at the recall stage. Without
55
+ # it, a deep decision whose every paragraph matches a
56
+ # specific query (Canfield: 94 paragraphs on device border
57
+ # searches) fills the entire fusion pool, so the result-
58
+ # stage SOURCE_CAP defers its surplus with nothing left to
59
+ # promote and _ensure_primary finds no statute to pull in.
60
+ # Generous by design: SOURCE_CAP=2 governs display; this
61
+ # only has to leave room for other sources to survive
62
+ # recall. Uncapped sources (_source_key None) are unlimited.
63
+ # Swept 2026-07-10 over {12,16,20,24} on the 159-Q eval:
64
+ # 12 and 20 tie at 0.80/0.94/0.97/0.99/0.87 (4 misses),
65
+ # both above the uncapped baseline; 20 chosen as the
66
+ # lightest-touch winner (24 regresses Hit@3/@5).
67
+ APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
68
+
69
+ # Primary instruments -- enacted law, collective agreements, the NJC directives
70
+ # incorporated into them, and the IRPA delegation instruments. Their sections or
71
+ # items are distinct provisions, so (like legislation) they are never collapsed
72
+ # under the diversity cap.
73
+ PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
74
+ # Memorandum families whose act_code is ONE shared constant over many
75
+ # independent items, so the diversity cap keys on `section` instead (see
76
+ # _source_key): a D-memo number, an AMPS contravention code.
77
+ _FLAT_MEMO_CODES = frozenset({"D-Memo", "AMPS"})
78
+
79
+ _TOKEN = re.compile(r"[a-z0-9]+")
80
+ # A chunk id ending '-p<k>' marks one embeddable piece of a long section that
81
+ # ingest.py split (see _split_text); same-section pieces are capped together.
82
+ _PIECE_ID = re.compile(r"-p\d+$")
83
+ _SECTION_REF = re.compile(r"\bs(?:ec(?:tion)?)?s?\.?\s*(\d+(?:\.\d+)?)")
84
+ # A cross-reference to another provision -- "section 34", "subsection 25(1)",
85
+ # "paragraph 36(1)(a)", "s. 34" -- capturing the top-level section number.
86
+ _XREF = re.compile(
87
+ r"\b(?:sections?|subsections?|paragraphs?|ss?\.)\s*(\d+(?:\.\d+)?)",
88
+ re.IGNORECASE)
89
+
90
+ # A line opening with "(1)", "(a)" or "(b.1)" -- a citable subdivision
91
+ # (subsection, paragraph or subparagraph) of a provision.
92
+ _MARKER = re.compile(r"(?m)^\(([0-9a-zA-Z]+(?:\.\d+)?)\)")
93
+
94
+ # A D-memorandum's reference to a provision -- "section 32 of the Customs Act",
95
+ # or "section 32 of the Act" (the Act a D-memo administers -- the Customs Act).
96
+ _MEMO_CITE = re.compile(
97
+ r"\b(?:sub)?sections?\s+(\d+(?:\.\d+)?)(?:\([^)]+\))*\s+of\s+the\s+"
98
+ r"(Customs Act|Customs Tariff|Act)\b", re.IGNORECASE)
99
+
100
+ # A directive section's reference to an appendix of the same directive --
101
+ # "as specified in Appendix C". A trailing "of" ("Appendix C of the NJC Travel
102
+ # Directive") marks a cross-directive citation and is deliberately left alone.
103
+ _APPENDIX_REF = re.compile(r"\bAppendi(?:x|ces)\s+([A-Za-z])\b(?!\s+of\b)",
104
+ re.IGNORECASE)
105
+ # A directive chunk that *is* an appendix: its title opens "Appendix C ...".
106
+ _APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
107
+
108
+
109
+ _STEMMER = snowballstemmer.stemmer("english")
110
+ _STEM_CACHE = {}
111
+
112
+
113
+ # Stem pairs Snowball does not merge but that share a legal meaning, so a
114
+ # query naming the verb still matches a provision titled with the noun (or
115
+ # vice versa). Mapped to the verb form on both index and query sides, which
116
+ # is consistent and arbitrary -- the merge is what matters.
117
+ _STEM_NORMALIZE = {
118
+ "seizur": "seiz", # seizure -> seize
119
+ "forfeitur": "forfeit", # forfeiture -> forfeit
120
+ "appel": "appeal", # appellate/appellant -> appeal
121
+ "detent": "detain", # detention -> detain
122
+ "exclus": "exclud", # exclusion -> exclude
123
+ "admiss": "admit", # admission/admissibility -> admit
124
+ "applic": "appli", # application -> apply
125
+ "complianc": "compli", # compliance -> comply
126
+ "grievanc": "griev", # grievance -> grieve
127
+ }
128
+
129
+
130
+ def _stem(word):
131
+ """Snowball-stem a word, memoised -- legal text repeats terms heavily.
132
+ A small post-stem normalization merges a few verb/noun pairs Snowball
133
+ leaves apart ('seize'/'seizure', 'forfeit'/'forfeiture')."""
134
+ stemmed = _STEM_CACHE.get(word)
135
+ if stemmed is None:
136
+ stemmed = _STEMMER.stemWord(word)
137
+ stemmed = _STEM_NORMALIZE.get(stemmed, stemmed)
138
+ _STEM_CACHE[word] = stemmed
139
+ return stemmed
140
+
141
+
142
+ # Query-language detection for the bilingual corpus: accented characters or
143
+ # two-plus French function words mark a French query. French queries may draw
144
+ # on BOTH languages (guidance and case law exist only in English); English
145
+ # queries never surface French chunks (the English corpus is complete, and a
146
+ # French twin would only duplicate results).
147
+ _FR_HINT = re.compile(r"[àâçéèêëîïôùûüÿœæ]", re.IGNORECASE)
148
+ # ASCII-only entries: tokens are matched pre-folding with [a-zA-Z']+, and any
149
+ # accented query already short-circuits through _FR_HINT above.
150
+ _FR_WORDS = frozenset(
151
+ "le la les de des une du et est dans pour que qui aux au sur par ne pas "
152
+ "peut doit selon lors quelle quel cette ces son ses leur d'un d'une "
153
+ "interdiction fouille saisie renvoi territoire".split())
154
+
155
+
156
+ def query_lang(query):
157
+ """'fr' when the query reads as French, else 'en'."""
158
+ if _FR_HINT.search(query):
159
+ return "fr"
160
+ tokens = re.findall(r"[a-zA-Z']+", query.lower())
161
+ return "fr" if sum(1 for t in tokens if t in _FR_WORDS) >= 2 else "en"
162
+
163
+
164
+ def _fold_accents(text):
165
+ """ASCII-fold accented characters so a French or mixed query tokenizes
166
+ usefully instead of shattering: the [a-z0-9] token pattern used to split
167
+ 'détention' into ['d','tention']. NFKD + ASCII-encode drops the combining
168
+ marks ('détention' -> 'detention'), which both matches the English corpus
169
+ directly and lets the FR->EN synonym bridges fire."""
170
+ return unicodedata.normalize("NFKD", text).encode(
171
+ "ascii", "ignore").decode("ascii")
172
+
173
+
174
+ def tokenize(text):
175
+ """Lower-case, accent-fold, split on word characters, and Snowball-stem
176
+ each token, so a query matches a provision even when their word forms
177
+ differ -- 'possession' vs 'possess', 'reporting' vs 'report',
178
+ 'importation' vs 'import', 'détention' vs 'detention'."""
179
+ return [_stem(w) for w in _TOKEN.findall(_fold_accents(text.lower()))]
180
+
181
+
182
+ def _section_refs(query):
183
+ """Pull explicit section numbers from a query, e.g. 'section 34', 's. 20.1'."""
184
+ return set(_SECTION_REF.findall(query.lower()))
185
+
186
+
187
+ def topical_title(chunk):
188
+ """Return the chunk's topic-bearing string, used wherever a section's
189
+ 'title' is weighted for retrieval -- BM25 indexing, the title-match boost,
190
+ and the semantic embedding. Differs by doc_type because the field that
191
+ carries the topic differs: legislation/agreement/directive/delegation use
192
+ the marginal_note (section heading); D-memoranda use 'part' because their
193
+ marginal_note is a generic banner; case-law uses 'heading' because its
194
+ marginal_note is just the paragraph range ('paras 11-13') and the case
195
+ proposition lives in heading."""
196
+ doc_type = chunk.get("doc_type")
197
+ if doc_type == "memorandum":
198
+ return chunk.get("part") or chunk["marginal_note"]
199
+ if doc_type == "caselaw":
200
+ return chunk.get("heading") or chunk["marginal_note"]
201
+ return chunk["marginal_note"]
202
+
203
+
204
+ def _provision_units(text):
205
+ """Citable parts of a provision, for pinpoint scoring -- a list of
206
+ (citation_suffix, scoring_text, snippet). One entry per paragraph, with its
207
+ subsection chapeau prepended to scoring_text for context, plus one per
208
+ paragraph-less subsection. Returns [] when the provision is too flat to
209
+ pinpoint (fewer than two subdivisions)."""
210
+ marks = list(_MARKER.finditer(text))
211
+ if len(marks) < 2:
212
+ return []
213
+ spans = []
214
+ for i, m in enumerate(marks):
215
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
216
+ spans.append((m.group(1), text[m.start():end].strip()))
217
+ units, cur_sub, cur_intro = [], "", ""
218
+ for j, (token, body) in enumerate(spans):
219
+ if "[Repealed" in body[:40]:
220
+ if token[0].isdigit():
221
+ cur_sub, cur_intro = f"({token})", ""
222
+ continue
223
+ if token[0].isdigit():
224
+ cur_sub, cur_intro = f"({token})", body
225
+ nxt = spans[j + 1][0] if j + 1 < len(spans) else ""
226
+ if not nxt or nxt[0].isdigit():
227
+ units.append((cur_sub, body, body)) # subsection has no paragraphs
228
+ # otherwise the chapeau is emitted via its paragraphs below
229
+ else:
230
+ label = f"{cur_sub}({token})" if cur_sub else f"({token})"
231
+ units.append((label, f"{cur_intro} {body}".strip(), body))
232
+ return units
233
+
234
+
235
+ class LegislationIndex:
236
+ def __init__(self):
237
+ self.chunks = []
238
+ for path in sorted(PROCESSED_DIR.glob("*.json")):
239
+ self.chunks.extend(json.loads(path.read_text(encoding="utf-8")))
240
+ if not self.chunks:
241
+ raise RuntimeError(
242
+ f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
243
+ self._tri_index = None # lazy; see _fuzzy_term
244
+ self._word_vocab = None # lazy; see _correct_word (typo repair)
245
+ self._word_tri = None
246
+ # Cached recall mask for the common case (English query, no
247
+ # filters): everything but the French twins.
248
+ self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
249
+ self._build_bm25()
250
+ self._build_note_tokens()
251
+ self._build_xref()
252
+ self._build_appendix_index()
253
+ self._load_semantic()
254
+ self._load_reranker()
255
+
256
+ def _build_bm25(self):
257
+ """BM25 postings plus TWO sets of collection statistics: one over the
258
+ whole corpus, one over the English scope alone.
259
+
260
+ An English query never retrieves the French twins (see _en_mask), yet
261
+ a single corpus-wide idf/avgdl let those unreachable chunks set the
262
+ rarity of every shared token (numbers, act codes, proper nouns, folded
263
+ cognates) and drag the average document length. Ingesting the French
264
+ D-memos and NJC directives moved legacy Hit@1 0.767 -> 0.750 that way,
265
+ without a single French chunk ever being retrievable -- the statistics
266
+ must be scoped to what the query can actually reach.
267
+ """
268
+ self.doc_len = []
269
+ self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
270
+ df = defaultdict(int)
271
+ df_en = defaultdict(int)
272
+ en_docs = en_total_len = 0
273
+ for idx, c in enumerate(self.chunks):
274
+ # The topical title is repeated to weight it above body text;
275
+ # the Act name, code and section are indexed too, so an Act's own
276
+ # terminology (e.g. "controlled substance") and its codes/numbers
277
+ # are searchable even when a section's text omits them. The title
278
+ # is doc_type-aware via topical_title -- for case-law it picks
279
+ # the case proposition (heading), not the paragraph range
280
+ # (marginal_note), so a leading case surfaces on a topical query.
281
+ title = topical_title(c)
282
+ blob = " ".join((title, title, c["heading"],
283
+ c["part"], c["division"], c["act_name"], c["act_code"],
284
+ c["section"], c["text"]))
285
+ counts = Counter(tokenize(blob))
286
+ self.doc_len.append(sum(counts.values()))
287
+ is_en = c.get("lang", "en") != "fr"
288
+ if is_en:
289
+ en_docs += 1
290
+ en_total_len += self.doc_len[-1]
291
+ for term, tf in counts.items():
292
+ self.postings[term].append((idx, tf))
293
+ df[term] += 1
294
+ if is_en:
295
+ df_en[term] += 1
296
+ n = len(self.chunks)
297
+ self.avgdl = sum(self.doc_len) / n
298
+ self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
299
+ # The English-scoped twins. A term the English corpus never uses is
300
+ # absent from idf_en rather than zero-weighted: it can only match
301
+ # chunks the mask already excludes.
302
+ self.avgdl_en = en_total_len / (en_docs or 1)
303
+ self.idf_en = {t: math.log(1 + (en_docs - d + 0.5) / (d + 0.5))
304
+ for t, d in df_en.items()}
305
+
306
+ def _build_note_tokens(self):
307
+ """Pre-tokenise each chunk's topical title (see topical_title) for the
308
+ title-match boost in search(). Each chunk is also flagged as a
309
+ regulation (act codes beginning SOR/C.R.C.) for the Act-over-regulation
310
+ preference, as collective-agreement back-matter (memoranda and letters
311
+ with no article number) for the back-matter penalty, and as a
312
+ not-in-force amendment for the pending-law penalty."""
313
+ self._note_tokens = []
314
+ self._is_regulation = []
315
+ self._is_backmatter = []
316
+ self._is_nif = []
317
+ self._is_repealed = []
318
+ for c in self.chunks:
319
+ self._note_tokens.append(set(tokenize(topical_title(c))))
320
+ self._is_regulation.append(
321
+ c.get("doc_type", "legislation") == "legislation"
322
+ and c["act_code"].startswith(("SOR", "C.R.C")))
323
+ self._is_backmatter.append(
324
+ c.get("doc_type") == "agreement"
325
+ and not str(c["section"])[:1].isdigit())
326
+ self._is_nif.append(c.get("status") == "not-in-force")
327
+ self._is_repealed.append(c.get("status") == "repealed")
328
+
329
+ def _build_appendix_index(self):
330
+ """Index directive appendices by (act_code, letter), so a directive
331
+ section that cites 'Appendix C' can pull that appendix into the result
332
+ set -- a directive's rate-table appendices are bare numbers and rank
333
+ poorly on a natural-language query, yet the section citing them is of
334
+ little use without them."""
335
+ self._appendix = defaultdict(list)
336
+ for idx, c in enumerate(self.chunks):
337
+ if c.get("doc_type") != "directive":
338
+ continue
339
+ m = _APPENDIX_HEAD.match(c["marginal_note"])
340
+ if m:
341
+ self._appendix[(c["act_code"], m.group(1).upper())].append(idx)
342
+
343
+ def _load_semantic(self):
344
+ """Load precomputed embeddings and the query embedder.
345
+
346
+ Any failure (missing embeddings, or numpy/model unavailable) degrades the
347
+ index to BM25-only rather than breaking retrieval.
348
+ """
349
+ self.semantic = False
350
+ emb_path = PROCESSED_DIR / "embeddings.npz"
351
+ if not emb_path.exists():
352
+ print("CanLex index: no embeddings.npz; using BM25 only "
353
+ "(run 'canlex.embed' to enable semantic search).", file=sys.stderr)
354
+ return
355
+ try:
356
+ import numpy as np
357
+ from .embed import Embedder
358
+ # Multi-vector layout: rows share a chunk id when the chunk was
359
+ # embedded as several windows (long D-memo sections etc.); a
360
+ # chunk's semantic score is the max over its windows. Rows whose
361
+ # id matches no loaded chunk are dropped; chunks with no rows
362
+ # count as missing (BM25-only for them).
363
+ chunk_ordinal = {c["id"]: i for i, c in enumerate(self.chunks)}
364
+ with np.load(emb_path) as data:
365
+ all_ids = data["ids"].tolist()
366
+ all_vecs = data["vectors"]
367
+ keep = [(chunk_ordinal[i], r) for r, i in enumerate(all_ids)
368
+ if i in chunk_ordinal]
369
+ self._np = np
370
+ self.vectors = all_vecs[[r for _, r in keep]]
371
+ self._row_chunk = np.array([o for o, _ in keep], dtype=np.int64)
372
+ covered = set(self._row_chunk.tolist())
373
+ missing = len(self.chunks) - len(covered)
374
+ self.embedder = Embedder()
375
+ self.semantic = True
376
+ if missing:
377
+ print(f"CanLex index: {missing}/{len(self.chunks)} sections lack "
378
+ f"embeddings; re-run 'canlex.embed' to refresh.", file=sys.stderr)
379
+ except Exception as exc:
380
+ print(f"CanLex index: semantic search disabled ({type(exc).__name__}: "
381
+ f"{exc}); using BM25 only.", file=sys.stderr)
382
+ self.semantic = False
383
+
384
+ def _load_reranker(self):
385
+ """Load the cross-encoder reranker; degrade to the fusion order on failure."""
386
+ self.reranker = None
387
+ try:
388
+ from .rerank import Reranker
389
+ self.reranker = Reranker()
390
+ except Exception as exc:
391
+ print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
392
+ f"using hybrid fusion order.", file=sys.stderr)
393
+
394
+ def _fuzzy_term(self, term):
395
+ """The closest vocabulary term for a token the corpus has never seen
396
+ -- a typo bridge ('detension' -> 'detention'), scored by character-
397
+ trigram Dice similarity with a strict 0.6 floor and a length guard.
398
+ Only ever consulted for UNKNOWN tokens, so correctly-spelled queries
399
+ are untouched. The trigram index is built lazily on first miss."""
400
+ if len(term) < 4:
401
+ return None
402
+ if self._tri_index is None:
403
+ tri = defaultdict(set)
404
+ for vocab_term in self.idf:
405
+ for i in range(len(vocab_term) - 2):
406
+ tri[vocab_term[i:i + 3]].add(vocab_term)
407
+ self._tri_index = tri
408
+ tris = {term[i:i + 3] for i in range(len(term) - 2)}
409
+ counts = Counter()
410
+ for t in tris:
411
+ counts.update(self._tri_index.get(t, ()))
412
+ best, best_score = None, 0.6
413
+ for cand, n in counts.most_common(50):
414
+ # Typos rarely corrupt a word's opening letters; requiring a
415
+ # shared 2-char prefix kills drift like 'detens' -> 'pretens'
416
+ # while keeping 'seisur' -> 'seizur'.
417
+ if abs(len(cand) - len(term)) > 2 or cand[:2] != term[:2]:
418
+ continue
419
+ score = 2 * n / (len(tris) + len(cand) - 2)
420
+ if score > best_score:
421
+ best, best_score = cand, score
422
+ return best
423
+
424
+ @staticmethod
425
+ def _edit_distance(a, b, cap=3):
426
+ """Damerau-Levenshtein distance, early-exited past `cap` -- typos
427
+ are substitutions, insertions, deletions and transpositions, and a
428
+ transposition ('undeclraed') must cost 1, not 2."""
429
+ if abs(len(a) - len(b)) > cap:
430
+ return cap + 1
431
+ prev2, prev = None, list(range(len(b) + 1))
432
+ for i, ca in enumerate(a, start=1):
433
+ cur = [i]
434
+ best_row = i
435
+ for j, cb in enumerate(b, start=1):
436
+ cost = 0 if ca == cb else 1
437
+ d = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
438
+ if (prev2 is not None and i > 1 and j > 1
439
+ and ca == b[j - 2] and a[i - 2] == cb):
440
+ d = min(d, prev2[j - 2] + 1)
441
+ cur.append(d)
442
+ best_row = min(best_row, d)
443
+ if best_row > cap:
444
+ return cap + 1
445
+ prev2, prev = prev, cur
446
+ return prev[-1]
447
+
448
+ def _correct_word(self, word):
449
+ """Nearest corpus WORD (not stem) for a misspelling, or None.
450
+
451
+ Trigram index generates candidates; Damerau-Levenshtein picks the
452
+ winner (distance <= 2, shared 2-char prefix, frequency floor of 5
453
+ so OCR debris in the corpus can never be a 'correction'). The
454
+ result is real text: it feeds the semantic embedder and the
455
+ cross-encoder, which score words, not stems."""
456
+ if self._word_vocab is None:
457
+ words = Counter()
458
+ for c in self.chunks:
459
+ words.update(re.findall(
460
+ r"[a-z]{4,}", _fold_accents(c["text"].lower())))
461
+ tri = defaultdict(set)
462
+ for w, n in words.items():
463
+ if n >= 5: # junk never enters the index
464
+ for i in range(len(w) - 2):
465
+ tri[w[i:i + 3]].add(w)
466
+ self._word_vocab, self._word_tri = words, tri
467
+ tris = {word[i:i + 3] for i in range(len(word) - 2)}
468
+ candidates = set()
469
+ for t in tris:
470
+ candidates |= self._word_tri.get(t, set())
471
+ best, best_key = None, (3, 0) # (distance, -frequency)
472
+ for cand in candidates:
473
+ if abs(len(cand) - len(word)) > 2 or cand[:2] != word[:2]:
474
+ continue
475
+ dist = self._edit_distance(word, cand, cap=2)
476
+ if dist > 2:
477
+ continue
478
+ key = (dist, -self._word_vocab[cand])
479
+ if key < best_key:
480
+ best, best_key = cand, key
481
+ return best
482
+
483
+ def _correct_query(self, query):
484
+ """The query with misspelled words replaced by their nearest corpus
485
+ words; the query itself when nothing needed fixing. A word is only
486
+ 'misspelled' when its stem is absent from the whole BM25 vocabulary,
487
+ so correctly-spelled queries pass through untouched by
488
+ construction."""
489
+ out, changed = [], False
490
+ for tok in query.split():
491
+ word = _fold_accents(tok.lower()).strip(".,;:!?()'\"")
492
+ if len(word) >= 4 and word.isalpha():
493
+ stem = _stem(word)
494
+ df = len(self.postings.get(stem, ()))
495
+ if df == 0:
496
+ # Unknown everywhere: replace outright.
497
+ fix = self._correct_word(word)
498
+ if fix and fix != word:
499
+ out.append(fix)
500
+ changed = True
501
+ continue
502
+ elif df <= 2:
503
+ # Nearly unknown -- often a typo that happens to collide
504
+ # with OCR debris in the corpus. APPEND a strong
505
+ # correction rather than replacing, so a legitimately
506
+ # rare term (a chemical in a CDSA schedule, a case name)
507
+ # never loses its own recall.
508
+ fix = self._correct_word(word)
509
+ if (fix and fix != word
510
+ and self._word_vocab[fix] >= 100):
511
+ out.append(f"{tok} {fix}")
512
+ changed = True
513
+ continue
514
+ out.append(tok)
515
+ return " ".join(out) if changed else query
516
+
517
+ def _bm25_scores(self, query, allowed=None, en_only=False):
518
+ """allowed: optional boolean list by chunk index. Filtered searches
519
+ pass it so recall competes only within scope -- filtering after
520
+ recall meant a doc_type='caselaw' search drew from whatever case law
521
+ happened to survive the open-corpus top-N competition.
522
+
523
+ en_only: score with the English-scoped collection statistics (see
524
+ _build_bm25). Set for every English query, whose mask already bars
525
+ the French twins from the results they would otherwise be weighting.
526
+ """
527
+ idf_map = self.idf_en if en_only else self.idf
528
+ avgdl = self.avgdl_en if en_only else self.avgdl
529
+ scores = defaultdict(float)
530
+ for term in set(tokenize(query)):
531
+ idf = idf_map.get(term)
532
+ weight = 1.0
533
+ if idf is None:
534
+ # Unknown token: try the typo bridge at a discount, so a
535
+ # misspelled query still reaches the provision while a real
536
+ # match always outranks a fuzzy one.
537
+ alt = self._fuzzy_term(term)
538
+ if alt is None or alt not in idf_map:
539
+ continue
540
+ term, idf, weight = alt, idf_map[alt], 0.7
541
+ for idx, tf in self.postings[term]:
542
+ if allowed is not None and not allowed[idx]:
543
+ continue
544
+ dl = self.doc_len[idx]
545
+ denom = tf + K1 * (1 - B + B * dl / avgdl)
546
+ scores[idx] += weight * idf * tf * (K1 + 1) / denom
547
+ return scores
548
+
549
+ def _semantic_ranking(self, query, allowed=None):
550
+ np = self._np
551
+ qv = self.embedder.encode_query(query)
552
+ row_sims = self.vectors @ qv
553
+ # Aggregate windows to chunks by max: a long section scores as its
554
+ # best-matching window, so a fact buried mid-memo is reachable.
555
+ sims = np.full(len(self.chunks), -1.0, dtype=np.float32)
556
+ np.maximum.at(sims, self._row_chunk, row_sims)
557
+ if allowed is not None:
558
+ # Mask out-of-scope chunks so a filtered search ranks (and reports
559
+ # confidence) within its own scope, not corpus-wide.
560
+ sims = np.where(np.asarray(allowed, dtype=bool), sims, -1.0)
561
+ # Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
562
+ # surplus chunks of any one deep source, and the extra depth is what
563
+ # lets other sources backfill the freed CANDIDATES slots.
564
+ order = np.argsort(sims)[::-1][:CANDIDATES * 4]
565
+ # The top cosine similarity doubles as a corpus-coverage signal: a query
566
+ # the corpus cannot answer has no passage close to it.
567
+ return [int(i) for i in order], float(sims.max())
568
+
569
+ def _rerank_doc(self, idx):
570
+ c = self.chunks[idx]
571
+ return f"{c['citation']} — {c['marginal_note']}\n{c['text']}"
572
+
573
+ def _source_key(self, idx):
574
+ """The parent document a chunk belongs to, for diversity capping. Returns
575
+ None for primary instruments -- legislation, collective agreements and
576
+ directives -- whose sections are distinct provisions and are never
577
+ capped; case law is keyed by citation, memoranda by memo number.
578
+
579
+ Benefit-plan booklets also return None: a booklet's sections are
580
+ distinct topics (eligibility, coverage, claims, appeals...) like
581
+ statute sections, so capping the whole booklet to SOURCE_CAP chunks
582
+ would starve a detailed benefits query. They are not in
583
+ PRIMARY_DOC_TYPES, though -- they are guidance, not governing
584
+ instruments, so _ensure_primary does not pull them in as if they were
585
+ enacted law.
586
+
587
+ Exception: the pieces of one long section/article that was split for
588
+ embedding (ids ending '-p<k>') share a key, so the diversity cap stops a
589
+ single multi-part provision from filling the result set with its own
590
+ fragments. This applies to the otherwise-uncapped primary types
591
+ (legislation, agreement, directive, delegation): pieces are keyed by
592
+ their shared base id, distinct sections keep distinct keys. (Memoranda
593
+ and case law are already capped at the parent level, so their pieces are
594
+ bounded without a separate key.)"""
595
+ c = self.chunks[idx]
596
+ doc_type = c.get("doc_type", "legislation")
597
+ m = _PIECE_ID.search(c.get("id", ""))
598
+ if m and doc_type in PRIMARY_DOC_TYPES:
599
+ return ("piece", c["id"][:m.start()]) # pieces of one chunk together
600
+ # Commentary entries are like benefits chunks: each is a distinct
601
+ # curated topic, so capping the dataset as one source would starve a
602
+ # multi-entry question; not PRIMARY, so never force-pulled as law.
603
+ if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
604
+ return None
605
+ if doc_type == "memorandum":
606
+ # The key must be the parent DOCUMENT, and which field carries that
607
+ # identity differs across the memorandum families. ENF chapters,
608
+ # PDI pages and the IRB guidelines each have their own act_code and
609
+ # use `section` to subdivide it, so act_code is the document.
610
+ # _FLAT_MEMO_CODES are the two families where one act_code covers
611
+ # many independent items -- ~360 D-memoranda and ~1,000 AMPS
612
+ # contraventions -- and `section` (memo number / contravention
613
+ # code) is the unit a reader would cite.
614
+ #
615
+ # Keying every memorandum on `section` (as this did) gave each ENF
616
+ # section and each PDI page its own source key, so neither cap ever
617
+ # bound at the document level: the ~150-page PDI refugee tree could
618
+ # fill the whole candidate pool and push IRPA s. 112 out of a PRRA
619
+ # query entirely.
620
+ code = c["act_code"]
621
+ return ("memorandum",
622
+ c["section"] if code in _FLAT_MEMO_CODES else code)
623
+ return (doc_type, c["act_code"]) # one decision, keyed by citation
624
+
625
+ def _capped_top(self, ordered):
626
+ """The first CANDIDATES indices of `ordered`, keeping at most
627
+ RECALL_CAP per capped source. The recall-stage counterpart of
628
+ _diversify: where _diversify reorders the fused candidates so a deep
629
+ source cannot monopolise the visible results, this stops that source
630
+ from monopolising the candidate pool itself -- skipped surplus frees
631
+ slots that later, weaker-ranked chunks of OTHER sources backfill."""
632
+ out, counts = [], defaultdict(int)
633
+ for idx in ordered:
634
+ key = self._source_key(idx)
635
+ if key is not None:
636
+ counts[key] += 1
637
+ if counts[key] > RECALL_CAP:
638
+ continue
639
+ out.append(idx)
640
+ if len(out) >= CANDIDATES:
641
+ break
642
+ return out
643
+
644
+ def _diversify(self, ordered):
645
+ """Reorder so no single case, memorandum, agreement or directive can
646
+ monopolise the results: once a source has contributed SOURCE_CAP chunks,
647
+ its remaining chunks are deferred below every other candidate. This stops
648
+ a heavily paragraph-chunked decision from crowding out the statute it
649
+ interprets. Legislation is never capped."""
650
+ kept, deferred, counts = [], [], defaultdict(int)
651
+ for idx in ordered:
652
+ key = self._source_key(idx)
653
+ if key is None:
654
+ kept.append(idx)
655
+ continue
656
+ counts[key] += 1
657
+ (kept if counts[key] <= SOURCE_CAP else deferred).append(idx)
658
+ return kept + deferred
659
+
660
+ def _ensure_primary(self, ordered, top_k, q_tokens):
661
+ """Guarantee the governing primary instrument is surfaced: when the
662
+ natural top_k is monopolised by case law or D-memoranda that interpret
663
+ a statute, pull the most topically-on-target Act/agreement/directive/
664
+ delegation section into the top_k, displacing the lowest-ranked
665
+ secondary sources. The single best match is always kept in place.
666
+
667
+ Two changes from the older 'ensure_legislation' guarantee: (i) all
668
+ primary instruments count, not only legislation -- so an FB-Agreement
669
+ query that surfaces only FPSLREB case law gets the agreement article
670
+ pulled in too; (ii) the candidate to promote is chosen by title-match
671
+ against the query (the section whose marginal_note covers the most of
672
+ the query's distinctive vocabulary), not by raw fusion rank. The
673
+ fusion rank surfaces tangentially-on-topic sections that share the
674
+ Act's general vocabulary; the title-match scorer surfaces the section
675
+ actually about the topic ('Seizure and forfeiture' over 'Report to
676
+ President' for a 'seize currency' query)."""
677
+ if top_k < 3:
678
+ return ordered
679
+ def is_primary(i):
680
+ # A not-in-force amendment is pending law, not a governing
681
+ # provision -- never promote it as "the statute on point".
682
+ return (self.chunks[i].get("doc_type", "legislation") in PRIMARY_DOC_TYPES
683
+ and self.chunks[i].get("status") != "not-in-force")
684
+ top, rest = ordered[:top_k], ordered[top_k:]
685
+ need = 2 - sum(1 for i in top if is_primary(i))
686
+ if need <= 0:
687
+ return ordered
688
+ primary_in_rest = [i for i in rest if is_primary(i)]
689
+ if not primary_in_rest:
690
+ return ordered
691
+ if q_tokens:
692
+ def title_score(idx):
693
+ note_tokens = self._note_tokens[idx]
694
+ if not note_tokens:
695
+ return 0.0
696
+ matched = sum(self.idf.get(t, 0.0)
697
+ for t in note_tokens if t in q_tokens)
698
+ total = sum(self.idf.get(t, 0.0) for t in note_tokens) or 1.0
699
+ score = matched * matched / total
700
+ # Mirror the fusion-stage hierarchy preferences for tiebreaks:
701
+ # the governing Act beats its regulation, and numbered
702
+ # agreement articles beat their back-matter, when both have
703
+ # identical titles (e.g. IRPA s. 112 and IRPR s. 160 both
704
+ # marginal-noted 'Application for protection').
705
+ if self._is_regulation[idx]:
706
+ score -= REG_PENALTY
707
+ if self._is_backmatter[idx]:
708
+ score -= BACKMATTER_PENALTY
709
+ return score
710
+ # Sort by title-match descending, then by original fusion order as
711
+ # a tiebreak (stable sort: keep the original rest order).
712
+ primary_in_rest.sort(key=title_score, reverse=True)
713
+ promote = primary_in_rest[:need]
714
+ drop = [i for i in reversed(top) if not is_primary(i)][:len(promote)]
715
+ if not drop:
716
+ return ordered
717
+ promote = promote[:len(drop)]
718
+ dropped, promoted = set(drop), set(promote)
719
+ kept = [i for i in top if i not in dropped]
720
+ return kept[:1] + promote + kept[1:] + drop + [
721
+ i for i in rest if i not in promoted]
722
+
723
+ def _cosurface_appendices(self, top):
724
+ """Append the appendices the directive results cite but that retrieval
725
+ missed. A directive's rate tables ('Appendix C') rank poorly on a
726
+ natural-language query, yet a section that cites them is of little use
727
+ without them -- so the appendix travels with it. When more appendices
728
+ are cited than APPENDIX_CAP allows, the ones cited by the most results
729
+ win, so a lone off-topic result cannot outvote the relevant ones.
730
+ Returns `top` extended by up to APPENDIX_CAP appendix chunks."""
731
+ have = set(top)
732
+ cited = Counter()
733
+ for idx in top:
734
+ c = self.chunks[idx]
735
+ if c.get("doc_type") != "directive":
736
+ continue
737
+ seen = set() # count an appendix once per citing result
738
+ for m in _APPENDIX_REF.finditer(c["text"]):
739
+ key = (c["act_code"], m.group(1).upper())
740
+ for app in self._appendix.get(key, ()):
741
+ if app not in have and app not in seen:
742
+ seen.add(app)
743
+ cited[app] += 1
744
+ return top + [app for app, _ in cited.most_common(APPENDIX_CAP)]
745
+
746
+ def _highlight(self, query, indices):
747
+ """For each result chunk, the subsection or paragraph most on point for
748
+ the query: {result_position: (citation_suffix, snippet)}. Uses the
749
+ cross-encoder; returns {} if it is unavailable or nothing is structured.
750
+ Only the first results are scored -- a pinpoint deep in the list is not
751
+ worth the cross-encoder cost."""
752
+ if not self.reranker:
753
+ return {}
754
+ jobs = [] # (result_position, label, scoring_text, snippet)
755
+ for pos, idx in enumerate(indices[:8]):
756
+ c = self.chunks[idx]
757
+ if c.get("doc_type", "legislation") != "legislation":
758
+ continue
759
+ note = c["marginal_note"]
760
+ for label, scoring, snippet in _provision_units(c["text"]):
761
+ jobs.append((pos, label, f"{note}. {scoring}", snippet))
762
+ if not jobs:
763
+ return {}
764
+ best = {} # result_position -> (score, label, snippet)
765
+ for (pos, label, _, snippet), score in zip(
766
+ jobs, self.reranker.score(query, [j[2] for j in jobs])):
767
+ if pos not in best or score > best[pos][0]:
768
+ best[pos] = (score, label, snippet)
769
+ return {pos: (label, " ".join(snippet[:240].split()))
770
+ for pos, (score, label, snippet) in best.items()}
771
+
772
+ def _filter_ok(self, c, act, doc_type, qlang="en", court=None,
773
+ decided_after=None):
774
+ """One predicate for both recall masking and the late result filter.
775
+ The act filter matches short name, code, or full name -- an agent
776
+ passing 'Immigration and Refugee Protection Act' should not get
777
+ silence. English queries never see French chunks; French queries see
778
+ both languages (guidance/case law exist only in English). court /
779
+ decided_after constrain CASE-LAW chunks only (a case-law chunk's
780
+ 'part' carries the deciding court and current_to the decision date);
781
+ non-caselaw chunks pass those filters untouched so a court-filtered
782
+ mixed search still surfaces the governing statute."""
783
+ if qlang == "en" and c.get("lang", "en") == "fr":
784
+ return False
785
+ if act:
786
+ a = act.lower()
787
+ if a not in (c["act_short"].lower(), c["act_code"].lower(),
788
+ c.get("act_name", "").lower()):
789
+ return False
790
+ if doc_type and c.get("doc_type", "legislation") != doc_type:
791
+ return False
792
+ if (court or decided_after) and c.get("doc_type") == "caselaw":
793
+ if court and court.lower() not in (c.get("part") or "").lower():
794
+ return False
795
+ if decided_after:
796
+ year = (c.get("current_to") or "")[:4]
797
+ if not year.isdigit() or int(year) < decided_after:
798
+ return False
799
+ return True
800
+
801
+ def search(self, query, top_k=6, act=None, doc_type=None, court=None,
802
+ decided_after=None):
803
+ """Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
804
+ # Repair misspellings first (no-op for correctly-spelled queries),
805
+ # then expand legal abbreviations (PRRA, H&C, ...) into statutory
806
+ # wording for the recall stages. The corrected text is what feeds
807
+ # the semantic embedder and the cross-encoder -- the stem-level
808
+ # fuzzy bridge in _bm25_scores only ever helped BM25, which is why
809
+ # typo queries used to reach the pool but not the top ranks.
810
+ corrected = self._correct_query(query)
811
+ expanded = expand_query(corrected)
812
+ qlang = query_lang(query)
813
+ confidence = None
814
+ fused = defaultdict(float)
815
+ # A filtered search competes only within its scope: the mask reaches
816
+ # both recall stages, so results come from the best of the whole
817
+ # filtered corpus rather than whatever survived open recall. An
818
+ # unfiltered English query uses the cached English-only mask; a
819
+ # French query searches both languages unmasked.
820
+ if act or doc_type or court or decided_after:
821
+ mask = [self._filter_ok(c, act, doc_type, qlang, court,
822
+ decided_after)
823
+ for c in self.chunks]
824
+ elif qlang == "en":
825
+ mask = self._en_mask
826
+ else:
827
+ mask = None
828
+ bm25 = self._bm25_scores(expanded, allowed=mask,
829
+ en_only=(qlang == "en"))
830
+ for rank, idx in enumerate(self._capped_top(
831
+ sorted(bm25, key=bm25.get, reverse=True))):
832
+ fused[idx] += 1.0 / (RRF_K + rank)
833
+ if self.semantic:
834
+ sem_order, confidence = self._semantic_ranking(expanded, allowed=mask)
835
+ for rank, idx in enumerate(self._capped_top(sem_order)):
836
+ fused[idx] += W_SEM / (RRF_K + rank)
837
+
838
+ # Ensure explicitly-referenced sections are retrieved even if recall
839
+ # missed them -- but only for Acts the query actually names. A query
840
+ # like "IRPA s. 40 misrepresentation defence" uses the section number
841
+ # topically; pulling every Act's s. 40 into the pool would drown out
842
+ # the case law that interprets the section the user meant. Substring
843
+ # check rather than token-overlap because act_codes split into trivial
844
+ # tokens ("A-8.8" -> {a, 8}) that spuriously match common query words.
845
+ refs = _section_refs(query)
846
+ q_lc = query.lower()
847
+ def _act_in_query(c):
848
+ short = c["act_short"].lower()
849
+ code = c["act_code"].lower()
850
+ return ((short and short in q_lc)
851
+ or (code and len(code) >= 3 and code in q_lc))
852
+ if refs:
853
+ for idx, c in enumerate(self.chunks):
854
+ if (c["section"] in refs and idx not in fused
855
+ and _act_in_query(c)):
856
+ fused[idx] = 0.0
857
+
858
+ # Title-match boost: the marginal note is a section's canonical subject.
859
+ # Reward a candidate by how completely and how specifically the query
860
+ # matches its marginal note. The overlap is idf-weighted (matching a
861
+ # distinctive title like "hours of work" counts far more than a generic
862
+ # one like "Decision"), scaled by coverage, and capped -- so it nudges
863
+ # ranking toward the provision a question names by topic without
864
+ # overriding a strong base score.
865
+ q_tokens = set(tokenize(expanded))
866
+ for idx in list(fused):
867
+ note_tokens = self._note_tokens[idx]
868
+ total = sum(self.idf.get(t, 0.0) for t in note_tokens)
869
+ if total <= 0:
870
+ continue
871
+ matched = sum(self.idf.get(t, 0.0)
872
+ for t in note_tokens if t in q_tokens)
873
+ if matched > 0:
874
+ fused[idx] += min(MN_WEIGHT * matched * matched / total, MN_CAP)
875
+
876
+ # Hierarchy penalties: a topical question should surface the governing
877
+ # provision, not the supplementary material around it. An Act creates a
878
+ # duty while a regulation only elaborates procedure; a collective
879
+ # agreement's numbered articles are its substance while its memoranda
880
+ # and letters of understanding are back-matter; a not-in-force
881
+ # amendment is pending law, not the law. Each takes a small fusion
882
+ # penalty so the governing provision wins a close contest (the NIF
883
+ # penalty is larger: pending text should surface only when it is the
884
+ # clearly best match for the subject, never beat the live provision).
885
+ for idx in list(fused):
886
+ if self._is_nif[idx]:
887
+ fused[idx] -= NIF_PENALTY
888
+ elif self._is_repealed[idx]:
889
+ fused[idx] -= REPEALED_PENALTY
890
+ elif self._is_regulation[idx]:
891
+ fused[idx] -= REG_PENALTY
892
+ elif self._is_backmatter[idx]:
893
+ fused[idx] -= BACKMATTER_PENALTY
894
+
895
+ candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
896
+ if self._filter_ok(self.chunks[i], act, doc_type, qlang,
897
+ court, decided_after)]
898
+ if not candidates:
899
+ return []
900
+ scores = {i: fused[i] for i in candidates}
901
+
902
+ # Precision stage: the cross-encoder rescores the top candidate pool, but
903
+ # may only PROMOTE -- each pooled candidate is placed at the better of its
904
+ # fusion rank and its rerank rank, never below its fusion rank. The
905
+ # reranker reliably surfaces a strong answer the fusion ranked low, yet is
906
+ # unreliable on long statutory text (it can score the right section
907
+ # negative), so its power to demote a candidate is deliberately removed.
908
+ if self.reranker:
909
+ pool = candidates[:RERANK_POOL]
910
+ docs = [self._rerank_doc(i) for i in pool]
911
+ ce_orig = self.reranker.score(query, docs)
912
+ # A repaired query is scored alongside the original and each
913
+ # candidate keeps its better score -- the cross-encoder cannot
914
+ # ground 'detension' against text saying 'detention'. Applies
915
+ # only when a correction fired, so legacy queries never take
916
+ # the second pass.
917
+ if corrected != query:
918
+ ce_fix = self.reranker.score(corrected, docs)
919
+ ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_fix)]
920
+ # Optional (CANLEX_RERANK_DUAL=1): likewise for the
921
+ # synonym-expanded form. Off by default: the 2026-07 eval could
922
+ # not attribute its effect cleanly on the grown corpus.
923
+ if expanded != corrected and os.environ.get("CANLEX_RERANK_DUAL"):
924
+ ce_exp = self.reranker.score(expanded, docs)
925
+ ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
926
+ ce = dict(zip(pool, ce_orig))
927
+ fusion_rank = {idx: r for r, idx in enumerate(pool)}
928
+ rerank_rank = {idx: r for r, idx in enumerate(
929
+ sorted(pool, key=ce.get, reverse=True))}
930
+ pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
931
+ fusion_rank[i]))
932
+ candidates = pool + candidates[RERANK_POOL:]
933
+
934
+ # Explicit section references are pinned to the very top -- using the
935
+ # same Act-mentioned constraint as the recall step above, for the same
936
+ # reason: a bare "s. 40" without an Act name is usually topical
937
+ # (e.g. "the IRPA s. 40 misrepresentation defence"), not a lookup.
938
+ if refs:
939
+ pinned = [i for i in candidates
940
+ if self.chunks[i]["section"] in refs
941
+ and _act_in_query(self.chunks[i])]
942
+ if pinned:
943
+ pinned_set = set(pinned)
944
+ candidates = pinned + [i for i in candidates if i not in pinned_set]
945
+
946
+ # Cap one-source monopolies, then guarantee a primary instrument on
947
+ # the topic is represented. The guarantee operates on a fixed visible
948
+ # window of min(top_k, 5), not the full top_k -- with top_k=20 (the
949
+ # eval default) the larger window almost always contains incidental
950
+ # legislation, so the guarantee never fires even when the governing
951
+ # provision is buried at rank 10+.
952
+ candidates = self._diversify(candidates)
953
+ candidates = self._ensure_primary(candidates, min(top_k, 5), q_tokens)
954
+
955
+ top = self._cosurface_appendices(candidates[:top_k])
956
+ highlights = self._highlight(query, top)
957
+ results = []
958
+ for pos, i in enumerate(top):
959
+ result = {**self.chunks[i], "score": round(scores.get(i, 0.0), 4),
960
+ "confidence": confidence}
961
+ if pos in highlights:
962
+ result["highlight"] = highlights[pos]
963
+ results.append(result)
964
+ return results
965
+
966
+ def get_section(self, act, section, lang="en"):
967
+ """The section chunk in the requested language, falling back to the
968
+ other language rather than returning nothing."""
969
+ act = act.lower()
970
+ fallback = None
971
+ for c in self.chunks:
972
+ if c["section"] == section and act in (
973
+ c["act_short"].lower(), c["act_code"].lower(),
974
+ c.get("act_name", "").lower()):
975
+ if c.get("lang", "en") == lang:
976
+ return c
977
+ fallback = fallback or c
978
+ return fallback
979
+
980
+ def _build_xref(self):
981
+ """Index legislation by (act, section); find each Act's definitions
982
+ section; link every regulation to its enabling Act and every
983
+ D-memorandum to the provisions it cites -- all for cross-referencing."""
984
+ self._by_section = {}
985
+ self._defs_section = {}
986
+ acts, regs = {}, {} # act_code -> (act_short, act_name)
987
+ for c in self.chunks:
988
+ if c.get("doc_type", "legislation") != "legislation":
989
+ continue
990
+ if c.get("lang", "en") == "fr":
991
+ continue # xref keys are per (act, section); English canonical
992
+ self._by_section[(c["act_code"], c["section"])] = c
993
+ if c["act_code"] not in self._defs_section and (
994
+ c["marginal_note"].strip().lower() in (
995
+ "definitions", "definition", "interpretation")):
996
+ self._defs_section[c["act_code"]] = c
997
+ bucket = regs if c["act_code"].startswith(("SOR", "C.R.C")) else acts
998
+ bucket.setdefault(c["act_code"], (c["act_short"], c["act_name"]))
999
+
1000
+ # Link a regulation to the Act it is made under by matching their names
1001
+ # ("X Regulations" <-> "X Act").
1002
+ self._enabling_act = {} # reg code -> (act_short, act_name)
1003
+ self._regulations = defaultdict(list) # act code -> [(reg_short, reg_name)]
1004
+ def base(name):
1005
+ return re.sub(r"\b(?:Act|Regulations)\b", "", name).strip().lower()
1006
+ act_by_base = {base(n): (code, s, n) for code, (s, n) in acts.items()}
1007
+ for rcode, (rshort, rname) in regs.items():
1008
+ hit = act_by_base.get(base(rname))
1009
+ if hit:
1010
+ self._enabling_act[rcode] = (hit[1], hit[2])
1011
+ self._regulations[hit[0]].append((rshort, rname))
1012
+
1013
+ # Link D-memoranda to the Customs Act / Customs Tariff provisions they
1014
+ # cite; an unqualified "the Act" in a D-memo means the Customs Act.
1015
+ by_short = {s.lower(): code for code, (s, n) in acts.items()}
1016
+ customs, tariff = by_short.get("customs act"), by_short.get("customs tariff")
1017
+ self._memos_for_section = defaultdict(set) # (act_code, section) -> memos
1018
+ for c in self.chunks:
1019
+ if c.get("doc_type") != "memorandum":
1020
+ continue
1021
+ for num, which in _MEMO_CITE.findall(c["text"]):
1022
+ code = tariff if which.lower() == "customs tariff" else customs
1023
+ if code:
1024
+ self._memos_for_section[(code, num)].add(c["section"])
1025
+
1026
+ def related(self, chunk):
1027
+ """Cross-references for a legislation result, as a dict: 'provisions'
1028
+ (intra-Act sections it cites, plus the definitions section),
1029
+ 'regulations' (made under this Act), 'enabling_act' (for a regulation,
1030
+ the Act it is made under) and 'memoranda' (D-memo numbers citing this
1031
+ section). Empty dict for case law, memoranda, etc."""
1032
+ if chunk.get("doc_type", "legislation") != "legislation":
1033
+ return {}
1034
+ act = chunk["act_code"]
1035
+ provisions, seen = [], {chunk["section"]}
1036
+ defs = self._defs_section.get(act)
1037
+ if defs and defs["section"] not in seen:
1038
+ provisions.append((defs["section"], defs["marginal_note"]))
1039
+ seen.add(defs["section"])
1040
+ for match in _XREF.finditer(chunk["text"]):
1041
+ sec = match.group(1)
1042
+ if sec in seen:
1043
+ continue
1044
+ target = self._by_section.get((act, sec))
1045
+ if target:
1046
+ provisions.append((sec, target["marginal_note"]))
1047
+ seen.add(sec)
1048
+ if len(provisions) >= 8:
1049
+ break
1050
+ return {
1051
+ "provisions": provisions,
1052
+ "regulations": self._regulations.get(act, []),
1053
+ "enabling_act": self._enabling_act.get(act),
1054
+ "memoranda": sorted(self._memos_for_section.get(
1055
+ (act, chunk["section"]), []))[:6],
1056
+ }
1057
+
1058
+
1059
+ def main():
1060
+ if len(sys.argv) < 2:
1061
+ print('usage: python -m canlex.index "your query"')
1062
+ return
1063
+ query = " ".join(sys.argv[1:])
1064
+ index = LegislationIndex()
1065
+ if index.reranker:
1066
+ mode = "hybrid + cross-encoder rerank"
1067
+ elif index.semantic:
1068
+ mode = "hybrid (BM25 + semantic)"
1069
+ else:
1070
+ mode = "BM25 only"
1071
+ print(f"{len(index.chunks)} sections indexed - {mode}. Query: {query!r}\n")
1072
+ for r in index.search(query):
1073
+ print(f"[{r['score']:.3f}] {r['citation']} - {r['marginal_note']}")
1074
+ print(f" {r['text'].replace(chr(10), ' ')[:160]}")
1075
+ print()
1076
+
1077
+
1078
+ if __name__ == "__main__":
1079
+ main()
canlex/ingest.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest Justice Laws XML into structured, section-level JSON chunks."""
2
+ import re
3
+ import sys
4
+ import time
5
+ import urllib.request
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from ._common import norm_ws as _norm, split_lines, write_corpus
9
+ from .config import SOURCES, RAW_DIR, PROCESSED_DIR
10
+
11
+ LIMS = "{http://justice.gc.ca/lims}"
12
+ BLOCK_TAGS = {"Subsection", "Paragraph", "Subparagraph", "Clause", "Definition"}
13
+
14
+ # A section whose entire rendered body is a placeholder marker -- "30 [Amendment]",
15
+ # "162 [Amendments]", "5 [Related provision]" -- left where an enacting Act amended
16
+ # another Act. Content-free; dropped at ingest. ([Repealed, ...] is deliberately
17
+ # excluded here so repealed sections are kept and flagged instead.)
18
+ _AMENDMENT_STUB = re.compile(
19
+ r"^\S+\s*\[(?:Amendments?|Related provisions?)\]\s*$", re.IGNORECASE)
20
+
21
+ # The semantic embedder (embed.py) only reads the first _MAX_BODY=2000 chars of
22
+ # a chunk, so the tail of a long section is invisible to semantic search. A
23
+ # section whose rendered text exceeds _SPLIT_THRESHOLD is therefore emitted as
24
+ # several piece-chunks, each <= _PIECE_TARGET chars (under the embed window) and
25
+ # split on subsection/line boundaries, so every part is embeddable. Sections at
26
+ # or below the threshold are emitted unchanged -- same id, same embedding --
27
+ # which keeps ~90% of legislation chunks (and their retrieval behaviour) intact.
28
+ _SPLIT_THRESHOLD = 2400
29
+ _PIECE_TARGET = 1800
30
+
31
+
32
+ def _split_text(text, target=_PIECE_TARGET):
33
+ """Split rendered section text into <=target pieces at line boundaries,
34
+ folding small leading/trailing remnants into a neighbour so no piece is a
35
+ stub."""
36
+ return split_lines(text, target, fold_stubs=True)
37
+
38
+
39
+ def _itertext(el):
40
+ return _norm("".join(el.itertext()))
41
+
42
+
43
+ def fetch_xml(code, force=False):
44
+ src = SOURCES[code]
45
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
46
+ dest = RAW_DIR / f"{code}.xml"
47
+ if dest.exists() and not force:
48
+ return dest
49
+ print(f" downloading {src['xml_url']}")
50
+ req = urllib.request.Request(src["xml_url"], headers={"User-Agent": "CanLex/0.1"})
51
+ with urllib.request.urlopen(req, timeout=120) as resp:
52
+ dest.write_bytes(resp.read())
53
+ time.sleep(1.0) # be polite to the Justice Laws server between downloads
54
+ return dest
55
+
56
+
57
+ def _heading_text(h):
58
+ """Render a <Heading> as 'LABEL - TITLE' when both parts are present."""
59
+ label, title = h.find("Label"), h.find("TitleText")
60
+ if label is not None or title is not None:
61
+ parts = [_itertext(e) for e in (label, title) if e is not None]
62
+ return " - ".join(p for p in parts if p)
63
+ return _itertext(h)
64
+
65
+
66
+ def _render_block(el, is_section=False):
67
+ """Recursively render a provision element into readable, structured text."""
68
+ label = note = ""
69
+ inline, blocks = [], []
70
+ for child in el:
71
+ tag = child.tag
72
+ if tag == "Label":
73
+ label = _itertext(child)
74
+ elif tag == "MarginalNote":
75
+ if not is_section: # a section's own marginal note is its title
76
+ note = _itertext(child)
77
+ elif tag == "Text":
78
+ inline.append(_itertext(child))
79
+ elif tag == "HistoricalNote":
80
+ continue
81
+ elif tag in BLOCK_TAGS:
82
+ blocks.append(_render_block(child))
83
+ else:
84
+ nested = _render_block(child)
85
+ if nested:
86
+ blocks.append(nested)
87
+ head = " ".join(p for p in (label, f"[{note}]" if note else "", " ".join(inline)) if p)
88
+ if blocks:
89
+ return (head + "\n" if head else "") + "\n".join(b for b in blocks if b)
90
+ return head
91
+
92
+
93
+ def _table_text(tablegroup):
94
+ """Render a CALS <TableGroup> as caption/title lines plus one
95
+ pipe-separated line per row. Column structure survives as ' | '
96
+ separators, which is enough for BM25/semantic matching and readable in a
97
+ result pane; no attempt is made to preserve spanned cells."""
98
+ out = []
99
+ for t in (tablegroup.find("Caption"), tablegroup.find("table/title")):
100
+ if t is not None:
101
+ txt = _itertext(t)
102
+ if txt:
103
+ out.append(txt)
104
+ for row in tablegroup.iter("row"):
105
+ cells = [_itertext(e) for e in row.findall("entry")]
106
+ line = " | ".join(cells).strip(" |")
107
+ if line:
108
+ out.append(line)
109
+ return "\n".join(out)
110
+
111
+
112
+ def _sched_body(el):
113
+ """Render a <Schedule>'s content elements into readable text.
114
+
115
+ Observed shapes across the corpus: List/Item trees (CDSA I-IV, IRPR
116
+ 1.1), DocumentInternal/Group/Provision prose (CDSA V-VI, IRPA's Refugee
117
+ Convention), and TableGroup grids (IRPR Schedule 2 violations, the AMPS
118
+ designated-provisions tables). Lists, groups and provisions reuse
119
+ _render_block, whose Label/Text recursion matches their structure;
120
+ tables get the dedicated renderer above so rows keep their shape."""
121
+ parts = []
122
+ for child in el:
123
+ tag = child.tag
124
+ if tag in ("ScheduleFormHeading", "HistoricalNote"):
125
+ continue
126
+ if tag == "TableGroup":
127
+ parts.append(_table_text(child))
128
+ elif tag == "GroupHeading":
129
+ parts.append(_heading_text(child))
130
+ elif tag in ("DocumentInternal", "Group", "RegulationPiece", "BillPiece"):
131
+ parts.append(_sched_body(child))
132
+ else:
133
+ rendered = _render_block(child)
134
+ if rendered:
135
+ parts.append(rendered)
136
+ return "\n".join(p for p in parts if p)
137
+
138
+
139
+ # Schedule ids with dedicated handling: NifProvs is ingested separately as
140
+ # not-in-force chunks; RelatedProvs (transitional provisions of past
141
+ # amendments) is deliberately excluded -- see _nif_chunks.
142
+ _SCHED_SKIP_IDS = {"NifProvs", "RelatedProvs"}
143
+
144
+
145
+ def _schedule_chunks(root, code, src, current_to):
146
+ """Chunks for an Act's substantive Schedules.
147
+
148
+ Previously every Schedule except NifProvs was silently dropped -- which
149
+ meant the CDSA's controlled-substance lists, the AMPS designated-
150
+ provisions tables and IRPA's Refugee Convention articles were absent
151
+ from the corpus even though the sections that depend on them were
152
+ ingested. A source can opt out with 'skip_schedules': True (the Customs
153
+ Tariff does: its schedule is the full tariff nomenclature, served
154
+ separately by tariff_schedule.py from the CBSA edition)."""
155
+ if src.get("skip_schedules"):
156
+ return []
157
+ chunks = []
158
+ for n, sched in enumerate(root.findall("Schedule"), start=1):
159
+ if sched.get("id") in _SCHED_SKIP_IDS:
160
+ continue
161
+ h = sched.find("ScheduleFormHeading")
162
+ label = _norm(h.findtext("Label") or "") if h is not None else ""
163
+ title = _norm(h.findtext("TitleText") or "") if h is not None else ""
164
+ oref = _norm(h.findtext("OriginatingRef") or "") if h is not None else ""
165
+ label = label or (f"SCHEDULE ({n})" if not title else title)
166
+ repealed = sched.find("Repealed") is not None
167
+ body = "[Repealed]" if repealed else _sched_body(sched)
168
+ if not body:
169
+ continue
170
+ header = " — ".join(p for p in (title, oref) if p)
171
+ text = (header + "\n" if header else "") + body
172
+ base = {
173
+ "act_code": code,
174
+ "act_short": src["short"],
175
+ "act_name": src["name"],
176
+ "section": label.title(),
177
+ "marginal_note": title or label.title(),
178
+ "part": "SCHEDULES",
179
+ "division": "",
180
+ "heading": label.title(),
181
+ "history": "",
182
+ "last_amended": sched.get(f"{LIMS}lastAmendedDate", ""),
183
+ "in_force": sched.get(f"{LIMS}inforce-start-date", ""),
184
+ "status": "repealed" if repealed else "in force",
185
+ "current_to": current_to,
186
+ "citation": f"{src['short']}, {label.title()}",
187
+ "source_url": src["web_base"],
188
+ }
189
+ pieces = (_split_text(text)
190
+ if len(text) > _SPLIT_THRESHOLD else [text])
191
+ total = len(pieces)
192
+ for k, piece in enumerate(pieces, start=1):
193
+ if total == 1:
194
+ chunks.append({**base, "id": f"{code}-sched{n}", "text": piece})
195
+ else:
196
+ chunks.append({
197
+ **base, "id": f"{code}-sched{n}-p{k}", "text": piece,
198
+ "citation": f"{base['citation']} (part {k} of {total})"})
199
+ return chunks
200
+
201
+
202
+ def _history(section):
203
+ note = section.find("HistoricalNote")
204
+ if note is None:
205
+ return ""
206
+ return "; ".join(_itertext(i) for i in note.iter("HistoricalNoteSubItem"))
207
+
208
+
209
+ def parse_legislation(xml_path, code):
210
+ src = SOURCES[code]
211
+ data = xml_path.read_bytes()
212
+ if data[:3] == b"\xef\xbb\xbf": # some Justice Laws XML files carry a UTF-8 BOM
213
+ data = data[3:]
214
+ root = ET.fromstring(data)
215
+ current_to = root.get(f"{LIMS}current-date", "")
216
+ body = root.find("Body")
217
+ if body is None:
218
+ # A schedule-only instrument (e.g. the Import Control List) has no
219
+ # <Body> at all -- its entire substance is in Schedules.
220
+ if not root.findall("Schedule"):
221
+ raise ValueError(f"{code}: no <Body> element (root <{root.tag}>)")
222
+ body = ()
223
+
224
+ headings, chunks = {}, []
225
+ for el in body:
226
+ if el.tag == "Heading":
227
+ try:
228
+ level = int(el.get("level", "0"))
229
+ except ValueError:
230
+ level = 0
231
+ headings = {k: v for k, v in headings.items() if k < level}
232
+ headings[level] = _heading_text(el)
233
+ continue
234
+ if el.tag != "Section":
235
+ continue
236
+
237
+ label_el = el.find("Label")
238
+ number = _itertext(label_el) if label_el is not None else ""
239
+ note_el = el.find("MarginalNote")
240
+ marginal = _itertext(note_el) if note_el is not None else ""
241
+ body_text = _render_block(el, is_section=True)
242
+
243
+ # Drop content-free amendment/related-provision stubs. When an Act
244
+ # amends OTHER Acts, the consolidation keeps a placeholder section whose
245
+ # whole body is just a marker -- e.g. "30 [Amendment]" -- the actual
246
+ # text living in the Act being amended. These carry no substance, yet
247
+ # were being indexed and rendered with an 'in force' currency line as if
248
+ # they were live provisions (117 in the CBSA Act alone). Skip them.
249
+ # (Repealed sections, "<n> [Repealed, ...]", are NOT dropped -- they are
250
+ # flagged status='repealed' and kept so a section lookup still informs.)
251
+ if _AMENDMENT_STUB.match(body_text):
252
+ continue
253
+
254
+ if number:
255
+ chunk_id = f"{code}-s{number}"
256
+ citation = f"{src['short']}, s. {number}"
257
+ source_url = f"{src['web_base']}section-{number}.html"
258
+ else:
259
+ # An unnumbered <Section> is a preamble or enacting recital. Keep it
260
+ # unless it has no text; the LIMS id keeps the chunk id unique when
261
+ # an Act contains more than one such block.
262
+ if not body_text:
263
+ continue
264
+ number = marginal or "Preamble"
265
+ marginal = marginal or "Preamble"
266
+ lims_id = el.get(f"{LIMS}id") or str(len(chunks))
267
+ chunk_id = f"{code}-pre-{lims_id}"
268
+ citation = f"{src['short']}, {number}"
269
+ source_url = src["web_base"]
270
+
271
+ part = next((v for _, v in sorted(headings.items())
272
+ if v.upper().startswith("PART")), "")
273
+ division = next((v for _, v in sorted(headings.items())
274
+ if v.upper().startswith("DIVISION")), "")
275
+ nearest = headings[max(headings)] if headings else ""
276
+
277
+ # Per-section coming-into-force date (lims:inforce-start-date is present
278
+ # on every section) and repeal status. A wholly-repealed section is a
279
+ # <Section> whose own <Text> is a <Repealed> marker (e.g. "[Repealed,
280
+ # 2018, c. 27, s. 174]"); a live section with a repealed subsection has
281
+ # the <Repealed> nested deeper, so we key on the direct Text/Repealed to
282
+ # flag only sections that are themselves no longer law.
283
+ in_force = el.get(f"{LIMS}inforce-start-date", "")
284
+ status = "repealed" if el.find("Text/Repealed") is not None else "in force"
285
+
286
+ base = {
287
+ "act_code": code,
288
+ "act_short": src["short"],
289
+ "act_name": src["name"],
290
+ "section": number,
291
+ "marginal_note": marginal,
292
+ "part": part,
293
+ "division": division,
294
+ "heading": nearest,
295
+ "history": _history(el),
296
+ "last_amended": el.get(f"{LIMS}lastAmendedDate", ""),
297
+ "in_force": in_force,
298
+ "status": status,
299
+ "current_to": current_to,
300
+ "source_url": source_url,
301
+ }
302
+
303
+ # Short sections (the large majority) stay one chunk with their
304
+ # original id and citation -- unchanged embedding and retrieval. Long
305
+ # ones are split into embeddable pieces; each piece keeps the section
306
+ # number (so citation lookups, section-ref pinning and the eval still
307
+ # match) and notes its part in the citation.
308
+ pieces = (_split_text(body_text)
309
+ if len(body_text) > _SPLIT_THRESHOLD else [body_text])
310
+ n = len(pieces)
311
+ for k, piece in enumerate(pieces, start=1):
312
+ if n == 1:
313
+ chunks.append({**base, "id": chunk_id, "text": piece,
314
+ "citation": citation})
315
+ else:
316
+ chunks.append({**base, "id": f"{chunk_id}-p{k}", "text": piece,
317
+ "citation": f"{citation} (part {k} of {n})"})
318
+
319
+ chunks.extend(_schedule_chunks(root, code, src, current_to))
320
+ chunks.extend(_nif_chunks(root, code, src, current_to))
321
+ return chunks
322
+
323
+
324
+ def _nif_chunks(root, code, src, current_to):
325
+ """Chunks for the Act's AMENDMENTS NOT IN FORCE schedule.
326
+
327
+ Justice Laws consolidations carry enacted-but-not-yet-in-force amendments
328
+ in a Schedule with id='NifProvs' (each entry a RelatedOrNotInForce node:
329
+ a citation heading for the amending Act plus the amendment text as regular
330
+ Section elements). Previously the parser dropped these silently -- the
331
+ corpus's biggest currency blind spot, since a Royal-Assented amendment
332
+ days from changing the law was invisible until consolidation. They are
333
+ ingested with status='not-in-force': retrieval down-weights them below
334
+ live law and the server banners them, but a search for their subject
335
+ matter now surfaces the pending law instead of nothing.
336
+
337
+ The sibling Schedule id='RelatedProvs' (transitional related provisions)
338
+ is deliberately NOT ingested here: those are in-force companion rules of
339
+ past amendments, not pending law, and would need their own treatment.
340
+
341
+ Note the Label inside an entry is the AMENDING Act's section number, so
342
+ the chunk's 'section' is left empty -- these must never satisfy a lookup
343
+ or pin for the target Act's own section numbers.
344
+ """
345
+ nif = next((s for s in root.findall("Schedule")
346
+ if s.get("id") == "NifProvs"), None)
347
+ if nif is None:
348
+ return []
349
+ chunks = []
350
+ for n, entry in enumerate(nif.iter("RelatedOrNotInForce"), start=1):
351
+ title = entry.find("Heading/TitleText")
352
+ cite = _norm("".join(title.itertext())) if title is not None else ""
353
+ cite = cite.lstrip("—- ").strip()
354
+ sections = entry.findall("Section")
355
+ body = "\n".join(p for p in (_render_block(s, is_section=True)
356
+ for s in sections) if p)
357
+ if not body:
358
+ continue
359
+ note_el = sections[0].find("MarginalNote") if sections else None
360
+ marginal = _norm("".join(note_el.itertext())) if note_el is not None else ""
361
+ pieces = (_split_text(body)
362
+ if len(body) > _SPLIT_THRESHOLD else [body])
363
+ total = len(pieces)
364
+ for k, piece in enumerate(pieces, start=1):
365
+ suffix = "" if total == 1 else f"-p{k}"
366
+ part_note = "" if total == 1 else f" (part {k} of {total})"
367
+ chunks.append({
368
+ "id": f"{code}-nif-{n}{suffix}",
369
+ "act_code": code,
370
+ "act_short": src["short"],
371
+ "act_name": src["name"],
372
+ "section": "",
373
+ "marginal_note": marginal or f"Amendment not in force — {cite}",
374
+ "part": "AMENDMENTS NOT IN FORCE",
375
+ "division": "",
376
+ "heading": "Amendments not in force",
377
+ "text": piece,
378
+ "history": cite,
379
+ "last_amended": "",
380
+ "in_force": "",
381
+ "status": "not-in-force",
382
+ "current_to": current_to,
383
+ "citation": (f"{src['short']} — amendment not in force "
384
+ f"({cite}){part_note}"),
385
+ "source_url": src["web_base"],
386
+ })
387
+ return chunks
388
+
389
+
390
+ def ingest(code, force=False, allow_shrink=False):
391
+ print(f"Ingesting {code} ({SOURCES[code]['short']})...")
392
+ xml_path = fetch_xml(code, force=force)
393
+ chunks = parse_legislation(xml_path, code)
394
+ out = PROCESSED_DIR / f"{code}.json"
395
+ # An Act whose XML schema shifts parses to a fraction of its sections; the
396
+ # guard turns that into a refusal instead of a quietly gutted statute.
397
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
398
+ raise RuntimeError(f"{code}: refused to write a collapsed corpus")
399
+ print(f" {len(chunks)} sections -> {out.name}")
400
+ return chunks
401
+
402
+
403
+ # --- French corpus (phase 3 of bilingual support) ----------------------------
404
+ # Justice Laws publishes every consolidation as parallel French XML in the
405
+ # SAME schema (English element names, French text, identical section
406
+ # numbering), so the whole parser -- schedules included -- works unchanged.
407
+ # French chunks carry lang='fr', a '-fr-' id infix, French citation style
408
+ # ('art.' / 'annexe') and the French web URL; retrieval filters by query
409
+ # language (index.py), and get_section/xref/list_acts stay English-first.
410
+
411
+ def _fr_xml_url(src):
412
+ return (src["xml_url"]
413
+ .replace("/eng/XML/", "/fra/XML/")
414
+ .replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
415
+ .replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
416
+
417
+
418
+ def _frenchify(chunks, code):
419
+ out = []
420
+ for c in chunks:
421
+ c = dict(c)
422
+ c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
423
+ c["lang"] = "fr"
424
+ c["citation"] = (c["citation"]
425
+ .replace(", s. ", ", art. ")
426
+ .replace(", Schedule", ", annexe"))
427
+ c["source_url"] = (c["source_url"]
428
+ .replace("/eng/acts/", "/fra/lois/")
429
+ .replace("/eng/regulations/", "/fra/reglements/"))
430
+ out.append(c)
431
+ return out
432
+
433
+
434
+ def ingest_fr(code, force=False, allow_shrink=False):
435
+ src = SOURCES[code]
436
+ dest = RAW_DIR / f"{code}-fr.xml"
437
+ if not dest.exists() or force:
438
+ url = _fr_xml_url(src)
439
+ print(f" downloading {url}")
440
+ req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
441
+ with urllib.request.urlopen(req, timeout=120) as resp:
442
+ dest.write_bytes(resp.read())
443
+ time.sleep(1.0)
444
+ chunks = _frenchify(parse_legislation(dest, code), code)
445
+ out = PROCESSED_DIR / f"{code}-fr.json"
446
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
447
+ raise RuntimeError(f"{code}: refused to write a collapsed corpus")
448
+ print(f" {len(chunks)} articles -> {out.name}")
449
+ return chunks
450
+
451
+
452
+ def main():
453
+ force = "--force" in sys.argv
454
+ french = "--fr" in sys.argv
455
+ allow_shrink = "--allow-shrink" in sys.argv
456
+ only = [a for a in sys.argv[1:] if not a.startswith("-")]
457
+ codes = only or list(SOURCES)
458
+ failures = []
459
+ for code in codes:
460
+ if code not in SOURCES:
461
+ print(f" SKIP {code}: not a known source")
462
+ continue
463
+ try:
464
+ if french:
465
+ print(f"Ingesting {code} (français)...")
466
+ ingest_fr(code, force=force, allow_shrink=allow_shrink)
467
+ else:
468
+ ingest(code, force=force, allow_shrink=allow_shrink)
469
+ except Exception as exc:
470
+ failures.append((code, exc))
471
+ print(f" FAILED {code}: {type(exc).__name__}: {exc}")
472
+ print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
473
+ for code, exc in failures:
474
+ print(f" FAILED {code}: {exc}")
475
+ # One collapsed Act must not pass as a clean run: refresh.py and the
476
+ # RUNBOOK chain this into `&& py -m canlex.embed`.
477
+ sys.exit(1 if failures else 0)
478
+
479
+
480
+ if __name__ == "__main__":
481
+ main()
canlex/irb_guidelines.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]
38
+
39
+ # "Effective date: May 31, 2021" (or amended/revised) in the page prose.
40
+ _EFFECTIVE = re.compile(
41
+ r"(?:effective|amended|revised)[^.:\n]*:?\s*((?:January|February|March|"
42
+ r"April|May|June|July|August|September|October|November|December)\s+"
43
+ r"\d{1,2},\s+\d{4})", re.I)
44
+ _MONTHS = {m: i for i, m in enumerate(
45
+ ("January", "February", "March", "April", "May", "June", "July",
46
+ "August", "September", "October", "November", "December"), start=1)}
47
+
48
+
49
+ def _iso(prose):
50
+ m = re.match(r"(\w+)\s+(\d{1,2}),\s+(\d{4})", prose or "")
51
+ if not m or m.group(1) not in _MONTHS:
52
+ return ""
53
+ return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
54
+
55
+
56
+ def _sections(main):
57
+ """Yield (label, heading, text) per numbered heading in the page body."""
58
+ heads = main.find_all(["h2", "h3"])
59
+ for i, h in enumerate(heads):
60
+ heading = _norm(h.get_text())
61
+ m = re.match(r"(\d+(?:\.\d+)*)\.?\s+(.*)", heading)
62
+ label = m.group(1) if m else str(i + 1)
63
+ title = m.group(2) if m else heading
64
+ parts = []
65
+ for sib in h.next_siblings:
66
+ if getattr(sib, "name", None) in ("h2", "h3"):
67
+ break
68
+ if getattr(sib, "get_text", None):
69
+ parts.append(sib.get_text("\n"))
70
+ body = "\n".join(p for p in (_norm(x) for x in
71
+ "\n".join(parts).split("\n")) if p)
72
+ if len(body) > 80:
73
+ yield label, title, body
74
+
75
+
76
+ def build(allow_shrink=False):
77
+ chunks, failures = [], []
78
+ for g in GUIDELINES:
79
+ try:
80
+ html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
81
+ pause=1.0).decode("utf-8", "replace")
82
+ except Exception as exc:
83
+ print(f" !! Guideline {g['num']}: fetch failed "
84
+ f"({type(exc).__name__}: {exc})")
85
+ failures.append(f"IRB-G{g['num']}")
86
+ continue
87
+ soup = BeautifulSoup(html, "html.parser")
88
+ main = soup.find("main") or soup
89
+ eff = _EFFECTIVE.search(main.get_text())
90
+ effective = _iso(eff.group(1)) if eff else ""
91
+ n = 0
92
+ for label, title, body in _sections(main):
93
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
94
+ total = len(pieces)
95
+ for k, piece in enumerate(pieces, start=1):
96
+ suffix = "" if total == 1 else f"-p{k}"
97
+ note = "" if total == 1 else f" (part {k} of {total})"
98
+ chunks.append({
99
+ "id": f"irbg-{g['num']}-{label.replace('.', '-')}{suffix}",
100
+ "doc_type": "memorandum",
101
+ "act_code": f"IRB-G{g['num']}",
102
+ "act_short": f"IRB Guideline {g['num']}",
103
+ "act_name": f"IRB Chairperson's Guideline {g['num']} — "
104
+ f"{g['title']}",
105
+ "section": f"G{g['num']} s. {label}",
106
+ "marginal_note": title,
107
+ "part": g["title"],
108
+ "division": "",
109
+ "heading": "",
110
+ "text": piece,
111
+ "history": "",
112
+ "last_amended": effective,
113
+ "current_to": effective,
114
+ "citation": f"Chairperson's Guideline {g['num']}, "
115
+ f"s. {label} ({title}){note}",
116
+ "source_url": g["url"],
117
+ })
118
+ n += 1
119
+ if not n:
120
+ # Fetched, parsed to nothing: the IRB page template moved.
121
+ print(f" !! Guideline {g['num']}: no content parsed; "
122
+ f"keeping stored copy")
123
+ failures.append(f"IRB-G{g['num']}")
124
+ continue
125
+ print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
126
+ f"effective {effective or '?'}")
127
+ out = PROCESSED_DIR / "irb_guidelines.json"
128
+ chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
129
+ key=lambda c: c["act_code"],
130
+ only=set(failures))
131
+ if preserved:
132
+ print(f" kept the last-good copy of {len(preserved)} guideline(s) "
133
+ f"that failed this run: {', '.join(preserved)}")
134
+ uniquify_ids(chunks)
135
+ if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
136
+ return False
137
+ print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
138
+ return chunks
139
+
140
+
141
+ def main():
142
+ # `is not False` rather than truthiness: a first-ever run that writes an
143
+ # empty corpus succeeded, a refused write did not.
144
+ ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
145
+ sys.exit(0 if ok else 1)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
canlex/loganalyze.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mine the query log for real-world retrieval gaps.
2
+
3
+ The MCP server appends one JSON line per search to CANLEX_LOG_DIR/queries.jsonl
4
+ when CANLEX_LOG_DIR is set (see server._log_query). Real user queries -- the
5
+ ones that returned nothing or scored a weak match -- are the only unbiased
6
+ source of corpus and synonym gaps, since the 155-question eval set is authored
7
+ by the maintainer. This tool surfaces the weakest and most frequent of them as
8
+ candidates for new eval questions, synonyms, or corpus additions.
9
+
10
+ py -m canlex.loganalyze [path/to/queries.jsonl] # defaults to CANLEX_LOG_DIR
11
+
12
+ By default it lists no-result queries, then the lowest-confidence queries, then
13
+ the most-repeated ones. Privacy: query text may contain personal facts -- treat
14
+ the log as sensitive and do not commit it.
15
+ """
16
+ import json
17
+ import os
18
+ import sys
19
+ from collections import Counter
20
+ from pathlib import Path
21
+
22
+ # Mirror server.HEDGE_THRESHOLD's intent without importing the server: a query
23
+ # whose best result scored below this is a weak match worth reviewing.
24
+ _WEAK = 0.72
25
+
26
+
27
+ def _load(path):
28
+ rows = []
29
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
30
+ line = line.strip()
31
+ if not line:
32
+ continue
33
+ try:
34
+ rows.append(json.loads(line))
35
+ except ValueError:
36
+ continue
37
+ return rows
38
+
39
+
40
+ def report(path):
41
+ rows = _load(path)
42
+ if not rows:
43
+ print(f"No logged queries in {path}.")
44
+ return
45
+ print(f"{len(rows)} logged queries in {path}\n")
46
+
47
+ no_result = [r for r in rows if r.get("n_results", 0) == 0]
48
+ weak = sorted((r for r in rows
49
+ if r.get("confidence") is not None
50
+ and r["confidence"] < _WEAK and r.get("n_results")),
51
+ key=lambda r: r["confidence"])
52
+ freq = Counter(r.get("query", "").strip().lower() for r in rows)
53
+
54
+ print(f"== No results ({len(no_result)}) -- likely corpus or vocabulary gaps ==")
55
+ for r in no_result[:25]:
56
+ print(f" {r.get('query','')}")
57
+
58
+ print(f"\n== Weak matches (confidence < {_WEAK}; {len(weak)}) -- candidate "
59
+ f"eval questions / synonyms ==")
60
+ for r in weak[:25]:
61
+ top = (r.get("top_citations") or ["?"])[0]
62
+ print(f" {r['confidence']:.3f} {r.get('query','')[:70]:70} -> {top}")
63
+
64
+ print("\n== Most repeated queries (demand signal) ==")
65
+ for q, n in freq.most_common(15):
66
+ if n > 1:
67
+ print(f" {n:>3}x {q}")
68
+
69
+
70
+ def main():
71
+ path = (sys.argv[1] if len(sys.argv) > 1
72
+ else os.path.join(os.environ.get("CANLEX_LOG_DIR", "."), "queries.jsonl"))
73
+ if not Path(path).exists():
74
+ print(f"No query log at {path}. Set CANLEX_LOG_DIR on the server (and "
75
+ f"pass the path here) to collect one.", file=sys.stderr)
76
+ sys.exit(1)
77
+ report(path)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
canlex/pdi.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]
71
+
72
+ _MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
73
+ _TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
74
+ _LINK = re.compile(r'href="([^"#?]*?\.html)"')
75
+
76
+
77
+ def _strip_tags(html):
78
+ html = re.sub(r"</t[dh]>", " | ", html)
79
+ html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
80
+ html = re.sub(r"<[^>]+>", " ", html)
81
+ return [l for l in (_norm(x).strip("| ").strip()
82
+ for x in html.split("\n")) if l]
83
+
84
+
85
+ def _main(html):
86
+ m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
87
+ return m.group(1) if m else html
88
+
89
+
90
+ def _page_chunks(src, page_url, html, page_no):
91
+ content = _main(html)
92
+ title_m = _TITLE.search(content)
93
+ title = (_norm(re.sub(r"<[^>]+>", " ", title_m.group(1)))
94
+ if title_m else src["short"])
95
+ mod = _MODIFIED.search(html)
96
+ modified = mod.group(1) if mod else ""
97
+ # Split on h2 headings; each becomes a section-like unit.
98
+ parts = re.split(r"<h2[^>]*>", content[title_m.end():] if title_m else content)
99
+ chunks = []
100
+ for i, part in enumerate(parts):
101
+ if i == 0:
102
+ heading, body_html = title, part
103
+ else:
104
+ head_end = part.find("</h2>")
105
+ heading = _norm(re.sub(r"<[^>]+>", " ", part[:head_end]))
106
+ body_html = part[head_end:]
107
+ body = "\n".join(_strip_tags(body_html))
108
+ if len(body) < 120:
109
+ continue
110
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
111
+ total = len(pieces)
112
+ for k, piece in enumerate(pieces, start=1):
113
+ suffix = "" if total == 1 else f"-p{k}"
114
+ note = "" if total == 1 else f" (part {k} of {total})"
115
+ chunks.append({
116
+ "id": f"{src['code']}-{page_no}-{i}{suffix}",
117
+ "doc_type": "memorandum",
118
+ "act_code": src["code"].upper(),
119
+ "act_short": src["short"],
120
+ "act_name": src["name"],
121
+ "section": f"{title[:60]} — {heading[:60]}" if i else title[:80],
122
+ "marginal_note": heading,
123
+ "part": src["name"],
124
+ "division": "",
125
+ "heading": "",
126
+ "text": piece,
127
+ "history": "",
128
+ "last_amended": modified,
129
+ "current_to": modified,
130
+ "citation": f"{src['short']}: {heading}{note}",
131
+ "source_url": page_url,
132
+ })
133
+ return chunks
134
+
135
+
136
+ def preserve_failed(chunks, failures, stored):
137
+ """Re-attach the last-good chunks of sub-pages this run could not fetch.
138
+
139
+ Ingestion is a full rebuild, so a sub-page that 404s or drops its
140
+ connection silently vanishes from the corpus; serving the copy we already
141
+ had beats losing instructions that are still in force. Keyed on source_url,
142
+ the only per-page identity a PDI chunk carries that survives pages coming
143
+ and going -- the chunk id embeds the page's position in the index listing,
144
+ which shifts whenever one page drops out. Restricted to pages that actually
145
+ errored, so a sub-page IRCC retired on purpose is not resurrected.
146
+ Returns (chunks, preserved page URLs).
147
+ """
148
+ return preserve_dropped(chunks, stored, key=lambda c: c["source_url"],
149
+ only={url for url, _why in failures})
150
+
151
+
152
+ def _crawl(src, failures):
153
+ """Breadth-first crawl of one PDI category: index -> in-scope links ->
154
+ their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
155
+ Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
156
+ failures land in `failures` so preserve_failed keeps their stored
157
+ copies."""
158
+ index_html = fetch_cached(
159
+ src["index"], RAW / f"{src['code']}-index.html",
160
+ powershell=True, pause=0.8).decode("utf-8", "replace")
161
+ pages = [(src["index"], index_html)]
162
+ if not src["scope"]:
163
+ return pages
164
+ seen = {src["index"]}
165
+ frontier = [(src["index"], index_html)]
166
+ dropped = 0
167
+ for _depth in range(_MAX_DEPTH):
168
+ next_frontier = []
169
+ for _url, html in frontier:
170
+ for href in _LINK.findall(_main(html)):
171
+ if src["scope"] not in href:
172
+ continue
173
+ url = href if href.startswith("http") else \
174
+ "https://www.canada.ca" + href
175
+ if url in seen:
176
+ continue
177
+ seen.add(url)
178
+ if len(pages) >= _MAX_PAGES:
179
+ dropped += 1
180
+ continue
181
+ name = url.rsplit("/", 1)[1].replace(".html", "")
182
+ try:
183
+ page = fetch_cached(
184
+ url, RAW / f"{src['code']}-{name}.html",
185
+ powershell=True, pause=0.8).decode("utf-8", "replace")
186
+ except Exception as exc:
187
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
188
+ print(f" !! {name}: {type(exc).__name__}: {exc}")
189
+ continue
190
+ pages.append((url, page))
191
+ next_frontier.append((url, page))
192
+ frontier = next_frontier
193
+ if dropped:
194
+ print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
195
+ f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
196
+ return pages
197
+
198
+
199
+ def build(allow_shrink=False):
200
+ """Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
201
+ all_chunks, failures = [], []
202
+ for src in SETS:
203
+ try:
204
+ pages = _crawl(src, failures)
205
+ except Exception as exc:
206
+ failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
207
+ print(f" !! {src['short']}: index fetch failed "
208
+ f"({type(exc).__name__}: {exc})")
209
+ continue
210
+ n = 0
211
+ for page_no, (url, html) in enumerate(pages):
212
+ chunks = _page_chunks(src, url, html, page_no)
213
+ if not chunks:
214
+ # Downloaded fine, parsed to nothing: the page template moved
215
+ # under us. That is exactly how the D-memo corpus was lost, so
216
+ # it counts as a failure and the stored page is kept.
217
+ print(f" !! {url.rsplit('/', 1)[-1]}: no content parsed; "
218
+ f"keeping stored copy")
219
+ failures.append((url, "no content parsed"))
220
+ continue
221
+ all_chunks.extend(chunks)
222
+ n += len(chunks)
223
+ print(f" {src['short']}: {len(pages)} pages, {n} chunks")
224
+
225
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
226
+ stored_chunks(OUT_FILE))
227
+ if preserved:
228
+ names = ", ".join(u.rsplit("/", 1)[-1] for u in preserved)
229
+ print(f" kept the last-good copy of {len(preserved)} unfetchable "
230
+ f"page(s): {names}")
231
+ # After preservation, not before: a preserved page brings back the ids it
232
+ # was written with, and this run may have handed those ids to other pages
233
+ # (an id embeds the page's position in the index listing).
234
+ uniquify_ids(all_chunks)
235
+ if not write_corpus(OUT_FILE, all_chunks, indent=2,
236
+ allow_shrink=allow_shrink):
237
+ return False
238
+ print(f"pdi: {len(all_chunks)} chunks -> {OUT_FILE.name}")
239
+ return all_chunks
240
+
241
+
242
+ def main():
243
+ # `is not False` rather than truthiness: a first-ever run that writes an
244
+ # empty corpus succeeded, a refused write did not.
245
+ ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
246
+ sys.exit(0 if ok else 1)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
canlex/refresh.py ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
28
+
29
+ This is the staleness detection the corpus previously lacked: legislation was
30
+ refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
31
+
32
+ py -m canlex.refresh --check # human-readable; exit 1 on drift
33
+ py -m canlex.refresh --check --json # machine-readable for CI
34
+ """
35
+ import datetime
36
+ import json
37
+ import re
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ import urllib.request
42
+ from pathlib import Path
43
+
44
+ from bs4 import BeautifulSoup
45
+
46
+ from . import delegation, directive, dmemo
47
+ from ._common import BROWSER_UA, MONTHS
48
+ from .config import SOURCES, PROCESSED_DIR
49
+
50
+ _UA = "CanLex-refresh/0.1"
51
+ _HEAD_BYTES = 16384 # the root element's attributes sit at the very top
52
+ _CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
53
+
54
+ # Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
55
+ # command, description). These carry no single upstream consolidation date, so
56
+ # the check reports their STORED currency (newest item) and how to refresh them
57
+ # rather than diffing against upstream. (Delegation and the NJC directives are
58
+ # checked against upstream separately, by check_delegation and check_directives,
59
+ # because their sources DO expose a comparable signal -- the CBSA instrument
60
+ # index and IL3 version string, and the NJC directive index dates.)
61
+ _NON_XML = [
62
+ ("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
63
+ ("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
64
+ ("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
65
+ ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
66
+ ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
67
+ "Customs Tariff Schedule ch. 98-99"),
68
+ ("enf", "enf.json", "py -m canlex.enf", "ENF operational manuals"),
69
+ ("amps", "amps.json", "py -m canlex.amps", "AMPS Master Penalty Document"),
70
+ ("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
71
+ ("commentary", "commentary.json", "py -m canlex.commentary",
72
+ "Curated commentary chunks"),
73
+ ("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
74
+ ("pdi", "pdi.json", "py -m canlex.pdi",
75
+ "IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
76
+ ("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
77
+ "Mémorandums D (français)"),
78
+ ("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
79
+ "Directives du CNM (français)"),
80
+ ("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
81
+ "IRB Chairperson's Guidelines"),
82
+ ]
83
+
84
+
85
+ _CIU_DECISIONS = "https://ciu-sdi.ca/en/important-decisions/"
86
+ _CIU_DEC_MARKER = PROCESSED_DIR.parent / "raw" / "ciu" / "decisions-page-date.txt"
87
+
88
+
89
+ def check_ciu_decisions():
90
+ """New-case-law tripwire: the CIU curates its Important Decisions page,
91
+ so its dateModified moving is a cheap signal that a new decision worth
92
+ ingesting has issued. This is the only automated freshness signal the
93
+ case-law corpus has -- courts publish no 'new leading case' feed -- and
94
+ it complements the quarterly gap-audit ritual rather than replacing it."""
95
+ try:
96
+ req = urllib.request.Request(
97
+ _CIU_DECISIONS, headers={"User-Agent": BROWSER_UA})
98
+ with urllib.request.urlopen(req, timeout=60) as resp:
99
+ html = resp.read().decode("utf-8", "replace")
100
+ m = re.search(r'"dateModified":"(\d{4}-\d{2}-\d{2})', html)
101
+ remote = m.group(1) if m else ""
102
+ except Exception as exc:
103
+ return {"stored": "", "remote": "", "status": "error",
104
+ "error": f"{type(exc).__name__}: {exc}"}
105
+ if not remote:
106
+ return {"stored": "", "remote": "", "status": "no-date", "error": ""}
107
+ stored = (_CIU_DEC_MARKER.read_text(encoding="utf-8").strip()
108
+ if _CIU_DEC_MARKER.exists() else "")
109
+ if not stored:
110
+ # First run: record the baseline rather than alarming.
111
+ _CIU_DEC_MARKER.parent.mkdir(parents=True, exist_ok=True)
112
+ _CIU_DEC_MARKER.write_text(remote, encoding="utf-8")
113
+ stored = remote
114
+ status = "stale" if remote > stored else "ok"
115
+ return {"stored": stored, "remote": remote, "status": status, "error": ""}
116
+
117
+
118
+ def check_union():
119
+ """Upstream drift for the CIU/PSAC documents (incl. every site VSSA).
120
+
121
+ The union reposts amended documents at the same URLs, so the drift
122
+ signal is each PDF's Last-Modified header against our cached copy's
123
+ fetch time. New/removed VSSAs surface as fetch errors or new links on
124
+ the next canlex.ciu run; this check covers content changes to what we
125
+ already hold."""
126
+ import email.utils
127
+ from . import ciu
128
+ rows = []
129
+ targets = [(short, lang, ciu.RAW / f"{code}{'-fr' if lang == 'fr' else ''}.pdf", url)
130
+ for code, short, name, url, lang in ciu.DOCS]
131
+ try:
132
+ targets += [(f"VSSA {stem}", "en",
133
+ ciu.RAW / f"vssa-{stem.lower()}.pdf", url)
134
+ for url, stem in ciu._vssa_links()]
135
+ except Exception as exc:
136
+ rows.append({"short": "VSSA index", "lang": "en", "status": "error",
137
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
138
+ for short, lang, cache, url in targets:
139
+ try:
140
+ req = urllib.request.Request(url, method="HEAD",
141
+ headers={"User-Agent": BROWSER_UA})
142
+ with urllib.request.urlopen(req, timeout=30) as resp:
143
+ lm = resp.headers.get("Last-Modified", "")
144
+ except Exception as exc:
145
+ rows.append({"short": short, "lang": lang, "status": "error",
146
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
147
+ continue
148
+ if not lm:
149
+ rows.append({"short": short, "lang": lang, "status": "no-date",
150
+ "remote": "", "error": ""})
151
+ continue
152
+ remote_ts = email.utils.parsedate_to_datetime(lm).timestamp()
153
+ local_ts = cache.stat().st_mtime if cache.exists() else 0
154
+ rows.append({"short": short, "lang": lang,
155
+ "status": "stale" if remote_ts > local_ts else "ok",
156
+ "remote": lm[:25], "error": ""})
157
+ time.sleep(0.3)
158
+ return rows
159
+
160
+ # Curated datasets age: state legislatures amend deferred-adjudication
161
+ # statutes every session, so a reviewed date older than this many days earns
162
+ # a warning even though nothing upstream can be diffed automatically.
163
+ _CURATED_MAX_AGE_DAYS = 180
164
+
165
+
166
+ def check_curated(today):
167
+ """Age-check the curated datasets and detect edit-without-regen drift.
168
+
169
+ Two signals: (1) each data/curated/*.json 'reviewed' date older than
170
+ _CURATED_MAX_AGE_DAYS -> aging (re-verify against the state sources);
171
+ (2) the newest commentary.json current_to differing from the newest
172
+ curated 'reviewed' -> the curated file was edited without rerunning
173
+ canlex.commentary + canlex.embed, so production serves stale chunks."""
174
+ from .config import DATA_DIR
175
+ rows = []
176
+ newest_reviewed = ""
177
+ for path in sorted((DATA_DIR / "curated").glob("*.json")):
178
+ try:
179
+ reviewed = json.loads(
180
+ path.read_text(encoding="utf-8")).get("reviewed", "")
181
+ except Exception as exc:
182
+ rows.append({"file": path.name, "reviewed": "",
183
+ "status": "error", "error": f"{type(exc).__name__}: {exc}"})
184
+ continue
185
+ newest_reviewed = max(newest_reviewed, reviewed or "")
186
+ status = "ok"
187
+ if reviewed:
188
+ age = (datetime.date.fromisoformat(today)
189
+ - datetime.date.fromisoformat(reviewed)).days
190
+ if age > _CURATED_MAX_AGE_DAYS:
191
+ status = "aging"
192
+ rows.append({"file": path.name, "reviewed": reviewed,
193
+ "status": status, "error": ""})
194
+ commentary = PROCESSED_DIR / "commentary.json"
195
+ if newest_reviewed and commentary.exists():
196
+ chunks = json.loads(commentary.read_text(encoding="utf-8"))
197
+ newest_chunk = max((c.get("current_to", "") for c in chunks), default="")
198
+ if newest_chunk != newest_reviewed:
199
+ rows.append({"file": "commentary.json",
200
+ "reviewed": newest_chunk, "status": "drift",
201
+ "error": f"curated reviewed {newest_reviewed} but "
202
+ f"chunks say {newest_chunk or '?'} -- rerun "
203
+ f"canlex.commentary && canlex.embed"})
204
+ return rows
205
+
206
+
207
+ def check_tariff_edition(today):
208
+ """Probe whether CBSA has published a Customs Tariff edition newer than
209
+ the ingested one. The tariff pages live under a year-stamped path
210
+ (.../tariff-tarif/<year>/html/...), so probing the ingested-edition-plus-
211
+ one URL (and the current calendar year's) is a reliable new-edition
212
+ signal without parsing anything."""
213
+ from . import tariff_schedule
214
+ src = next(iter(tariff_schedule.SOURCES.values()))
215
+ have = int(src["edition"])
216
+ candidates = sorted({have + 1, int(today[:4])} - {have})
217
+ for year in candidates:
218
+ if year <= have:
219
+ continue
220
+ probe = src["url"].replace(f"/{src['edition']}/", f"/{year}/")
221
+ try:
222
+ req = urllib.request.Request(
223
+ probe, method="HEAD", headers={"User-Agent": BROWSER_UA})
224
+ with urllib.request.urlopen(req, timeout=30) as resp:
225
+ if resp.status == 200:
226
+ return {"stored": str(have), "remote": str(year),
227
+ "status": "stale", "error": ""}
228
+ except Exception:
229
+ continue # 404 = that edition doesn't exist yet
230
+ return {"stored": str(have), "remote": "", "status": "ok", "error": ""}
231
+
232
+
233
+ def dmemo_drift(raw, stored):
234
+ """(newest active LAST_UPDATED, memos newer than `stored`) from the CBSA
235
+ D-memo datasource JSON. Pure, for testability."""
236
+ rows = [r for r in json.loads(raw).get("data", [])
237
+ if str(r.get("STATUS", "")).strip().lower() != "cancelled"]
238
+ remote = max((r.get("LAST_UPDATED", "") for r in rows), default="")
239
+ drifted = sorted(_dmemo_number(r) for r in rows
240
+ if stored and r.get("LAST_UPDATED", "") > stored)
241
+ return remote, drifted
242
+
243
+
244
+ def _dmemo_number(row):
245
+ """'D1-2-1' out of a datasource NUMBER cell (an anchor, or bare text)."""
246
+ cell = str(row.get("NUMBER", ""))
247
+ inner = re.search(r">([^<]+)<", cell)
248
+ return (inner.group(1) if inner else re.sub(r"<[^>]+>", "", cell)).strip()
249
+
250
+
251
+ def check_dmemo_index():
252
+ """Newest per-memo 'Last updated' upstream vs stored currency.
253
+
254
+ This reads the JSON datasource behind the index table, NOT dates in the
255
+ index HTML: since CBSA rebuilt the page (2026-07-10) the newest date in the
256
+ markup is the page's own 'Date modified', which moves when the page is
257
+ rebuilt and no memo has changed -- that would report stale forever. The
258
+ datasource carries a LAST_UPDATED per memo, so drift is both true and named.
259
+ """
260
+ stored = ""
261
+ path = PROCESSED_DIR / "dmemos.json"
262
+ if path.exists():
263
+ chunks = json.loads(path.read_text(encoding="utf-8"))
264
+ stored = max((c.get("current_to", "") for c in chunks), default="")
265
+ try:
266
+ req = urllib.request.Request(
267
+ dmemo.DATASOURCE_URL, headers={"User-Agent": BROWSER_UA})
268
+ with urllib.request.urlopen(req, timeout=60) as resp:
269
+ remote, drifted = dmemo_drift(resp.read(), stored)
270
+ except Exception as exc:
271
+ return {"stored": stored, "remote": "", "drifted": [], "status": "error",
272
+ "error": f"{type(exc).__name__}: {exc}"}
273
+ status = "stale" if (remote and stored and remote > stored) else \
274
+ ("ok" if remote else "no-date")
275
+ return {"stored": stored, "remote": remote, "drifted": drifted,
276
+ "status": status, "error": ""}
277
+
278
+ # NJC directive dates as published: the index prints prose ('June 1, 1993' --
279
+ # double spaces occur in the raw markup) while chunks may store either that
280
+ # prose or the ISO fallback directive.py derives from page text, so both sides
281
+ # are normalized to ISO before comparing.
282
+ _NJC_PROSE_DATE = re.compile(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})")
283
+ _ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
284
+
285
+ # CBSA publishes its IRPA delegation as a base instrument plus incremental
286
+ # amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
287
+ # linked from the CBSA legislation index. A date on the index that we have not
288
+ # ingested means a new amendment to add to delegation.SOURCES.
289
+ _CBSA_INDEX_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
290
+ "legislation-eng.html")
291
+ _CBSA_INSTRUMENT_RE = re.compile(r"irpa-lipr-(\d{4}-\d{2}-\d{2})-eng\.html")
292
+ # The IRCC IL3 instrument is reissued with a seasonal version stamp on page 1.
293
+ _IL3_VERSION_RE = re.compile(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}")
294
+
295
+ # Bills known to be heading into force that affect the corpus. Surfaced once
296
+ # their in-force date has passed, with the codes to re-ingest. (The legislation
297
+ # drift check above will independently flag the affected Act once Justice Laws
298
+ # consolidates the amendment; this list just names the cause.)
299
+ _PENDING_BILLS = [
300
+ {"bill": "C-16", "name": "Protecting Victims Act",
301
+ "in_force": "2026-07-18", "affects": ["C-46"],
302
+ "note": "Criminal Code: coercive control, child-protection reporting "
303
+ "(coercive-control provisions come into force later)."},
304
+ ]
305
+
306
+
307
+ def _extract_current_date(raw):
308
+ """Pull the lims:current-date (YYYY-MM-DD) from the head of a Justice Laws
309
+ XML document. Pure and offline, for testability. Returns '' if absent."""
310
+ if raw[:3] == b"\xef\xbb\xbf":
311
+ raw = raw[3:]
312
+ m = _CURRENT_DATE.search(raw)
313
+ return m.group(1).decode("ascii") if m else ""
314
+
315
+
316
+ def _local_current_to(code):
317
+ """The current_to stored in data/processed/<code>.json (first chunk), or ''."""
318
+ path = PROCESSED_DIR / f"{code}.json"
319
+ if not path.exists():
320
+ return None
321
+ chunks = json.loads(path.read_text(encoding="utf-8"))
322
+ return chunks[0].get("current_to", "") if chunks else ""
323
+
324
+
325
+ def _remote_current_date(url):
326
+ """Fetch only the head of the XML and extract its consolidation date.
327
+ Returns (date, error): date is '' on a clean fetch that lacked the
328
+ attribute, error is a short string when the fetch itself failed."""
329
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
330
+ try:
331
+ with urllib.request.urlopen(req, timeout=60) as resp:
332
+ head = resp.read(_HEAD_BYTES)
333
+ return _extract_current_date(head), ""
334
+ except Exception as exc: # network / HTTP / timeout
335
+ return "", f"{type(exc).__name__}: {exc}"
336
+
337
+
338
+ def check_legislation():
339
+ """Compare each Justice Laws source's upstream date to the stored corpus.
340
+ Returns a list of row dicts."""
341
+ rows = []
342
+ for code, src in SOURCES.items():
343
+ local = _local_current_to(code)
344
+ remote, error = _remote_current_date(src["xml_url"])
345
+ if error:
346
+ status = "error"
347
+ elif local is None:
348
+ status = "missing-local" # configured but never ingested
349
+ elif remote and remote > local:
350
+ status = "stale" # upstream newer than our corpus
351
+ elif not remote:
352
+ status = "no-date" # fetched, but no date found (unusual)
353
+ else:
354
+ status = "ok"
355
+ rows.append({"code": code, "short": src["short"], "local": local or "",
356
+ "remote": remote, "status": status, "error": error})
357
+ time.sleep(0.3) # be polite to Justice Laws
358
+ return rows
359
+
360
+
361
+ def non_xml_currency():
362
+ """Stored currency (newest item) of the aggregate sources."""
363
+ rows = []
364
+ for label, fname, cmd, desc in _NON_XML:
365
+ path = PROCESSED_DIR / fname
366
+ if not path.exists():
367
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
368
+ "newest": "", "chunks": 0, "present": False})
369
+ continue
370
+ chunks = json.loads(path.read_text(encoding="utf-8"))
371
+ newest = max((c.get("current_to", "") for c in chunks), default="")
372
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
373
+ "newest": newest, "chunks": len(chunks), "present": True})
374
+ return rows
375
+
376
+
377
+ def _cbsa_ingested_dates():
378
+ """The effective dates of the CBSA dated delegation instruments we ingest."""
379
+ return sorted({s["effective"] for s in delegation.SOURCES.values()
380
+ if s.get("kind") == "html-cbsa" and s.get("effective")})
381
+
382
+
383
+ def _ircc_stored_version():
384
+ """The IL3 version string recorded in the ingested corpus, or ''."""
385
+ path = PROCESSED_DIR / "delegation.json"
386
+ if not path.exists():
387
+ return ""
388
+ chunks = json.loads(path.read_text(encoding="utf-8"))
389
+ versions = [c.get("current_to", "") for c in chunks
390
+ if c.get("act_short") == "IRCC IL3" and c.get("current_to")]
391
+ return max(versions) if versions else ""
392
+
393
+
394
+ def check_delegation():
395
+ """Compare the delegation instruments against upstream. Returns row dicts.
396
+
397
+ CBSA: diff the dated instruments published on the legislation index against
398
+ those we ingest -- a new date means an un-ingested amendment. IRCC: compare
399
+ the IL3 version stamp published in the live PDF against the stored one."""
400
+ rows = []
401
+
402
+ # --- CBSA: index diff (cheap; one HTML fetch) ---
403
+ have = _cbsa_ingested_dates()
404
+ try:
405
+ req = urllib.request.Request(_CBSA_INDEX_URL,
406
+ headers={"User-Agent": BROWSER_UA})
407
+ with urllib.request.urlopen(req, timeout=60) as resp:
408
+ html = resp.read().decode("utf-8", "replace")
409
+ published = sorted(set(_CBSA_INSTRUMENT_RE.findall(html)))
410
+ new = sorted(set(published) - set(have))
411
+ rows.append({"name": "CBSA IRPA delegation (+ amendments)",
412
+ "status": "stale" if new else "ok",
413
+ "have": have, "published": published, "new": new,
414
+ "error": ""})
415
+ except Exception as exc:
416
+ rows.append({"name": "CBSA IRPA delegation (+ amendments)",
417
+ "status": "error", "have": have, "published": [],
418
+ "new": [], "error": f"{type(exc).__name__}: {exc}"})
419
+
420
+ # --- IRCC IL3: version-stamp compare (re-fetches the PDF via PowerShell) ---
421
+ stored = _ircc_stored_version()
422
+ try:
423
+ with tempfile.TemporaryDirectory() as td:
424
+ data = delegation._fetch(delegation.SOURCES["ircc"]["url"],
425
+ Path(td) / "il3.pdf", powershell=True)
426
+ pages = delegation._pdf_pages(data)
427
+ m = _IL3_VERSION_RE.search(pages[0]) if pages else None
428
+ published = m.group(0) if m else ""
429
+ if not published:
430
+ status = "no-version"
431
+ elif published != stored:
432
+ status = "stale"
433
+ else:
434
+ status = "ok"
435
+ rows.append({"name": "IRCC IL3 instrument", "status": status,
436
+ "have": [stored], "published": [published], "new": [],
437
+ "error": ""})
438
+ except Exception as exc:
439
+ rows.append({"name": "IRCC IL3 instrument", "status": "error",
440
+ "have": [stored], "published": [], "new": [],
441
+ "error": f"{type(exc).__name__}: {exc}"})
442
+ return rows
443
+
444
+
445
+ def _stored_directives():
446
+ """Per-directive stored currency from data/processed/directives.json:
447
+ {code: (title, current_to, source_url)}, one entry per act_code."""
448
+ path = PROCESSED_DIR / "directives.json"
449
+ if not path.exists():
450
+ return {}
451
+ chunks = json.loads(path.read_text(encoding="utf-8"))
452
+ out = {}
453
+ for c in chunks:
454
+ code = c.get("act_code")
455
+ if code and code not in out:
456
+ out[code] = (c.get("act_short", ""), c.get("current_to", ""),
457
+ c.get("source_url", ""))
458
+ return out
459
+
460
+
461
+ def _directive_iso(text):
462
+ """Normalize an NJC directive date ('June 1, 1993' or already ISO) to
463
+ YYYY-MM-DD. Pure, for testability. Returns '' when not a recognizable date."""
464
+ text = (text or "").strip()
465
+ if _ISO_DATE.fullmatch(text):
466
+ return text
467
+ m = _NJC_PROSE_DATE.fullmatch(text)
468
+ if m and m.group(1).lower() in MONTHS:
469
+ return (f"{int(m.group(3)):04d}-{MONTHS[m.group(1).lower()]:02d}-"
470
+ f"{int(m.group(2)):02d}")
471
+ return ""
472
+
473
+
474
+ def _njc_index_dates(html):
475
+ """{code: date text} parsed from the NJC directive index, mirroring
476
+ directive.directive_links: the first <a> in each top-level li of
477
+ ul.directive-list is the current directive (nested lis are archived
478
+ versions), and the sibling span.date its effective date ('' if absent)."""
479
+ soup = BeautifulSoup(html, "html.parser")
480
+ ul = soup.find("ul", class_="directive-list")
481
+ out = {}
482
+ if ul is None:
483
+ return out
484
+ for li in ul.find_all("li", recursive=False):
485
+ a = li.find("a", href=True)
486
+ if not a:
487
+ continue
488
+ m = directive._CODE.search(a["href"])
489
+ if not m:
490
+ continue
491
+ span = li.find("span", class_="date")
492
+ out[m.group(1)] = span.get_text(strip=True) if span else ""
493
+ return out
494
+
495
+
496
+ def _njc_page_effective(url, td):
497
+ """Fresh-fetch a directive's own page and pull its 'effective <Month D,
498
+ YYYY>' prose (directive._page_effective_date), following the 'Print Full
499
+ Directive' link when the landing page is only a table of contents (the
500
+ FSDs). Returns (iso_date, error)."""
501
+ try:
502
+ html = directive._fetch(url, td / "probe.html", force=True)
503
+ soup = BeautifulSoup(html, "html.parser")
504
+ main = soup.find("main")
505
+ date = directive._page_effective_date(main) if main else ""
506
+ if not date:
507
+ print_url = directive._print_link(html)
508
+ if print_url:
509
+ full = directive._fetch(print_url, td / "probe-full.html",
510
+ force=True)
511
+ fmain = BeautifulSoup(full, "html.parser").find("main")
512
+ date = directive._page_effective_date(fmain) if fmain else ""
513
+ return date, ""
514
+ except Exception as exc:
515
+ return "", f"{type(exc).__name__}: {exc}"
516
+
517
+
518
+ def check_directives():
519
+ """Compare each ingested NJC directive's effective date against upstream.
520
+ Returns row dicts.
521
+
522
+ The date directive.py stores in current_to comes from the NJC directive
523
+ index (the span.date beside each entry), so one fresh index fetch covers
524
+ nearly every directive; entries the index leaves undated (the Foreign
525
+ Service Directives) fall back to the 'effective <Month D, YYYY>' prose on
526
+ the directive's own page -- the same two signals ingestion uses. Probe
527
+ fetches go to a temp dir, never data/raw, so a stale probe can't mask a
528
+ later real refresh; directive._fetch already sleeps 0.5s after each
529
+ download, so the probes stay polite without extra pauses here."""
530
+ stored = _stored_directives()
531
+ rows = []
532
+ if not stored:
533
+ return rows
534
+ with tempfile.TemporaryDirectory() as td:
535
+ td = Path(td)
536
+ try:
537
+ html = directive._fetch(directive.INDEX_URL, td / "_index.html",
538
+ force=True)
539
+ index_dates, index_error = _njc_index_dates(html), ""
540
+ except Exception as exc:
541
+ index_dates, index_error = {}, f"{type(exc).__name__}: {exc}"
542
+ for code, (title, stored_date, url) in sorted(stored.items()):
543
+ upstream, error = index_dates.get(code, ""), index_error
544
+ if not upstream and not error:
545
+ # Undated on the index: read the date off the page itself.
546
+ upstream, error = _njc_page_effective(url, td)
547
+ local, remote = _directive_iso(stored_date), _directive_iso(upstream)
548
+ if error:
549
+ status = "error"
550
+ elif not remote:
551
+ status = "no-date"
552
+ elif remote != local:
553
+ status = "stale" # NJC reissues in place: any drift counts
554
+ else:
555
+ status = "ok"
556
+ rows.append({"code": code, "title": title, "stored": stored_date,
557
+ "local": local, "remote": remote, "status": status,
558
+ "error": error})
559
+ return rows
560
+
561
+
562
+ def pending_in_force(today):
563
+ """Pending bills whose in-force date has passed as of `today` (ISO str)."""
564
+ return [b for b in _PENDING_BILLS if b["in_force"] <= today]
565
+
566
+
567
+ def run(as_json=False):
568
+ today = datetime.date.today().isoformat()
569
+ leg = check_legislation()
570
+ deleg = check_delegation()
571
+ dirs = check_directives()
572
+ nonxml = non_xml_currency()
573
+ curated = check_curated(today)
574
+ dmemo_idx = check_dmemo_index()
575
+ tariff = check_tariff_edition(today)
576
+ union = check_union()
577
+ ciu_dec = check_ciu_decisions()
578
+ pending = pending_in_force(today)
579
+ stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
580
+ errors = [r for r in leg if r["status"] == "error"]
581
+ deleg_stale = [r for r in deleg if r["status"] == "stale"]
582
+ dirs_stale = [r for r in dirs if r["status"] == "stale"]
583
+ curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
584
+ dmemo_stale = dmemo_idx["status"] == "stale"
585
+ tariff_stale = tariff["status"] == "stale"
586
+ union_stale = [r for r in union if r["status"] == "stale"]
587
+ ciu_dec_stale = ciu_dec["status"] == "stale"
588
+
589
+ if as_json:
590
+ print(json.dumps({"checked": today, "legislation": leg,
591
+ "delegation": deleg, "directives": dirs,
592
+ "non_xml": nonxml, "curated": curated,
593
+ "dmemo_index": dmemo_idx, "tariff": tariff,
594
+ "union": union, "ciu_decisions": ciu_dec,
595
+ "pending_in_force": pending,
596
+ "stale_count": (len(stale) + len(deleg_stale)
597
+ + len(dirs_stale)
598
+ + len(curated_flag)
599
+ + len(union_stale)
600
+ + (1 if dmemo_stale else 0)
601
+ + (1 if tariff_stale else 0)),
602
+ "error_count": len(errors)}, indent=2))
603
+ else:
604
+ print(f"CanLex corpus staleness check — {today}\n")
605
+ print(f"Justice Laws legislation ({len(leg)} instruments):")
606
+ for r in sorted(leg, key=lambda x: (x["status"] != "stale", x["short"])):
607
+ if r["status"] == "stale":
608
+ print(f" STALE {r['short']:34} local {r['local']} "
609
+ f"upstream {r['remote']}")
610
+ elif r["status"] == "missing-local":
611
+ print(f" ABSENT {r['short']:34} configured but not ingested "
612
+ f"(upstream {r['remote']})")
613
+ elif r["status"] == "error":
614
+ print(f" ERROR {r['short']:34} {r['error'][:50]}")
615
+ elif r["status"] == "no-date":
616
+ print(f" ? {r['short']:34} no current-date found upstream")
617
+ if not stale and not errors:
618
+ print(f" all {len(leg)} current (local matches or exceeds upstream)")
619
+ if stale:
620
+ codes = " ".join(r["code"] for r in stale)
621
+ print(f"\n -> {len(stale)} drifted. Refresh: "
622
+ f"py -m canlex.ingest --force {codes}\n"
623
+ f" and the French twins: py -m canlex.ingest --fr "
624
+ f"--force {codes}\n"
625
+ f" then: py -m canlex.embed && py -m canlex.eval")
626
+
627
+ print("\nDelegation instruments (upstream check):")
628
+ for r in deleg:
629
+ if r["status"] == "stale" and r["new"]:
630
+ print(f" STALE {r['name']}: new upstream {', '.join(r['new'])} "
631
+ f"not ingested")
632
+ elif r["status"] == "stale":
633
+ print(f" STALE {r['name']}: upstream {r['published'][0] or '?'} "
634
+ f"!= stored {r['have'][0] or '?'}")
635
+ elif r["status"] == "error":
636
+ print(f" ERROR {r['name']}: {r['error'][:50]}")
637
+ elif r["status"] == "no-version":
638
+ print(f" ? {r['name']}: no version stamp found upstream")
639
+ elif len(r["published"]) > 1: # CBSA: a set of dated instruments
640
+ print(f" ok {r['name']}: current "
641
+ f"({len(r['published'])} instruments, latest "
642
+ f"{max(r['published'])})")
643
+ else: # IRCC: a single version stamp
644
+ shown = r["published"][0] if r["published"] else "current"
645
+ print(f" ok {r['name']}: current ({shown})")
646
+ if deleg_stale:
647
+ print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
648
+ "then py -m canlex.delegation && py -m canlex.embed")
649
+
650
+ print(f"\nNJC directives (upstream check, {len(dirs)} ingested):")
651
+ for r in dirs:
652
+ if r["status"] == "stale":
653
+ print(f" STALE {r['title']}: upstream {r['remote']} "
654
+ f"!= stored {r['local'] or r['stored'] or '?'}")
655
+ elif r["status"] == "error":
656
+ print(f" ERROR {r['title']}: {r['error'][:50]}")
657
+ elif r["status"] == "no-date":
658
+ print(f" ? {r['title']}: no effective date found upstream")
659
+ else:
660
+ print(f" ok {r['title']}: current ({r['remote']})")
661
+ if dirs_stale:
662
+ print(" -> re-ingest: py -m canlex.directive --force && "
663
+ "py -m canlex.embed")
664
+
665
+ print("\nOther sources (stored currency; re-run the ingester to refresh):")
666
+ for r in nonxml:
667
+ mark = " " if r["present"] else "!"
668
+ print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
669
+ f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
670
+
671
+ print("\nCurated datasets (age + regen-drift check):")
672
+ for r in curated:
673
+ if r["status"] == "aging":
674
+ print(f" AGING {r['file']}: reviewed {r['reviewed']} — older "
675
+ f"than {_CURATED_MAX_AGE_DAYS} days; re-verify the state "
676
+ f"rows against their statutes and bump 'reviewed'")
677
+ elif r["status"] == "drift":
678
+ print(f" DRIFT {r['file']}: {r['error']}")
679
+ elif r["status"] == "error":
680
+ print(f" ERROR {r['file']}: {r['error'][:60]}")
681
+ else:
682
+ print(f" ok {r['file']}: reviewed {r['reviewed'] or '?'}")
683
+
684
+ if dmemo_idx["status"] == "stale":
685
+ drifted = dmemo_idx.get("drifted") or []
686
+ named = (f" ({', '.join(drifted[:6])}"
687
+ f"{', …' if len(drifted) > 6 else ''})" if drifted else "")
688
+ print(f"\nD-memo index: STALE — newest upstream 'Last updated' "
689
+ f"{dmemo_idx['remote']} > stored {dmemo_idx['stored']}"
690
+ f"{named}; "
691
+ f"run py -m canlex.dmemo --force && py -m canlex.embed")
692
+ elif dmemo_idx["status"] == "error":
693
+ print(f"\nD-memo index: ERROR {dmemo_idx['error'][:60]}")
694
+ else:
695
+ print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
696
+ f"<= stored {dmemo_idx['stored'] or '?'})")
697
+
698
+ if tariff["status"] == "stale":
699
+ print(f"\nCustoms Tariff edition: STALE — CBSA has published the "
700
+ f"{tariff['remote']} edition (ingested: {tariff['stored']}); "
701
+ f"bump the year in tariff_schedule.SOURCES and re-ingest")
702
+ else:
703
+ print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
704
+ f"current)")
705
+
706
+ n_union_ok = sum(1 for r in union if r["status"] == "ok")
707
+ print(f"\nCIU/PSAC union documents (upstream Last-Modified check, "
708
+ f"{len(union)} documents):")
709
+ for r in union:
710
+ if r["status"] == "stale":
711
+ print(f" STALE {r['short']} ({r['lang']}): upstream "
712
+ f"modified {r['remote']} after our fetch — run "
713
+ f"py -m canlex.ciu && py -m canlex.embed")
714
+ elif r["status"] == "error":
715
+ print(f" ERROR {r['short']}: {r['error'][:55]}")
716
+ if not union_stale and not any(r["status"] == "error" for r in union):
717
+ print(f" all {n_union_ok} current (upstream unchanged since fetch)")
718
+
719
+ if ciu_dec["status"] == "stale":
720
+ print(f"\nCIU Important-Decisions page: UPDATED "
721
+ f"({ciu_dec['remote']} > {ciu_dec['stored']}) — the union "
722
+ f"may have flagged a new decision; review "
723
+ f"{_CIU_DECISIONS} and, after ingesting anything new, "
724
+ f"update {_CIU_DEC_MARKER.name} to {ciu_dec['remote']}")
725
+ elif ciu_dec["status"] == "error":
726
+ print(f"\nCIU Important-Decisions page: ERROR "
727
+ f"{ciu_dec['error'][:55]}")
728
+ else:
729
+ print(f"\nCIU Important-Decisions page: ok (last modified "
730
+ f"{ciu_dec['remote'] or '?'})")
731
+
732
+ if pending:
733
+ print("\nPending bills now in force (re-ingest the affected Acts):")
734
+ for b in pending:
735
+ print(f" Bill {b['bill']} ({b['name']}) in force {b['in_force']} "
736
+ f"-> {', '.join(b['affects'])}")
737
+ print(f" {b['note']}")
738
+ print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
739
+
740
+ return 1 if (stale or errors or deleg_stale or dirs_stale
741
+ or curated_flag or dmemo_stale or tariff_stale
742
+ or union_stale or ciu_dec_stale) else 0
743
+
744
+
745
+ def main():
746
+ args = sys.argv[1:]
747
+ as_json = "--json" in args
748
+ # --check is the only mode; accepted (and default) for forward-compatibility.
749
+ sys.exit(run(as_json=as_json))
750
+
751
+
752
+ if __name__ == "__main__":
753
+ main()
canlex/rehab.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Criminal-rehabilitation assessment under IRPA s. 36(3)(c) and IRPR ss. 17-18.1.
2
+
3
+ Pure rule logic, verified against the corpus text of IRPR ss. 17, 18 and 18.1
4
+ (SOR-2002-227, current consolidation) and IRPA s. 36(3). The rules:
5
+
6
+ Deemed rehabilitation (IRPR s. 18(2)) -- automatic, no application:
7
+ (a) ONE foreign conviction, indictable-equivalent, punishable in Canada by
8
+ a maximum of LESS THAN 10 years: 10 years after sentence completion,
9
+ with the cleanliness conditions (i)-(vii).
10
+ (b) TWO OR MORE foreign convictions that would all be summary offences:
11
+ 5 years after the sentences were completed, with conditions.
12
+ (c) ONE committed act (s. 36(1)(c)/(2)(c)), <10-year maximum: 10 years
13
+ after the commission, with conditions.
14
+ Serious criminality (>=10-year maximum equivalent) can NEVER be deemed
15
+ rehabilitated -- only an individual application.
16
+
17
+ Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17):
18
+ eligible to APPLY 5 years after sentence completion (conviction branches)
19
+ or 5 years after commission (act branches). Available for serious
20
+ criminality too.
21
+
22
+ In-Canada convictions are outside the rehabilitation scheme entirely --
23
+ the cure is a record suspension (IRPA s. 36(3)(b)) -- EXCEPT the IRPR
24
+ s. 18.1 prescribed class: inadmissible SOLELY for two or more in-Canada
25
+ summary-only convictions, exempt from s. 36(2)(a) five years after the
26
+ sentences were completed.
27
+ """
28
+
29
+ BASES = ("foreign-conviction", "foreign-act", "canada-conviction")
30
+ COUNTS = ("one", "two-plus-summary-only", "multiple")
31
+
32
+
33
+ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
34
+ """Assess rehabilitation status. Returns a dict:
35
+ {status, provision, explanation, caveats}.
36
+
37
+ basis: how the inadmissibility arises (BASES).
38
+ max_penalty_lt_10: True if the equivalent Canadian offence's maximum term
39
+ is under 10 years (False = serious criminality territory).
40
+ count: 'one' | 'two-plus-summary-only' | 'multiple' (COUNTS).
41
+ years_elapsed: full years since sentence completion (conviction bases)
42
+ or since commission (act basis).
43
+ clean_record: True if there are no other convictions or committed acts
44
+ (the s. 18(2) (i)-(vii) conditions, simplified -- see caveats).
45
+ """
46
+ caveats = [
47
+ "The 'clean record' answer stands in for the detailed conditions in "
48
+ "IRPR s. 18(2)(i)-(vii) (no Canadian indictable conviction ever; no "
49
+ "recent summary convictions; at most one older summary conviction; no "
50
+ "other committed acts) -- confirm each condition against the full "
51
+ "record before relying on deemed rehabilitation.",
52
+ "A temporary resident permit (IRPA s. 24) remains available at any "
53
+ "time regardless of rehabilitation status.",
54
+ "Time runs from the day AFTER completion of the ENTIRE sentence -- "
55
+ "including probation, fines and driving prohibitions -- not from the "
56
+ "conviction date.",
57
+ ]
58
+
59
+ if basis == "canada-conviction":
60
+ if count == "two-plus-summary-only":
61
+ if years_elapsed >= 5:
62
+ return _r("exempt", "IRPR s. 18.1",
63
+ "Inadmissible solely for two or more in-Canada "
64
+ "summary-only convictions, and at least five years "
65
+ "have passed since the sentences were completed: "
66
+ "the person is in the prescribed class exempt from "
67
+ "IRPA s. 36(2)(a).", caveats)
68
+ return _r("not-yet", "IRPR s. 18.1",
69
+ "The in-Canada two-summary-only exemption requires five "
70
+ "years since the sentences were completed "
71
+ f"({years_elapsed} elapsed).", caveats)
72
+ return _r("record-suspension-route", "IRPA s. 36(3)(b)",
73
+ "Rehabilitation does not apply to in-Canada convictions. "
74
+ "The cure is a record suspension under the Criminal Records "
75
+ "Act (IRPA s. 36(3)(b)); once ordered and not revoked, the "
76
+ "conviction cannot ground inadmissibility.", caveats)
77
+
78
+ since = ("sentence completion" if basis == "foreign-conviction"
79
+ else "the commission of the act")
80
+
81
+ if not max_penalty_lt_10:
82
+ # Serious criminality: deemed rehabilitation is never available.
83
+ if years_elapsed >= 5:
84
+ return _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
85
+ "The equivalent Canadian offence carries a maximum of "
86
+ "10 years or more (serious criminality), so deemed "
87
+ "rehabilitation is NOT available -- but at least five "
88
+ f"years have passed since {since}, so the person is "
89
+ "eligible to APPLY for individual rehabilitation.",
90
+ caveats)
91
+ return _r("not-yet", "IRPR s. 17",
92
+ "Serious criminality: deemed rehabilitation is not "
93
+ "available, and the five-year eligibility period to apply "
94
+ f"for individual rehabilitation has not yet run "
95
+ f"({years_elapsed} of 5 years since {since}).", caveats)
96
+
97
+ if count == "multiple":
98
+ if years_elapsed >= 5:
99
+ return _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
100
+ "More than one indictable-equivalent conviction/act: "
101
+ "deemed rehabilitation is not available (IRPR s. 18(2) "
102
+ "covers only a single such conviction or act), but the "
103
+ "five-year period to apply for individual "
104
+ "rehabilitation has run.", caveats)
105
+ return _r("not-yet", "IRPR s. 17",
106
+ "Multiple indictable-equivalent matters: only individual "
107
+ "rehabilitation is possible, and its five-year eligibility "
108
+ f"period has not run ({years_elapsed} of 5 years).", caveats)
109
+
110
+ if count == "two-plus-summary-only":
111
+ para = "IRPR s. 18(2)(b)"
112
+ need = 5
113
+ elif basis == "foreign-act":
114
+ para = "IRPR s. 18(2)(c)"
115
+ need = 10
116
+ else:
117
+ para = "IRPR s. 18(2)(a)"
118
+ need = 10
119
+
120
+ if years_elapsed >= need and clean_record:
121
+ return _r("deemed-rehabilitated", para,
122
+ f"All {para} requirements appear met: {years_elapsed} years "
123
+ f"since {since} (needs {need}), a qualifying single/summary "
124
+ "profile, and a clean record. The person is deemed "
125
+ "rehabilitated -- the matter cannot ground inadmissibility "
126
+ "under IRPA s. 36(3)(c).", caveats)
127
+ if years_elapsed >= 5:
128
+ why = (f"only {years_elapsed} of {need} years have run"
129
+ if years_elapsed < need else
130
+ "the record is not clean, which defeats deemed rehabilitation")
131
+ return _r("eligible-to-apply", f"{para}; IRPR s. 17",
132
+ f"Deemed rehabilitation is not (yet) made out ({why}), but "
133
+ "five years have passed, so the person is eligible to APPLY "
134
+ "for individual rehabilitation.", caveats)
135
+ return _r("not-yet", f"{para}; IRPR s. 17",
136
+ f"Neither deemed rehabilitation ({years_elapsed} of {need} "
137
+ "years) nor application eligibility (five years) has been "
138
+ "reached.", caveats)
139
+
140
+
141
+ def _r(status, provision, explanation, caveats):
142
+ return {"status": status, "provision": provision,
143
+ "explanation": explanation, "caveats": caveats}
canlex/rerank.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-encoder reranker over ONNX Runtime (local, key-free)."""
2
+ import json
3
+ import os
4
+
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ from huggingface_hub import hf_hub_download
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
+
26
+
27
+ def make_ort_session(model_path):
28
+ """A CPU InferenceSession tuned for shared, small-vCPU hosts.
29
+
30
+ Two deviations from onnxruntime defaults: (1) spin-waiting between requests
31
+ is disabled -- by default ORT worker threads busy-spin after an inference
32
+ for a microsecond-scale latency win, which on the Space's shared 2 vCPUs
33
+ burns quota between queries; (2) the intra-op thread count is
34
+ env-configurable (CANLEX_ORT_THREADS) so concurrent sessions can be capped
35
+ below core count if contention is ever observed. Unset, ORT's default
36
+ (all cores) is kept, so single-query numerics and speed are unchanged."""
37
+ opts = ort.SessionOptions()
38
+ threads = int(os.environ.get("CANLEX_ORT_THREADS", "0"))
39
+ if threads:
40
+ opts.intra_op_num_threads = threads
41
+ opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
42
+ opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
43
+ return ort.InferenceSession(model_path, sess_options=opts,
44
+ providers=["CPUExecutionProvider"])
45
+
46
+
47
+ class Reranker:
48
+ """Cross-encoder that scores (query, section) pairs for relevance.
49
+
50
+ Loads a BGE reranker as ONNX and runs it on CPU -- no API key; the model is
51
+ downloaded once and cached. A cross-encoder reads the query and section
52
+ jointly, so it judges true relevance far better than the BM25/embedding
53
+ similarities used to build the candidate pool.
54
+ """
55
+
56
+ def __init__(self):
57
+ model_path = hf_hub_download(RERANK_REPO, RERANK_ONNX)
58
+ tok_path = hf_hub_download(RERANK_REPO, "tokenizer.json")
59
+ cfg_path = hf_hub_download(RERANK_REPO, "config.json")
60
+ with open(cfg_path, encoding="utf-8") as fh:
61
+ self.pad_id = json.load(fh).get("pad_token_id", 0)
62
+
63
+ self.session = make_ort_session(model_path)
64
+ self.input_names = {i.name for i in self.session.get_inputs()}
65
+
66
+ self.tokenizer = Tokenizer.from_file(tok_path)
67
+ # only_second keeps the query intact and truncates the (longer) section.
68
+ self.tokenizer.enable_truncation(max_length=MAX_TOKENS, strategy="only_second")
69
+
70
+ def score(self, query, documents):
71
+ """Return a relevance logit for each document paired with the query.
72
+
73
+ Higher means more relevant. The returned list is aligned with `documents`.
74
+ """
75
+ if not documents:
76
+ return []
77
+ encs = self.tokenizer.encode_batch(
78
+ [(query, doc[:_MAX_DOC_CHARS]) for doc in documents])
79
+ width = max(len(e.ids) for e in encs)
80
+ input_ids = np.full((len(encs), width), self.pad_id, dtype=np.int64)
81
+ attention = np.zeros((len(encs), width), dtype=np.int64)
82
+ type_ids = np.zeros((len(encs), width), dtype=np.int64)
83
+ for row, enc in enumerate(encs):
84
+ n = len(enc.ids)
85
+ input_ids[row, :n] = enc.ids
86
+ attention[row, :n] = enc.attention_mask
87
+ type_ids[row, :n] = enc.type_ids
88
+
89
+ feed = {"input_ids": input_ids, "attention_mask": attention}
90
+ if "token_type_ids" in self.input_names:
91
+ feed["token_type_ids"] = type_ids
92
+ logits = self.session.run(None, feed)[0]
93
+ return np.asarray(logits, dtype=np.float32).reshape(-1).tolist()
94
+
95
+
96
+ def main():
97
+ import time
98
+ print(f"Loading {RERANK_REPO} ({RERANK_ONNX}) ...")
99
+ reranker = Reranker()
100
+ query = "powers of arrest without warrant"
101
+ docs = [
102
+ "Arrest without warrant. A peace officer may arrest without warrant a "
103
+ "person who has committed a criminal offence.",
104
+ "Definitions. In this Act, fish means any fish and includes shellfish, "
105
+ "crustaceans and marine animals.",
106
+ "Importation. It is prohibited to import cannabis except as authorized "
107
+ "under this Act.",
108
+ ]
109
+ start = time.perf_counter()
110
+ scores = reranker.score(query, docs)
111
+ elapsed = (time.perf_counter() - start) * 1000
112
+ print(f"\nQuery: {query!r}")
113
+ for doc, score in sorted(zip(docs, scores), key=lambda x: x[1], reverse=True):
114
+ print(f" {score:8.3f} {doc[:62]}")
115
+ print(f"\n{elapsed:.0f} ms for {len(docs)} documents")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
canlex/server.py ADDED
@@ -0,0 +1,1237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """MCP server exposing CanLex Canadian legislation retrieval as tools.
3
+
4
+ Runs over stdio. Launched by an MCP client (Claude Code / Claude Desktop);
5
+ it requires no API keys -- retrieval is fully local.
6
+ """
7
+ import datetime
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ # Allow this file to be launched directly by an MCP client, cwd-independent.
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from typing import Annotated, Literal
20
+
21
+ from pydantic import Field
22
+ from mcp.server.fastmcp import FastMCP
23
+
24
+ from canlex.index import LegislationIndex
25
+
26
+ mcp = FastMCP("canlex_mcp", host="0.0.0.0",
27
+ port=int(os.environ.get("PORT", "8000")))
28
+
29
+ _READONLY = {
30
+ "readOnlyHint": True,
31
+ "destructiveHint": False,
32
+ "idempotentHint": True,
33
+ "openWorldHint": False,
34
+ }
35
+
36
+ # Prepended to every result so the model grounds, cites, and hedges correctly.
37
+ GROUNDING_NOTE = (
38
+ "ANSWERING INSTRUCTIONS: Base the answer only on the material below. Cite "
39
+ "specific provisions and quote key operative words (e.g. 'IRPA s. 34(1)(c)'). "
40
+ "When a result lists related provisions, regulations or D-memoranda, fetch "
41
+ "any that bear on the question -- the definitions section, an exception, a "
42
+ "cross-referenced rule, the regulation that adds detail -- with "
43
+ "canlex_get_section or canlex_search_legislation before answering. "
44
+ "Distinguish the kinds of source: enacted law (Acts and regulations) is binding; "
45
+ "CBSA D-Memoranda are administrative guidance -- persuasive only, not binding, "
46
+ "and a court may disagree with them; collective agreements and the National "
47
+ "Joint Council directives they incorporate are binding employment-terms "
48
+ "instruments for a bargaining unit; court decisions interpret and apply the "
49
+ "law and are binding precedent depending on the court and jurisdiction -- "
50
+ "name the deciding court and the date, and do not assume a decision is still "
51
+ "good law if it may have been overtaken (the canlex_case tool checks a "
52
+ "decision's later treatment on CanLII -- give it the neutral citation). "
53
+ "Always state the date the source is current to, and that the answer "
54
+ "reflects the law only as of that date -- for a time-sensitive matter, tell "
55
+ "the reader to verify no amendment has come into force since. If the material "
56
+ "below does not fully resolve the question -- including where it turns on case "
57
+ "law or facts not present here -- say so explicitly. This is legal information, "
58
+ "not legal advice."
59
+ )
60
+
61
+ HEDGE_THRESHOLD = 0.72 # max semantic similarity below which results are weak
62
+
63
+ WEAK_MATCH_NOTE = (
64
+ "RETRIEVAL CAUTION: the material below is only a weak match for this query "
65
+ "— CanLex may not contain a provision or decision that directly answers it. "
66
+ "Read it critically; if it does not actually address the question, say so "
67
+ "plainly rather than stretching it to fit, and consider canlex_list_acts to "
68
+ "check what the corpus covers."
69
+ )
70
+
71
+ _INDEX: Optional[LegislationIndex] = None
72
+
73
+
74
+ def _index() -> LegislationIndex:
75
+ """Load and cache the legislation index on first use."""
76
+ global _INDEX
77
+ if _INDEX is None:
78
+ _INDEX = LegislationIndex()
79
+ return _INDEX
80
+
81
+
82
+ _GUIDANCE_AGE_YEARS = 5 # warn when guidance is older than this
83
+ _YEAR = re.compile(r"\b(?:19|20)\d{2}\b")
84
+
85
+
86
+ def _log_query(query, top_k, act, doc_type, results):
87
+ """Append one JSON line per search to CANLEX_LOG_DIR/queries.jsonl, if that
88
+ env var is set. Real user queries -- especially weak-match or no-result ones
89
+ -- are the only unbiased source of corpus gaps (the eval set is
90
+ maintainer-authored); scripts/loganalyze.py mines them. Logs the query text
91
+ and outcome only, never anything else; opt-in via the env var, off by
92
+ default. Best-effort: a logging failure never affects the response."""
93
+ log_dir = os.environ.get("CANLEX_LOG_DIR")
94
+ if not log_dir:
95
+ return
96
+ try:
97
+ top = results[0] if results else None
98
+ record = {
99
+ "ts": int(time.time()),
100
+ "query": query,
101
+ "top_k": top_k,
102
+ "act": act,
103
+ "doc_type": doc_type,
104
+ "n_results": len(results),
105
+ "confidence": (round(top["confidence"], 4)
106
+ if top and top.get("confidence") is not None else None),
107
+ "top_citations": [r.get("citation", "") for r in results[:3]],
108
+ }
109
+ path = Path(log_dir)
110
+ path.mkdir(parents=True, exist_ok=True)
111
+ with (path / "queries.jsonl").open("a", encoding="utf-8") as fh:
112
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def _stated(value: str) -> str:
118
+ """A currency value for display, or an honest placeholder when the source
119
+ states none -- so a reader sees 'not stated by the source' rather than a
120
+ bare 'n/a' that reads as an oversight."""
121
+ return value or "not stated by the source"
122
+
123
+
124
+ def _guidance_age_note(current_to: str) -> str:
125
+ """A warning when administrative guidance is several years old, since its
126
+ figures and procedures may have changed (e.g. duty-free limits in a 2013
127
+ D-memo). Only fires when a year can be read from the currency value."""
128
+ years = _YEAR.findall(current_to or "")
129
+ if not years:
130
+ return ""
131
+ newest = max(int(y) for y in years)
132
+ age = datetime.date.today().year - newest
133
+ if age >= _GUIDANCE_AGE_YEARS:
134
+ return (f"_⚠ This guidance is about {age} years old (dated {newest}); "
135
+ "its figures or procedures may have changed -- verify against "
136
+ "the current published version._")
137
+ return ""
138
+
139
+
140
+ def _format_section(c: dict, related=None) -> str:
141
+ """Render one chunk (legislation, D-Memo, or agreement) as cited Markdown."""
142
+ doc_type = c.get("doc_type", "legislation")
143
+ header = f"### {c['citation']} — {c['marginal_note']}".rstrip(" —")
144
+ location = " > ".join(p for p in (c["part"], c["division"]) if p)
145
+ lines = [header]
146
+ if location:
147
+ lines.append(f"*{location}*")
148
+ if doc_type == "memorandum":
149
+ lines.append("_CBSA administrative guidance — persuasive, not binding law._")
150
+ lines.append(f"(modified {_stated(c['current_to'])})")
151
+ age = _guidance_age_note(c["current_to"])
152
+ if age:
153
+ lines.append(age)
154
+ elif doc_type == "agreement":
155
+ lines.append("_Treasury Board collective agreement — a binding contract for "
156
+ "this bargaining unit._")
157
+ lines.append(f"(in force to {_stated(c['current_to'])})")
158
+ elif doc_type == "directive":
159
+ lines.append("_National Joint Council directive — forms part of collective "
160
+ "agreements; binding for the matters it covers._")
161
+ lines.append(f"(effective {_stated(c['current_to'])})")
162
+ age = _guidance_age_note(c["current_to"])
163
+ if age:
164
+ lines.append(age)
165
+ elif doc_type == "caselaw":
166
+ if "Immigration and Refugee Board" in c["part"]:
167
+ lines.append("_Immigration and Refugee Board jurisprudential guide "
168
+ "— IRB members apply its reasoning to similar cases or "
169
+ "explain why not; persuasive, and subject to revocation "
170
+ "or to review by the Federal Court._")
171
+ elif "Board" in c["part"]:
172
+ lines.append("_Labour-board decision — a federal administrative "
173
+ "tribunal's ruling; persuasive within the board's own "
174
+ "jurisprudence, and subject to judicial review by the "
175
+ "Federal Court of Appeal._")
176
+ else:
177
+ lines.append("_Court decision — binding precedent depending on the "
178
+ "court and jurisdiction; confirm it has not been "
179
+ "overturned on appeal or overtaken by later authority._")
180
+ lines.append(f"(decided {c['current_to'] or 'n/a'})")
181
+ if c["heading"]:
182
+ lines.append(f"Subject: {c['heading']}")
183
+ elif doc_type == "delegation":
184
+ lines.append("_Instrument of delegation and designation — it records "
185
+ "which officials the Minister has delegated powers to, or "
186
+ "designated for functions, under IRPA and the IRPR. "
187
+ "Administrative; confirm it is still the current version._")
188
+ lines.append(f"(dated {c['current_to'] or 'n/a'})")
189
+ elif doc_type == "benefits":
190
+ lines.append("_Benefit-plan member booklet — a plain-language summary "
191
+ "published by the plan administrator (Canada Life). The "
192
+ "governing terms are in the plan's directive or rules, "
193
+ "which prevail in any discrepancy; quote the booklet for "
194
+ "what it says but flag that the plan rules control._")
195
+ lines.append(f"(booklet edition: {_stated(c['current_to'])})")
196
+ age = _guidance_age_note(c["current_to"])
197
+ if age:
198
+ lines.append(age)
199
+ elif doc_type == "union":
200
+ lines.append("_Union document (CIU/PSAC) — internal governance "
201
+ "rules, member guides or locally negotiated scheduling "
202
+ "instruments published by the bargaining agent. They "
203
+ "bind the union and its members under the union's own "
204
+ "rules; they are NOT employer policy and NOT part of "
205
+ "the collective agreement itself (a VSSA operates "
206
+ "under the FB agreement's variable-shift provisions). "
207
+ "The union site may lag renegotiations — verify "
208
+ "currency with the Branch._")
209
+ lines.append(f"(document currency: {_stated(c['current_to'])})")
210
+ age = _guidance_age_note(c["current_to"])
211
+ if age:
212
+ lines.append(age)
213
+ elif doc_type == "commentary":
214
+ lines.append("_**CURATED ANALYSIS — CanLex commentary, not a source "
215
+ "of law.** A curated synthesis of the cited authorities; "
216
+ "where it says no authority exists, the analysis is "
217
+ "reasoned interpretation only. Always verify against the "
218
+ "cited cases and guidance before relying on it._")
219
+ lines.append(f"(entry reviewed {_stated(c['current_to'])})")
220
+ elif c.get("status") == "not-in-force":
221
+ # Enacted but not yet in force: pending law from the Act's AMENDMENTS
222
+ # NOT IN FORCE schedule. Make it impossible to mistake for current law.
223
+ lines.append("_**⚠ NOT YET IN FORCE — this is an enacted amendment "
224
+ "that has not come into force.** It is NOT current law; "
225
+ "the provision it amends (if any) continues to read as "
226
+ "consolidated. Cite it only as pending law, naming the "
227
+ f"amending Act: {c['history'] or 'see text'}._ "
228
+ f"(consolidation current to {c['current_to'] or 'n/a'})")
229
+ elif c.get("status") == "repealed":
230
+ # A repealed provision is no longer law; say so unmistakably so the
231
+ # model never presents the (near-empty) stub as if it were in force.
232
+ lines.append("_**⚠ REPEALED — this provision is no longer in force.** "
233
+ "The repeal citation is in the text below; do not rely on "
234
+ "it as current law, and tell the reader it has been "
235
+ f"repealed._ (text current to {c['current_to'] or 'n/a'})")
236
+ else:
237
+ meta = [f"in force; text current to {c['current_to'] or 'n/a'}"]
238
+ if c.get("in_force"):
239
+ meta.append(f"this provision in force since {c['in_force']}")
240
+ if c["last_amended"]:
241
+ meta.append(f"last amended {c['last_amended']}")
242
+ lines.append(f"**Currency:** {'; '.join(meta)}. Does not reflect any "
243
+ f"amendment that came into force after the 'current to' date.")
244
+ hl = c.get("highlight")
245
+ if hl:
246
+ label, snippet = hl
247
+ lines.append(f"**Most on point for this query:** "
248
+ f"{c['citation']}{label} — {snippet}")
249
+ lines.append("")
250
+ lines.append(c["text"])
251
+ lines.append("")
252
+ if related:
253
+ provisions = related.get("provisions")
254
+ if provisions:
255
+ refs = "; ".join(f"s. {s} ({n})" if n else f"s. {s}"
256
+ for s, n in provisions)
257
+ lines.append(f"Related provisions in this Act: {refs}")
258
+ regs = related.get("regulations")
259
+ if regs:
260
+ lines.append("Regulations made under this Act: "
261
+ + "; ".join(f"{n} ({s})" for s, n in regs))
262
+ enabling = related.get("enabling_act")
263
+ if enabling:
264
+ lines.append(f"Made under: {enabling[1]} ({enabling[0]})")
265
+ memos = related.get("memoranda")
266
+ if memos:
267
+ lines.append("CBSA D-memoranda citing this section (guidance, not "
268
+ "binding): " + ", ".join(memos))
269
+ if c["history"]:
270
+ if doc_type == "caselaw":
271
+ lines.append(f"Also reported: {c['history']}")
272
+ elif doc_type == "legislation":
273
+ lines.append(f"Amendment history: {c['history']}")
274
+ else:
275
+ lines.append(f"History: {c['history']}")
276
+ lines.append(f"Source: {c['source_url']}")
277
+ return "\n".join(lines)
278
+
279
+
280
+ # The eight doc_type values, as a Literal so every client schema carries them
281
+ # as an enum rather than free text (regex 'pattern' constraints are dropped by
282
+ # several function-calling translators, notably Gemini's).
283
+ DocType = Literal["legislation", "memorandum", "agreement", "directive",
284
+ "caselaw", "delegation", "benefits", "commentary", "union"]
285
+
286
+ # Longest alternative first, and 's'/'ss' only at a word boundary or before
287
+ # a dotted number -- so 'section 20.1' loses the whole word (not its first
288
+ # letter) and a real section value like 'Schedule I' is left alone.
289
+ _SECTION_PREFIX = re.compile(
290
+ r"^(?:sections?\b|ss?\.(?=\s*\d)|ss?\b|§+)\s*", re.IGNORECASE)
291
+
292
+
293
+ def _normalize_section(section: str) -> str:
294
+ """Accept the forms other tool outputs teach a client to write -- 's. 34',
295
+ 'section 34', '34(1)(c)' -- and reduce them to the bare section number the
296
+ index keys on. Subsection parentheticals are truncated (the full section
297
+ text contains its subsections anyway)."""
298
+ s = _SECTION_PREFIX.sub("", section.strip())
299
+ s = s.split("(", 1)[0].strip().rstrip(".")
300
+ return s or section.strip()
301
+
302
+
303
+ @mcp.tool(name="canlex_search_legislation",
304
+ annotations={"title": "Search Canadian Legislation", **_READONLY})
305
+ def canlex_search_legislation(
306
+ query: Annotated[str, Field(
307
+ description="Natural-language legal question or keywords, e.g. "
308
+ "'detention review timelines' or 'inadmissibility for serious "
309
+ "criminality'.", min_length=2, max_length=500)],
310
+ top_k: Annotated[int, Field(
311
+ description="Number of sections to return (1-20). Use more for broad "
312
+ "questions.", ge=1, le=20)] = 6,
313
+ act: Annotated[Optional[str], Field(
314
+ description="Optional filter to a single Act, by short name, code or "
315
+ "full name (e.g. 'IRPA', 'I-2.5', 'Customs Act'). Omit to search "
316
+ "every loaded instrument.")] = None,
317
+ doc_type: Annotated[Optional[DocType], Field(
318
+ description="Optional filter by source type: 'legislation' (Acts and "
319
+ "regulations, incl. their Schedules), 'memorandum' (CBSA D-Memoranda "
320
+ "and operational guidance), 'agreement' (collective agreements), "
321
+ "'directive' (NJC directives), 'caselaw' (court and tribunal "
322
+ "decisions), 'delegation' (IRPA/IRPR delegation instruments), "
323
+ "'benefits' (benefit-plan member booklets), or 'commentary' "
324
+ "(CanLex's curated analysis datasets, e.g. US dispositions vs the "
325
+ "IRPA 'conviction' concept). Omit to search all.")] = None,
326
+ court: Annotated[Optional[str], Field(
327
+ description="Optional: restrict CASE-LAW results to a deciding "
328
+ "court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court "
329
+ "of Appeal', 'Immigration and Refugee Board', 'FPSLREB'. Other "
330
+ "source types are unaffected.", max_length=60)] = None,
331
+ decided_after: Annotated[Optional[int], Field(
332
+ description="Optional: restrict CASE-LAW results to decisions from "
333
+ "this year onward, e.g. 2015. Other source types are unaffected.",
334
+ ge=1950, le=2100)] = None,
335
+ ) -> str:
336
+ """Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
337
+ and leading court decisions.
338
+
339
+ The CanLex corpus has nine kinds of source: federal Acts and regulations
340
+ with their Schedules (immigration, customs, criminal, firearms, drugs,
341
+ food/health, labour, privacy and more); CBSA D-Memoranda (administrative
342
+ guidance on how the CBSA applies customs and border law); Treasury Board
343
+ collective agreements (currently the FB / Border Services group); National
344
+ Joint Council directives (travel, relocation, isolated posts and more);
345
+ leading decisions of the courts and federal tribunals (Supreme Court,
346
+ Federal Court of Appeal and Federal Court, the IRB, and the FPSLREB and
347
+ CIRB labour boards); instruments of delegation and designation under IRPA
348
+ and the IRPR; member booklets of the public-service benefit plans;
349
+ CanLex's curated commentary (US-disposition and equivalency analysis);
350
+ and union documents (the CIU by-laws/regulations/branch rules, the PSAC
351
+ Constitution, member guides, and every published site VSSA).
352
+ Use this for ANY question about that material. It ranks results by
353
+ relevance and returns their full text so the answer can cite the actual
354
+ wording; an explicit section reference (e.g. "section 34") is always
355
+ surfaced. Each result is marked with its source type.
356
+
357
+ Returns:
358
+ str: Markdown with answering instructions followed by the matching
359
+ sections -- citation, Part/Division, currency dates, full text,
360
+ amendment history and source URL per result. Returns a "No ...
361
+ matched" message if nothing is found.
362
+
363
+ Examples:
364
+ - "What are the security grounds for inadmissibility?" -> query about s. 34.
365
+ - "detention review 48 hours" -> finds the detention-review provisions.
366
+ - Don't use to look up a known section number verbatim -- use canlex_get_section.
367
+ """
368
+ index = _index()
369
+ results = index.search(query, top_k=top_k, act=act, doc_type=doc_type,
370
+ court=court, decided_after=decided_after)
371
+ _log_query(query, top_k, act, doc_type, results)
372
+ if not results:
373
+ if act:
374
+ known = sorted({c["act_short"] for c in index.chunks})
375
+ act_lc = act.strip().lower()
376
+ if not any(act_lc in (c["act_short"].lower(), c["act_code"].lower(),
377
+ c.get("act_name", "").lower())
378
+ for c in index.chunks):
379
+ return (f"No results: the act filter '{act}' matches no loaded "
380
+ f"instrument (short names accepted: "
381
+ f"{', '.join(known)}). Retry without the filter, or "
382
+ f"with one of those names.")
383
+ scope = f" in '{act}'" if act else ""
384
+ return (f"No results matched '{query}'{scope}. "
385
+ f"Try broader or different keywords, or call canlex_list_acts to see "
386
+ f"what is currently loaded.")
387
+ blocks = []
388
+ weak = results[0].get("confidence")
389
+ if weak is not None and weak < HEDGE_THRESHOLD:
390
+ blocks += [WEAK_MATCH_NOTE, ""]
391
+ blocks += [GROUNDING_NOTE, "",
392
+ f'{len(results)} relevant section(s) for: "{query}"']
393
+ for c in results:
394
+ blocks.append("")
395
+ blocks.append("---")
396
+ blocks.append("")
397
+ blocks.append(_format_section(c, index.related(c)))
398
+ return "\n".join(blocks)
399
+
400
+
401
+ @mcp.tool(name="canlex_get_section",
402
+ annotations={"title": "Get a Legislation Section", **_READONLY})
403
+ def canlex_get_section(
404
+ act: Annotated[str, Field(
405
+ description="Act short name or code, e.g. 'IRPA' or 'I-2.5'.",
406
+ min_length=1, max_length=60)],
407
+ section: Annotated[str, Field(
408
+ description="Section number, e.g. '34', '20.1', 's. 34' or "
409
+ "'34(1)(c)' -- prefixes and subsection parentheticals are accepted "
410
+ "and normalized.", min_length=1, max_length=40)],
411
+ ) -> str:
412
+ """Retrieve one specific section of legislation verbatim, by Act and section number.
413
+
414
+ Use this when the exact citation is already known (e.g. the user asks about
415
+ "IRPA section 34" or a provision cross-references "subsection 25.1(1)").
416
+
417
+ Returns:
418
+ str: Markdown with answering instructions followed by the section's citation,
419
+ Part/Division, 'current to' and 'last amended' dates, full provision text,
420
+ amendment history, and source URL. Returns an actionable error message (listing
421
+ the loaded Acts) if the Act or section is not found.
422
+
423
+ Examples:
424
+ - "Show me IRPA s. 34" -> act='IRPA', section='34'.
425
+ - "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
426
+ """
427
+ index = _index()
428
+ result = index.get_section(act, section)
429
+ if result is None and section != _normalize_section(section):
430
+ result = index.get_section(act, _normalize_section(section))
431
+ if result is None:
432
+ acts = sorted({c["act_short"] for c in index.chunks})
433
+ return (f"Error: no section '{section}' found in '{act}'. "
434
+ f"Loaded Acts: {', '.join(acts) or 'none'}. Check the section number, "
435
+ f"or use canlex_search_legislation to locate the provision by topic.")
436
+ return GROUNDING_NOTE + "\n\n" + _format_section(result, index.related(result))
437
+
438
+
439
+ def _load_dispositions():
440
+ """The curated US-dispositions dataset (data/curated/us_dispositions.json)."""
441
+ from canlex.config import DATA_DIR
442
+ path = DATA_DIR / "curated" / "us_dispositions.json"
443
+ if not path.exists():
444
+ return None
445
+ return json.loads(path.read_text(encoding="utf-8"))
446
+
447
+
448
+ _STATE_NAMES = {
449
+ "al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas",
450
+ "ca": "california", "co": "colorado", "ct": "connecticut", "de": "delaware",
451
+ "fl": "florida", "ga": "georgia", "hi": "hawaii", "id": "idaho",
452
+ "il": "illinois", "in": "indiana", "ia": "iowa", "ks": "kansas",
453
+ "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland",
454
+ "ma": "massachusetts", "mi": "michigan", "mn": "minnesota",
455
+ "ms": "mississippi", "mo": "missouri", "mt": "montana", "ne": "nebraska",
456
+ "nv": "nevada", "nh": "new hampshire", "nj": "new jersey",
457
+ "nm": "new mexico", "ny": "new york", "nc": "north carolina",
458
+ "nd": "north dakota", "oh": "ohio", "ok": "oklahoma", "or": "oregon",
459
+ "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina",
460
+ "sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah",
461
+ "vt": "vermont", "va": "virginia", "wa": "washington",
462
+ "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming",
463
+ }
464
+
465
+
466
+ def _match_dispositions(data, disposition, state):
467
+ """Score dataset entries against the query; return the best matches.
468
+
469
+ Token-overlap over the entry's names/aliases, with a bonus when the
470
+ entry's state_variations cover the requested state. Deliberately simple:
471
+ the vocabulary is small and controlled."""
472
+ q_tokens = set(re.findall(r"[a-z0-9]+", disposition.lower()))
473
+ state_lc = (state or "").strip().lower()
474
+ state_name = _STATE_NAMES.get(state_lc, state_lc)
475
+ scored = []
476
+ for e in data.get("dispositions", []):
477
+ best = 0.0
478
+ for name in e["names"]:
479
+ n_tokens = set(re.findall(r"[a-z0-9]+", name.lower()))
480
+ if not n_tokens:
481
+ continue
482
+ overlap = len(q_tokens & n_tokens) / len(n_tokens)
483
+ best = max(best, overlap)
484
+ if state_name and any(
485
+ state_name in (v.get("state", "").lower())
486
+ or v.get("state", "").lower() in (state_lc, state_name)
487
+ for v in e.get("state_variations", [])):
488
+ best += 0.15
489
+ if best > 0.3:
490
+ scored.append((best, e))
491
+ scored.sort(key=lambda t: -t[0])
492
+ return [e for _, e in scored[:2]]
493
+
494
+
495
+ def _load_equivalency():
496
+ from canlex.config import DATA_DIR
497
+ path = DATA_DIR / "curated" / "us_equivalency.json"
498
+ if not path.exists():
499
+ return None
500
+ return json.loads(path.read_text(encoding="utf-8"))
501
+
502
+
503
+ # Tool registration order mirrors the s. 36 screening chain (step 1
504
+ # disposition, step 2 equivalency, step 3 rehabilitation) so clients that
505
+ # list tools in registration order present the workflow forwards.
506
+ @mcp.tool(name="canlex_us_disposition",
507
+ annotations={"title": "US Disposition vs IRPA 'Conviction'", **_READONLY})
508
+ def canlex_us_disposition(
509
+ disposition: Annotated[str, Field(
510
+ description="The US disposition, in the traveller's or record's own "
511
+ "words, e.g. 'deferred adjudication', 'nolo contendere plea', "
512
+ "'withheld adjudication', 'expunged conviction', 'ACD' -- or 'all' "
513
+ "for the complete breakdown of every covered disposition type and "
514
+ "state variation.", min_length=1, max_length=200)],
515
+ state: Annotated[Optional[str], Field(
516
+ description="Optional US state (name or 2-letter code), e.g. 'TX' or "
517
+ "'Florida' -- the analysis can vary by state.",
518
+ max_length=30)] = None,
519
+ ) -> str:
520
+ """Assess whether a US criminal disposition is a "conviction" for IRPA
521
+ s. 36 inadmissibility purposes, from CanLex's curated, authority-cited
522
+ dataset of US disposition types (deferred adjudication, withheld
523
+ adjudication, nolo pleas, expungements, state pardons, diversion, and
524
+ more), with state-by-state variations.
525
+
526
+ This is STEP 1 of a s. 36(1)(b)/(2)(b) screening (is there a conviction
527
+ at all?); step 2 is offence equivalency (canlex_us_equivalency) and step
528
+ 3 is rehabilitation (canlex_rehabilitation). Every answer is CURATED
529
+ COMMENTARY, not law: entries cite their authorities, and entries with no
530
+ authority say so and label their reasoning as interpretation.
531
+
532
+ Returns:
533
+ str: Markdown -- the matching entry/entries with authorities and
534
+ status flags, or the governing principles plus an honest no-entry
535
+ statement when nothing matches.
536
+ """
537
+ from canlex.commentary import BANNER, _entry_text
538
+ data = _load_dispositions()
539
+ if data is None:
540
+ return ("The curated US-dispositions dataset is not present in this "
541
+ "deployment. Use canlex_search_legislation (doc_type "
542
+ "'caselaw') for the governing principles: Canada (MCI) v "
543
+ "Saini, 2001 FCA 311, and IRPA s. 36(3).")
544
+ if disposition.strip().lower() in ("all", "list", "list all", "*"):
545
+ lines = [BANNER, "",
546
+ "Every covered disposition type (and state variation), with "
547
+ "its conviction verdict for IRPA s. 36. Query a specific "
548
+ "disposition for the full analysis and authorities.", ""]
549
+ for e in data.get("dispositions", []):
550
+ lines.append(f"- {e['names'][0]} -- conviction: "
551
+ f"{e['is_conviction'].upper()} "
552
+ f"[{e['status']}]")
553
+ for v in e.get("state_variations", []):
554
+ if v.get("state", "").lower() == "general":
555
+ continue
556
+ flag = (v.get("is_conviction") or e["is_conviction"]).upper()
557
+ lines.append(f" - {v['state']}: {flag}")
558
+ lines.append("")
559
+ lines.append("Reminder: even a non-conviction disposition can ground "
560
+ "inadmissibility under the act branch, IRPA "
561
+ "s. 36(1)(c)/(2)(c).")
562
+ return "\n".join(lines)
563
+ matches = _match_dispositions(data, disposition, state)
564
+ parts = []
565
+ if matches:
566
+ for e in matches:
567
+ parts.append(_entry_text(e))
568
+ else:
569
+ parts.append(BANNER)
570
+ parts.append(
571
+ f"No curated entry matches '{disposition}'"
572
+ + (f" ({state})" if state else "") + ". "
573
+ "That does not mean the disposition is or is not a conviction -- "
574
+ "it means CanLex has not analyzed this disposition type. Apply "
575
+ "the governing principles (below) to the disposition's actual "
576
+ "features: was guilt admitted or found? is the disposition final? "
577
+ "does an analogous Canadian non-conviction regime exist?")
578
+ for m in data.get("methodology", []):
579
+ if m.get("id") == "conviction-framework":
580
+ parts.append("---\n\nGoverning framework:\n\n" + m["text"])
581
+ break
582
+ parts.append("Confirm any cited decision's current status with "
583
+ "canlex_case, and retrieve its full text with "
584
+ "canlex_search_legislation (doc_type 'caselaw').")
585
+ return "\n\n".join(parts)
586
+
587
+
588
+ @mcp.tool(name="canlex_us_equivalency",
589
+ annotations={"title": "US Offence Equivalency", **_READONLY})
590
+ def canlex_us_equivalency(
591
+ offense: Annotated[str, Field(
592
+ description="The US offence as charged or described, e.g. 'DUI', "
593
+ "'petty theft', 'aggravated assault', 'possession of a controlled "
594
+ "substance', 'disorderly conduct'.",
595
+ min_length=2, max_length=200)],
596
+ ) -> str:
597
+ """Map a common US offence to its Canadian equivalent and the resulting
598
+ IRPA s. 36 inadmissibility branch (serious criminality vs criminality vs
599
+ none), from CanLex's curated pairing table with corpus-verified maximum
600
+ penalties.
601
+
602
+ This is step 2 of a s. 36 analysis (step 1: is the disposition a
603
+ conviction -- canlex_us_disposition; step 3: rehabilitation --
604
+ canlex_rehabilitation, which takes this tool's max-penalty answer as
605
+ input). Covers the COMMON pairings (DUI, theft, assault, fraud, drugs,
606
+ cannabis, disorderly conduct...); anything unusual gets the Hill
607
+ methodology and an honest no-entry statement.
608
+
609
+ Returns:
610
+ str: Markdown -- the pairing with penalty, branch, caveats and
611
+ authorities, or the Hill framework when nothing matches.
612
+ """
613
+ from canlex.commentary import BANNER
614
+ data = _load_equivalency()
615
+ if data is None:
616
+ return ("The equivalency dataset is not present in this deployment. "
617
+ "Apply Hill v Canada (MEI) (1987), 73 NR 315 (FCA): compare "
618
+ "essential elements, or the evidence of what was done, or "
619
+ "both; remember IRPA s. 36(3)(a) hybrid deeming.")
620
+ q = set(re.findall(r"[a-z0-9]+", offense.lower()))
621
+ scored = []
622
+ for p in data.get("pairings", []):
623
+ best = 0.0
624
+ for t in p["us_terms"]:
625
+ toks = set(re.findall(r"[a-z0-9]+", t.lower()))
626
+ if toks:
627
+ best = max(best, len(q & toks) / len(toks))
628
+ if best > 0.4:
629
+ scored.append((best, p))
630
+ scored.sort(key=lambda x: -x[0])
631
+ parts = [BANNER]
632
+ if scored:
633
+ for _, p in scored[:2]:
634
+ lines = [f"US offence: {'; '.join(p['us_terms'][:5])}",
635
+ f"Canadian equivalent: {p['canadian_offence']}",
636
+ f"Maximum penalty (verified): {p['penalty']}",
637
+ f"Inadmissibility branch: {p['branch']}", "",
638
+ p["analysis"]]
639
+ if p.get("caveats"):
640
+ lines.append("")
641
+ lines += [f"- CAVEAT: {c}" for c in p["caveats"]]
642
+ if p.get("authorities"):
643
+ lines += ["", "Authorities:"]
644
+ lines += [f"- {a['cite']} ({a['court']}): {a['holding']}"
645
+ for a in p["authorities"]]
646
+ parts.append("\n".join(lines))
647
+ else:
648
+ parts.append(
649
+ f"No curated pairing matches '{offense}'. Apply the Hill "
650
+ "framework (below) to the actual foreign statute -- compare "
651
+ "essential elements, or the evidence of what was done, or both.")
652
+ for m in data.get("methodology", []):
653
+ if m["id"] == "equivalency-framework":
654
+ parts.append("---\n\nGoverning framework:\n\n" + m["text"])
655
+ break
656
+ parts.append("Retrieve the Canadian provisions with canlex_get_section "
657
+ "to verify elements and penalties; feed the max-penalty "
658
+ "answer into canlex_rehabilitation for step 3.")
659
+ return "\n\n".join(parts)
660
+
661
+
662
+ @mcp.tool(name="canlex_rehabilitation",
663
+ annotations={"title": "Criminal Rehabilitation Assessment", **_READONLY})
664
+ def canlex_rehabilitation(
665
+ basis: Annotated[Literal["foreign-conviction", "foreign-act",
666
+ "canada-conviction"], Field(
667
+ description="How the inadmissibility arises: 'foreign-conviction' "
668
+ "(IRPA s. 36(1)(b)/(2)(b)), 'foreign-act' (s. 36(1)(c)/(2)(c)), or "
669
+ "'canada-conviction' (s. 36(1)(a)/(2)(a)).")],
670
+ max_penalty_lt_10: Annotated[bool, Field(
671
+ description="True if the equivalent Canadian offence's maximum term "
672
+ "of imprisonment is LESS THAN 10 years (remember IRPA s. 36(3)(a): "
673
+ "hybrid offences count as indictable at their indictable maximum).")],
674
+ count: Annotated[Literal["one", "two-plus-summary-only", "multiple"], Field(
675
+ description="'one' (a single conviction or act), "
676
+ "'two-plus-summary-only' (two or more, all summary-equivalent), or "
677
+ "'multiple' (more than one indictable-equivalent matter).")],
678
+ years_elapsed: Annotated[int, Field(
679
+ ge=0, le=100,
680
+ description="Full years since the day after completion of the ENTIRE "
681
+ "imposed sentence (conviction bases) or since the act was committed "
682
+ "(act basis).")],
683
+ clean_record: Annotated[bool, Field(
684
+ description="True if the person has no other convictions or "
685
+ "committed acts anywhere (the IRPR s. 18(2)(i)-(vii) conditions, "
686
+ "simplified -- the answer carries a caveat to confirm in detail).")],
687
+ ) -> str:
688
+ """Assess whether a person is deemed rehabilitated, eligible to apply for
689
+ individual rehabilitation, or neither, under IRPA s. 36(3)(c) and IRPR
690
+ ss. 17-18.1 -- the time-based cures for criminal inadmissibility.
691
+
692
+ Rules verified against the current IRPR consolidation: deemed
693
+ rehabilitation (automatic) for a single <10-year-maximum foreign
694
+ conviction after 10 clean years, two-plus summary-only convictions after
695
+ 5 years, or a single committed act after 10 years; individual
696
+ rehabilitation applications after 5 years (available even for serious
697
+ criminality, which can never be deemed); record suspensions for in-Canada
698
+ convictions. This is step 3 of a s. 36 screening: step 1 is whether the
699
+ disposition is a conviction (canlex_us_disposition), step 2 is offence
700
+ equivalency (which also supplies the max-penalty input here).
701
+
702
+ Returns:
703
+ str: The assessment with the governing provision, explanation and
704
+ mandatory caveats. Curated logic -- verify against the cited
705
+ provisions via canlex_get_section.
706
+ """
707
+ from canlex.rehab import assess
708
+ from canlex.commentary import BANNER
709
+ r = assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record)
710
+ lines = [BANNER, "",
711
+ f"REHABILITATION STATUS: {r['status'].upper().replace('-', ' ')}",
712
+ f"Governing provision(s): {r['provision']}", "",
713
+ r["explanation"], "", "Caveats:"]
714
+ lines += [f"- {c}" for c in r["caveats"]]
715
+ lines.append("")
716
+ lines.append("Retrieve the provisions with canlex_get_section (act "
717
+ "'IRPR', sections '17'/'18'/'18.1'; act 'IRPA', section "
718
+ "'36').")
719
+ return "\n".join(lines)
720
+
721
+
722
+ @mcp.tool(name="canlex_list_acts",
723
+ annotations={"title": "List Loaded Legislation", **_READONLY})
724
+ def canlex_list_acts() -> str:
725
+ """List what the CanLex corpus contains -- Acts and regulations, CBSA
726
+ D-Memoranda, collective agreements, NJC directives, leading cases,
727
+ delegation instruments, benefit-plan booklets, and CanLex's curated
728
+ commentary datasets.
729
+
730
+ Use this to learn the scope and currency of the corpus before searching, or to
731
+ report it to the user.
732
+
733
+ Returns:
734
+ str: Markdown grouped by source type.
735
+ """
736
+ index = _index()
737
+ acts: dict[str, dict] = {}
738
+ agreements: dict[str, dict] = {}
739
+ directives: dict[str, dict] = {}
740
+ cases: dict[str, dict] = {}
741
+ delegations: dict[str, dict] = {}
742
+ benefits: dict[str, dict] = {}
743
+ commentary: dict[str, dict] = {}
744
+ union_docs: dict[str, dict] = {}
745
+ memo_numbers: set[str] = set()
746
+ memo_chunks = 0
747
+ memo_date = ""
748
+ fr_chunks = 0
749
+ fr_acts: set[str] = set()
750
+ for c in index.chunks:
751
+ if c.get("lang", "en") == "fr":
752
+ # French twins share their English instrument's identity; count
753
+ # them once as coverage rather than doubling every act's rows.
754
+ fr_chunks += 1
755
+ fr_acts.add(c["act_code"])
756
+ continue
757
+ doc_type = c.get("doc_type", "legislation")
758
+ if doc_type == "memorandum":
759
+ memo_numbers.add(c["section"])
760
+ memo_chunks += 1
761
+ memo_date = max(memo_date, c["current_to"] or "")
762
+ elif doc_type == "agreement":
763
+ entry = agreements.setdefault(c["act_code"], {
764
+ "short": c["act_short"], "name": c["act_name"],
765
+ "current_to": c["current_to"], "count": 0,
766
+ })
767
+ entry["count"] += 1
768
+ elif doc_type == "directive":
769
+ entry = directives.setdefault(c["act_code"], {
770
+ "short": c["act_short"], "current_to": c["current_to"], "count": 0,
771
+ })
772
+ entry["count"] += 1
773
+ elif doc_type == "caselaw":
774
+ entry = cases.setdefault(c["act_code"], {
775
+ "name": c["act_name"], "decided": c["current_to"], "count": 0,
776
+ })
777
+ entry["count"] += 1
778
+ elif doc_type == "delegation":
779
+ entry = delegations.setdefault(c["act_code"], {
780
+ "short": c["act_short"], "name": c["act_name"],
781
+ "current_to": c["current_to"], "count": 0,
782
+ })
783
+ entry["count"] += 1
784
+ elif doc_type == "benefits":
785
+ entry = benefits.setdefault(c["act_code"], {
786
+ "short": c["act_short"], "name": c["act_name"],
787
+ "current_to": c["current_to"], "count": 0,
788
+ })
789
+ entry["count"] += 1
790
+ elif doc_type == "commentary":
791
+ entry = commentary.setdefault(c["act_code"], {
792
+ "short": c["act_short"], "name": c["act_name"],
793
+ "current_to": c["current_to"], "count": 0,
794
+ })
795
+ entry["count"] += 1
796
+ elif doc_type == "union":
797
+ entry = union_docs.setdefault(c["act_code"], {
798
+ "short": c["act_short"], "name": c["act_name"],
799
+ "current_to": c["current_to"], "count": 0,
800
+ })
801
+ entry["count"] += 1
802
+ else:
803
+ entry = acts.setdefault(c["act_code"], {
804
+ "short": c["act_short"], "name": c["act_name"],
805
+ "code": c["act_code"], "current_to": c["current_to"], "count": 0,
806
+ })
807
+ entry["count"] += 1
808
+ lines = ["# CanLex corpus", "", "## Enacted law"]
809
+ if fr_chunks:
810
+ lines.insert(1, f"_Bilingual: {len(fr_acts)} instruments also carry "
811
+ f"their parallel French text ({fr_chunks} articles); "
812
+ f"a French-language query searches both._")
813
+ for a in sorted(acts.values(), key=lambda x: x["short"]):
814
+ lines.append(f"- **{a['short']}** — {a['name']} ({a['code']}): "
815
+ f"{a['count']} sections, current to {a['current_to'] or 'n/a'}")
816
+ if memo_numbers:
817
+ lines += ["", "## CBSA guidance",
818
+ f"- **D-Memoranda** — {len(memo_numbers)} memoranda "
819
+ f"({memo_chunks} sections), modified up to {memo_date or 'n/a'}. "
820
+ f"Administrative guidance on customs and border law; "
821
+ f"persuasive, not binding."]
822
+ if agreements:
823
+ lines += ["", "## Collective agreements"]
824
+ for a in sorted(agreements.values(), key=lambda x: x["short"]):
825
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} articles, "
826
+ f"in force to {a['current_to'] or 'n/a'}")
827
+ if directives:
828
+ lines += ["", "## NJC directives"]
829
+ for a in sorted(directives.values(), key=lambda x: x["short"]):
830
+ lines.append(f"- **{a['short']}**: {a['count']} sections, "
831
+ f"effective {a['current_to'] or 'n/a'}")
832
+ if cases:
833
+ lines += ["", "## Case law"]
834
+ for cite, a in sorted(cases.items(), key=lambda kv: kv[1]["decided"]):
835
+ lines.append(f"- **{a['name']}**, {cite}: {a['count']} excerpts, "
836
+ f"decided {a['decided'] or 'n/a'}")
837
+ if delegations:
838
+ lines += ["", "## Delegation instruments"]
839
+ for a in sorted(delegations.values(), key=lambda x: x["short"]):
840
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} items, "
841
+ f"dated {a['current_to'] or 'n/a'}")
842
+ if benefits:
843
+ lines += ["", "## Benefit plans"]
844
+ for a in sorted(benefits.values(), key=lambda x: x["short"]):
845
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
846
+ f"sections, booklet edition {a['current_to'] or 'n/a'}. "
847
+ f"Plain-language member guide; the plan rules govern.")
848
+ if union_docs:
849
+ lines += ["", "## Union documents (CIU/PSAC)"]
850
+ for a in sorted(union_docs.values(), key=lambda x: x["short"]):
851
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
852
+ f"sections, currency {a['current_to'] or 'n/a'}. "
853
+ f"Bargaining-agent document; not employer policy.")
854
+ if commentary:
855
+ lines += ["", "## Curated commentary (not law)"]
856
+ for a in sorted(commentary.values(), key=lambda x: x["short"]):
857
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
858
+ f"entries, reviewed {a['current_to'] or 'n/a'}. "
859
+ f"CanLex-authored analysis of the cited authorities; "
860
+ f"clearly bannered, never a source of law.")
861
+ lines += ["", "Search with canlex_search_legislation; filter by doc_type "
862
+ "(legislation / memorandum / agreement / directive / caselaw / "
863
+ "delegation / benefits / commentary). Fetch a known provision "
864
+ "with canlex_get_section, or a case's citations with canlex_case."]
865
+ return "\n".join(lines)
866
+
867
+
868
+ @mcp.tool(name="canlex_whats_new",
869
+ annotations={"title": "What Changed Recently", **_READONLY})
870
+ def canlex_whats_new(
871
+ since: Annotated[str, Field(
872
+ description="ISO date (YYYY-MM-DD): report amendments and additions "
873
+ "on or after this date.", pattern=r"^\d{4}-\d{2}-\d{2}$")],
874
+ ) -> str:
875
+ """List what has changed in the corpus's law since a date: provisions
876
+ amended or newly in force on or after it, plus each guidance source's
877
+ newest modification date.
878
+
879
+ Use for questions like "has IRPA changed since March?" or "what did the
880
+ last refresh pick up?". Changes reflect the ingested consolidations --
881
+ an amendment newer than the corpus's 'current to' dates is invisible
882
+ here; say so when the date asked about is very recent.
883
+
884
+ Returns:
885
+ str: Markdown -- amended/new provisions grouped by Act, then
886
+ guidance sources' newest dates.
887
+ """
888
+ index = _index()
889
+ amended: dict[str, list] = {}
890
+ guidance_newest: dict[str, str] = {}
891
+ for c in index.chunks:
892
+ if c.get("lang", "en") == "fr":
893
+ continue
894
+ if c.get("doc_type", "legislation") == "legislation":
895
+ stamp = max(c.get("last_amended") or "", c.get("in_force") or "")
896
+ if stamp >= since and c.get("section"):
897
+ amended.setdefault(c["act_short"], []).append(
898
+ (c["section"], c.get("marginal_note", ""), stamp))
899
+ else:
900
+ key = c.get("act_short") or c.get("doc_type")
901
+ cur = c.get("current_to") or ""
902
+ if cur > guidance_newest.get(key, ""):
903
+ guidance_newest[key] = cur
904
+ lines = [f"# Changes since {since}", ""]
905
+ if amended:
906
+ lines.append("## Provisions amended or newly in force")
907
+ for act in sorted(amended):
908
+ secs = sorted({(s, n, d) for s, n, d in amended[act]},
909
+ key=lambda t: t[2], reverse=True)
910
+ lines.append(f"**{act}** ({len(secs)} provisions):")
911
+ for s, n, d in secs[:25]:
912
+ lines.append(f"- s. {s} ({n}) — {d}")
913
+ if len(secs) > 25:
914
+ lines.append(f"- ...and {len(secs) - 25} more.")
915
+ else:
916
+ lines.append("No ingested provision shows an amendment or "
917
+ "coming-into-force date on or after that date.")
918
+ recent_guidance = {k: v for k, v in guidance_newest.items() if v >= since}
919
+ if recent_guidance:
920
+ lines += ["", "## Guidance sources modified since then"]
921
+ for k, v in sorted(recent_guidance.items(), key=lambda kv: -ord(kv[1][0])
922
+ if kv[1] else 0):
923
+ lines.append(f"- {k}: {v}")
924
+ lines += ["", "_Reflects the ingested consolidations only; verify "
925
+ "anything newer than the corpus's 'current to' dates upstream._"]
926
+ return "\n".join(lines)
927
+
928
+
929
+ @mcp.tool(name="canlex_citing",
930
+ annotations={"title": "What Cites This Provision", **_READONLY})
931
+ def canlex_citing(
932
+ act: Annotated[str, Field(
933
+ description="Act short name or code, e.g. 'IRPA'.",
934
+ min_length=1, max_length=60)],
935
+ section: Annotated[str, Field(
936
+ description="Section number, e.g. '34' (prefixes accepted).",
937
+ min_length=1, max_length=40)],
938
+ ) -> str:
939
+ """Reverse cross-reference lookup: everything in the corpus that cites a
940
+ given provision -- related provisions in the same Act, regulations made
941
+ under it, and the CBSA D-memoranda/guidance citing the section --
942
+ without fetching the section's own text first.
943
+
944
+ Returns:
945
+ str: Markdown lists of citing/related material, or a not-found
946
+ message.
947
+ """
948
+ index = _index()
949
+ result = index.get_section(act, section)
950
+ if result is None and section != _normalize_section(section):
951
+ result = index.get_section(act, _normalize_section(section))
952
+ if result is None:
953
+ return (f"No section '{section}' in '{act}'. Use canlex_list_acts "
954
+ f"for the loaded instruments.")
955
+ related = index.related(result) or {}
956
+ lines = [f"# Material citing {result['citation']}", ""]
957
+ memos = related.get("memoranda")
958
+ if memos:
959
+ lines.append("**Guidance citing this section** (persuasive, not "
960
+ "binding): " + ", ".join(memos))
961
+ provisions = related.get("provisions")
962
+ if provisions:
963
+ lines.append("**Related provisions in this Act:** "
964
+ + "; ".join(f"s. {s} ({n})" if n else f"s. {s}"
965
+ for s, n in provisions))
966
+ regs = related.get("regulations")
967
+ if regs:
968
+ lines.append("**Regulations made under this Act:** "
969
+ + "; ".join(f"{n} ({s})" for s, n in regs))
970
+ enabling = related.get("enabling_act")
971
+ if enabling:
972
+ lines.append(f"**Made under:** {enabling[1]} ({enabling[0]})")
973
+ if len(lines) == 2:
974
+ lines.append("Nothing in the corpus cross-references this section.")
975
+ lines += ["", "For case law interpreting it, search with "
976
+ "canlex_search_legislation (doc_type 'caselaw')."]
977
+ return "\n".join(lines)
978
+
979
+
980
+ @mcp.tool(name="canlex_toc",
981
+ annotations={"title": "Act Table of Contents", **_READONLY})
982
+ def canlex_toc(
983
+ act: Annotated[str, Field(
984
+ description="Act short name or code, e.g. 'IRPA' or 'Customs Act'.",
985
+ min_length=1, max_length=60)],
986
+ ) -> str:
987
+ """Browse an Act's structure: its Parts/Divisions and the sections under
988
+ each, with their marginal notes. Use when you know the Act but not the
989
+ section number, or to survey what an Act covers.
990
+
991
+ Returns:
992
+ str: Markdown outline. Very large Acts (e.g. the Criminal Code)
993
+ return Parts with section ranges rather than every section.
994
+ """
995
+ index = _index()
996
+ a = act.strip().lower()
997
+ secs = [c for c in index.chunks
998
+ if c.get("doc_type", "legislation") == "legislation"
999
+ and c.get("lang", "en") == "en"
1000
+ and a in (c["act_short"].lower(), c["act_code"].lower(),
1001
+ c.get("act_name", "").lower())
1002
+ and c.get("section") and not c["id"].endswith(tuple(
1003
+ f"-p{k}" for k in range(2, 40)))]
1004
+ if not secs:
1005
+ known = sorted({c["act_short"] for c in index.chunks
1006
+ if c.get("doc_type", "legislation") == "legislation"})
1007
+ return (f"No legislation matches '{act}'. Loaded: "
1008
+ + ", ".join(known))
1009
+ by_part: dict[str, list] = {}
1010
+ seen = set()
1011
+ for c in secs:
1012
+ if c["section"] in seen:
1013
+ continue
1014
+ seen.add(c["section"])
1015
+ by_part.setdefault(c.get("part") or "(no Part)", []).append(
1016
+ (c["section"], c.get("marginal_note", "")))
1017
+ name = secs[0]["act_name"]
1018
+ total = sum(len(v) for v in by_part.values())
1019
+ lines = [f"# {name} — contents ({total} sections)", ""]
1020
+ detailed = total <= 150
1021
+ for part, entries in by_part.items():
1022
+ lines.append(f"## {part}" if part != "(no Part)" else "## —")
1023
+ if detailed:
1024
+ for s, n in entries:
1025
+ lines.append(f"- s. {s}" + (f" — {n}" if n else ""))
1026
+ else:
1027
+ first, last = entries[0][0], entries[-1][0]
1028
+ lines.append(f"- ss. {first}–{last} ({len(entries)} sections)")
1029
+ lines += ["", "Fetch any section with canlex_get_section."]
1030
+ return "\n".join(lines)
1031
+
1032
+
1033
+ _CITATOR = None
1034
+
1035
+
1036
+ def _citator():
1037
+ """Load and cache the CanLII citator on first use."""
1038
+ global _CITATOR
1039
+ if _CITATOR is None:
1040
+ from canlex.citator import Citator
1041
+ _CITATOR = Citator()
1042
+ return _CITATOR
1043
+
1044
+
1045
+ def _format_case(report: dict) -> str:
1046
+ """Render a CanLII citator report as Markdown."""
1047
+ meta = report["meta"]
1048
+ lines = [f"## {meta.get('title', '(untitled case)')}",
1049
+ meta.get("citation", "").strip()]
1050
+ facts = []
1051
+ if meta.get("decisionDate"):
1052
+ facts.append(f"decided {meta['decisionDate']}")
1053
+ if meta.get("docketNumber"):
1054
+ facts.append(f"docket {meta['docketNumber']}")
1055
+ if facts:
1056
+ lines.append("(" + "; ".join(facts) + ")")
1057
+ if meta.get("keywords"):
1058
+ lines.append(f"**Keywords:** {meta['keywords']}")
1059
+ if meta.get("topics"):
1060
+ lines.append(f"**Topics:** {meta['topics']}")
1061
+ if meta.get("url"):
1062
+ lines.append(f"CanLII: {meta['url']}")
1063
+ lines.append("")
1064
+ lines.append("_CanLII citator data (live). This is case metadata and the "
1065
+ "citation graph only -- not the judgment text; follow the CanLII "
1066
+ "link to read the decision. A case is binding precedent depending "
1067
+ "on the court and jurisdiction._")
1068
+
1069
+ def case_list(label, block):
1070
+ rows = [f"\n### {label}: {block['total']}"]
1071
+ for item in block["items"]:
1072
+ rows.append(f"- {item.get('title', '')} — {item.get('citation', '')}")
1073
+ extra = block["total"] - len(block["items"])
1074
+ if extra > 0:
1075
+ rows.append(f" ...and {extra} more.")
1076
+ return rows
1077
+
1078
+ lines += case_list("Cited by (later cases citing this one)", report["citingCases"])
1079
+ lines += case_list("Cites (authorities this case relies on)", report["citedCases"])
1080
+
1081
+ legis = report["citedLegislations"]
1082
+ lines.append(f"\n### Legislation cited: {legis['total']}")
1083
+ for item in legis["items"]:
1084
+ kind = item.get("type", "")
1085
+ lines.append(f"- {item.get('title', '')} — {item.get('citation', '')}"
1086
+ + (f" [{kind}]" if kind else ""))
1087
+ extra = legis["total"] - len(legis["items"])
1088
+ if extra > 0:
1089
+ lines.append(f" ...and {extra} more.")
1090
+ return "\n".join(lines)
1091
+
1092
+
1093
+ @mcp.tool(name="canlex_case",
1094
+ annotations={"title": "CanLII Case Citator", "readOnlyHint": True,
1095
+ "destructiveHint": False, "idempotentHint": True,
1096
+ "openWorldHint": True})
1097
+ def canlex_case(
1098
+ case_url: Annotated[str, Field(
1099
+ description="A Canadian case, given either as a full canlii.org URL "
1100
+ "or -- for a Supreme Court, Federal Court of Appeal, Federal Court, "
1101
+ "FPSLREB or CIRB decision -- its neutral citation (e.g. '2019 SCC "
1102
+ "65', '2016 FCA 93', '2023 FPSLREB 12'). For other courts, supply "
1103
+ "the canlii.org URL; find it by web search if you only have the "
1104
+ "case name.", min_length=8, max_length=400)],
1105
+ ) -> str:
1106
+ """Look up a Canadian case on CanLII and return its citation graph.
1107
+
1108
+ Returns the case's metadata plus its citator: the cases it cites, the cases
1109
+ that cite it (its treatment and how leading it is), and the legislation it
1110
+ cites -- live from the CanLII API. Use it to gauge whether a decision is
1111
+ still good law -- how heavily and how recently it has been cited.
1112
+
1113
+ Supply either a canlii.org URL or, for a Supreme Court / Federal Court of
1114
+ Appeal / Federal Court decision, its neutral citation (e.g. '2019 SCC 65') --
1115
+ the citation a canlex_search_legislation result already shows. This returns
1116
+ metadata and the citation graph only, NOT the judgment text -- follow the
1117
+ CanLII link for that. A call takes ~15-20 seconds (the API is rate-limited).
1118
+
1119
+ Args:
1120
+ params (CaseInput): contains case_url -- a canlii.org URL or a neutral
1121
+ citation.
1122
+
1123
+ Returns:
1124
+ str: Markdown -- the case's title, neutral citation, date, docket and
1125
+ topics; how many cases cite it (with examples); how many it cites; and the
1126
+ legislation it cites. Returns an error message if the URL is not a
1127
+ recognized canlii.org case URL, or if the CanLII API is unavailable.
1128
+ """
1129
+ try:
1130
+ citator = _citator()
1131
+ except Exception:
1132
+ return ("This deployment has no citator configured (the live CanLII "
1133
+ "lookup is unavailable here). Fall back to a web search for "
1134
+ "the case's CanLII page and note that its later treatment "
1135
+ "was not verified.")
1136
+ try:
1137
+ report = citator.case_report(case_url)
1138
+ except Exception as exc:
1139
+ return f"CanLII lookup failed: {type(exc).__name__}: {exc}"
1140
+ if "error" in report:
1141
+ return f"Error: {report['error']}"
1142
+ return _format_case(report)
1143
+
1144
+
1145
+ @mcp.prompt(name="us_record_screening",
1146
+ description="Guided IRPA s. 36 screening of a US criminal record: "
1147
+ "disposition -> equivalency -> rehabilitation, using the three "
1148
+ "CanLex screening tools in order with the right hand-offs.")
1149
+ def us_record_screening(disposition: str, state: str = "",
1150
+ offense: str = "", sentence_completed_year: str = "",
1151
+ ) -> str:
1152
+ """The three-step s. 36 screening chain as a reusable client prompt."""
1153
+ facts = [f"Disposition: {disposition}"]
1154
+ if state:
1155
+ facts.append(f"State: {state}")
1156
+ if offense:
1157
+ facts.append(f"Offence: {offense}")
1158
+ if sentence_completed_year:
1159
+ facts.append(f"Sentence completed: {sentence_completed_year}")
1160
+ return (
1161
+ "Screen this US criminal record for inadmissibility under IRPA "
1162
+ "s. 36, using CanLex's three screening tools IN ORDER and carrying "
1163
+ "each step's output into the next:\n\n" + "\n".join(facts) + "\n\n"
1164
+ "STEP 1 — canlex_us_disposition (pass the disposition and state): is "
1165
+ "this a 'conviction' at all? If the verdict is NO or LIKELY NO, note "
1166
+ "that the act branch (s. 36(1)(c)/(2)(c)) may still apply and say "
1167
+ "so; a NO verdict usually ends the conviction-branch analysis.\n"
1168
+ "STEP 2 — canlex_us_equivalency (pass the offence): what Canadian "
1169
+ "offence does it equate to, and is the branch serious criminality "
1170
+ "(s. 36(1)(b)) or criminality (s. 36(2)(b))? Note the maximum "
1171
+ "penalty — whether it is under 10 years feeds step 3.\n"
1172
+ "STEP 3 — canlex_rehabilitation: compute full years since sentence "
1173
+ "completion from today's date, pass basis='foreign-conviction', the "
1174
+ "max-penalty answer from step 2, the count, the elapsed years, and "
1175
+ "whether the record is otherwise clean.\n\n"
1176
+ "Then give a single bottom-line assessment with every authority "
1177
+ "cited, every uncertainty flagged, and the reminder that this is "
1178
+ "legal information, not legal advice, and that an officer's "
1179
+ "determination governs at the port of entry.")
1180
+
1181
+
1182
+ @mcp.resource("canlex://grounding-note",
1183
+ name="CanLex answering instructions",
1184
+ description="The grounding/citation/hedging instructions "
1185
+ "prepended to every search result.")
1186
+ def _res_grounding() -> str:
1187
+ return GROUNDING_NOTE
1188
+
1189
+
1190
+ @mcp.resource("canlex://corpus",
1191
+ name="CanLex corpus inventory",
1192
+ description="Everything the corpus contains, grouped by source "
1193
+ "type, with currency dates.")
1194
+ def _res_corpus() -> str:
1195
+ return canlex_list_acts()
1196
+
1197
+
1198
+ @mcp.resource("canlex://methodology/conviction",
1199
+ name="US disposition conviction framework",
1200
+ description="The governing framework for whether a US "
1201
+ "disposition is an IRPA s. 36 'conviction'.")
1202
+ def _res_conviction_framework() -> str:
1203
+ data = _load_dispositions()
1204
+ if data:
1205
+ for m in data.get("methodology", []):
1206
+ if m.get("id") == "conviction-framework":
1207
+ return m["text"]
1208
+ return "The dispositions dataset is not present in this deployment."
1209
+
1210
+
1211
+ @mcp.resource("canlex://methodology/equivalency",
1212
+ name="Offence equivalency framework (Hill)",
1213
+ description="The Hill v MEI methodology for equating a foreign "
1214
+ "offence to a Canadian one, with the s. 36(3)(a) and Tran rules.")
1215
+ def _res_equivalency_framework() -> str:
1216
+ data = _load_equivalency()
1217
+ if data:
1218
+ for m in data.get("methodology", []):
1219
+ if m.get("id") == "equivalency-framework":
1220
+ return m["text"]
1221
+ return "The equivalency dataset is not present in this deployment."
1222
+
1223
+
1224
+ if __name__ == "__main__":
1225
+ try:
1226
+ idx = _index()
1227
+ print(f"CanLex MCP: {len(idx.chunks)} sections loaded.", file=sys.stderr)
1228
+ except Exception as exc: # surfaced in the MCP client's server logs
1229
+ print(f"CanLex MCP: failed to load legislation index: {exc}\n"
1230
+ f"Run 'py -m canlex.ingest' first.", file=sys.stderr)
1231
+ sys.exit(1)
1232
+ # CANLEX_HTTP switches on the remote (web) transport; default is local stdio.
1233
+ if os.environ.get("CANLEX_HTTP"):
1234
+ print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr)
1235
+ mcp.run(transport="streamable-http")
1236
+ else:
1237
+ mcp.run()
canlex/sweep.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.006, 0.012, 0.024, 0.05], 0.012),
31
+ ("CANLEX_REG_PENALTY", [0.004, 0.008, 0.016, 0.032], 0.008),
32
+ ("CANLEX_BACKMATTER_PENALTY",[0.004, 0.008, 0.016, 0.032], 0.008),
33
+ ]
34
+
35
+
36
+ _METRIC_RE = re.compile(
37
+ r"Hit@1:\s*([\d.]+).*?Hit@3:\s*([\d.]+).*?Hit@5:\s*([\d.]+).*?"
38
+ r"Hit@10:\s*([\d.]+).*?MRR:\s*([\d.]+)", re.S)
39
+
40
+
41
+ def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
42
+ """Run canlex.eval once with the given env overrides; return metrics dict."""
43
+ env = dict(os.environ)
44
+ env["CANLEX_EVAL_SWEEP"] = "1" # holdout questions stay sweep-blind
45
+ for k, v in env_overrides.items():
46
+ env[k] = f"{v}"
47
+ proc = subprocess.run(
48
+ [sys.executable, "-u", "-m", "canlex.eval"],
49
+ capture_output=True, text=True, env=env, cwd=ROOT,
50
+ )
51
+ if proc.returncode != 0:
52
+ raise RuntimeError(f"eval failed (exit {proc.returncode}):\n"
53
+ f"{proc.stderr[-800:]}")
54
+ m = _METRIC_RE.search(proc.stdout)
55
+ if not m:
56
+ raise RuntimeError(f"could not parse eval output:\n{proc.stdout[-800:]}")
57
+ h1, h3, h5, h10, mrr = (float(x) for x in m.groups())
58
+ n_misses = proc.stdout.count("miss(es)") # 0 if we end up at 100
59
+ return {"hit1": h1, "hit3": h3, "hit5": h5, "hit10": h10, "mrr": mrr,
60
+ "stdout": proc.stdout}
61
+
62
+
63
+ def _score(metrics: dict[str, float]) -> tuple[float, float]:
64
+ """Order by Hit@5 then MRR (both higher is better)."""
65
+ return (metrics["hit5"], metrics["mrr"])
66
+
67
+
68
+ def main():
69
+ LOG.parent.mkdir(parents=True, exist_ok=True)
70
+ log = LOG.open("w", encoding="utf-8")
71
+ log.write(f"# CanLex tuning sweep -- {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
72
+ print(f"# CanLex tuning sweep -- writing log to {LOG}\n")
73
+
74
+ current = {name: default for name, _values, default in KNOBS}
75
+ all_runs: list[dict] = []
76
+
77
+ # Baseline at current defaults.
78
+ print("Baseline:", current)
79
+ log.write(f"\nBaseline: {current}\n")
80
+ baseline = _run_eval(current)
81
+ print(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}")
82
+ log.write(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}\n")
83
+ all_runs.append({"values": dict(current), "metrics":
84
+ {k: baseline[k] for k in ("hit1", "hit3", "hit5", "hit10", "mrr")}})
85
+ best_metrics = baseline
86
+
87
+ for name, values, _default in KNOBS:
88
+ print(f"\nSweeping {name} in {values} (others held at {current})...")
89
+ log.write(f"\nSweeping {name} in {values} (others held at {current})\n")
90
+ local_best = (current[name], _score(best_metrics), best_metrics)
91
+ for v in values:
92
+ if v == current[name]:
93
+ # Re-use the already-measured baseline at this knob value.
94
+ metrics = best_metrics
95
+ else:
96
+ run_values = dict(current, **{name: v})
97
+ metrics = _run_eval(run_values)
98
+ row = (f" {name}={v!r:<8s} -> Hit@1={metrics['hit1']:.3f} "
99
+ f"Hit@3={metrics['hit3']:.3f} Hit@5={metrics['hit5']:.3f} "
100
+ f"Hit@10={metrics['hit10']:.3f} MRR={metrics['mrr']:.3f}")
101
+ print(row); log.write(row + "\n"); log.flush()
102
+ all_runs.append({"values": dict(current, **{name: v}),
103
+ "metrics": {k: metrics[k] for k in
104
+ ("hit1", "hit3", "hit5", "hit10", "mrr")}})
105
+ score = _score(metrics)
106
+ if score > local_best[1]:
107
+ local_best = (v, score, metrics)
108
+ if local_best[0] != current[name]:
109
+ print(f" ! {name}: {current[name]} -> {local_best[0]} "
110
+ f"(Hit@5 {best_metrics['hit5']:.3f} -> {local_best[2]['hit5']:.3f})")
111
+ log.write(f" ! {name}: {current[name]} -> {local_best[0]}\n")
112
+ current[name] = local_best[0]
113
+ best_metrics = local_best[2]
114
+
115
+ print(f"\nBest: {current}")
116
+ print(f" Hit@1={best_metrics['hit1']:.3f} Hit@3={best_metrics['hit3']:.3f} "
117
+ f"Hit@5={best_metrics['hit5']:.3f} Hit@10={best_metrics['hit10']:.3f} "
118
+ f"MRR={best_metrics['mrr']:.3f}")
119
+ log.write(f"\nBest: {current}\n Hit@1={best_metrics['hit1']:.3f} "
120
+ f"Hit@5={best_metrics['hit5']:.3f} MRR={best_metrics['mrr']:.3f}\n")
121
+ log.close()
122
+ SUMMARY.write_text(json.dumps({
123
+ "best": current,
124
+ "best_metrics": {k: best_metrics[k] for k in
125
+ ("hit1", "hit3", "hit5", "hit10", "mrr")},
126
+ "runs": all_runs,
127
+ }, indent=2), encoding="utf-8")
128
+ print(f"\nLog: {LOG}\nSummary: {SUMMARY}")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
canlex/synonyms.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ (r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
54
+ # The single most common US-screening term: bridge DUI shorthand to the
55
+ # Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
56
+ (r"(?:dwi|dui|drunk driving)",
57
+ "operation while impaired blood alcohol concentration conveyance"),
58
+ (r"personal exemption",
59
+ "returning persons exemption duty free allowance"),
60
+ (r"(?:nexus|trusted traveller)",
61
+ "presentation of persons alternative manner authorization"),
62
+ # Border and customs
63
+ (r"cbsa", "canada border services agency"),
64
+ (r"bsos?", "border services officer"),
65
+ (r"amps", "administrative monetary penalty system"),
66
+ # Financial-crime and labour
67
+ (r"fintrac", "financial transactions and reports analysis centre"),
68
+ (r"njc", "national joint council"),
69
+ # French -> English bridges. The corpus is English-only, but officers work
70
+ # bilingually and drafting agents emit French terms; with the tokenizer's
71
+ # accent folding these triggers match either accented or folded forms.
72
+ # High-precision statutory anchors only.
73
+ (r"erar", "pre-removal risk assessment application for protection"),
74
+ (r"asfc", "canada border services agency"),
75
+ (r"interdiction de territoire", "inadmissibility inadmissible"),
76
+ (r"grande criminalit[ée]", "serious criminality"),
77
+ (r"criminalit[ée]", "criminality convicted offence"),
78
+ (r"renvoi", "removal order"),
79
+ (r"saisie?s?", "seizure seized goods"),
80
+ # 'devises' is the everyday word for currency; the PCMLTFA's French text
81
+ # says 'espèces et effets' -- bridge to both statutory vocabularies.
82
+ (r"devises", "currency monetary instruments espèces effets"),
83
+ (r"r[ée]sidents? permanents?", "permanent resident"),
84
+ (r"[ée]trangers?", "foreign national"),
85
+ (r"d[ée]tention", "detention detained review"),
86
+ (r"r[ée]fugi[ée]s?", "refugee protection convention"),
87
+ (r"douanes?", "customs duties"),
88
+ (r"marchandises?", "goods imported"),
89
+ (r"d[ée]claration", "report declaration"),
90
+ (r"armes? [àa] feu", "firearm weapon"),
91
+ (r"contr[ôo]le", "examination search"),
92
+ (r"agents? des services frontaliers", "border services officer"),
93
+ (r"permis de travail", "work permit"),
94
+ (r"permis d'[ée]tudes", "study permit"),
95
+ (r"visa de r[ée]sident temporaire", "temporary resident visa"),
96
+ (r"r[ée]adaptation", "rehabilitation prescribed period"),
97
+ (r"expulsion", "deportation removal order"),
98
+ (r"passeurs?", "human smuggling organized entry"),
99
+ (r"fouille", "search of the person strip search"),
100
+ (r"griefs?", "grievance adjudication"),
101
+ (r"convention collective", "collective agreement"),
102
+ (r"heures suppl[ée]mentaires", "overtime hours of work"),
103
+ ]
104
+
105
+ _COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)
106
+ for trigger, expansion in _SYNONYMS]
107
+
108
+
109
+ def expand_query(query):
110
+ """Return `query` with canonical statutory terms appended for every legal
111
+ abbreviation it contains; return it unchanged if it contains none."""
112
+ additions = [exp for pattern, exp in _COMPILED if pattern.search(query)]
113
+ if not additions:
114
+ return query
115
+ return f"{query} {' '.join(additions)}"
116
+
117
+
118
+ def main():
119
+ query = " ".join(sys.argv[1:]) or "PRRA eligibility and an H&C application"
120
+ print(f"query: {query}")
121
+ print(f"expanded: {expand_query(query)}")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
canlex/tariff_schedule.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
61
+ return fetch_cached(url, dest)
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):
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
+ chunks = parse_chapter(html, src)
254
+ except Exception as exc:
255
+ print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
256
+ failures.append((src["chapter"], f"{type(exc).__name__}: {exc}"))
257
+ continue
258
+ if not chunks:
259
+ # A chapter never parses to nothing legitimately -- an empty result
260
+ # means the page moved or changed shape, so treat it as a failure
261
+ # rather than letting it silently shrink the corpus.
262
+ print(f" !! {src['code']}: no chunks parsed")
263
+ failures.append((src["chapter"], "no chunks parsed"))
264
+ continue
265
+ all_chunks.extend(chunks)
266
+ print(f" {len(chunks)} chunks")
267
+
268
+ all_chunks, preserved = preserve_failed(all_chunks, failures,
269
+ stored_chunks(OUT))
270
+ if preserved:
271
+ print(f" kept the last-good copy of {len(preserved)} chapter(s): "
272
+ f"{', '.join(preserved)}")
273
+ if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
274
+ return False
275
+ print(f"\n{len(all_chunks)} tariff-schedule chunks from "
276
+ f"{len(SOURCES) - len(failures)} chapter(s) -> {OUT}")
277
+ return True
278
+
279
+
280
+ def main():
281
+ sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
data/curated/drafts/equivalency-pairings-draft.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_draft": "DRAFT FOR MAINTAINER REVIEW 2026-07-23 — three new pairings for data/curated/us_equivalency.json. Every Canadian maximum verified against the corpus text of the current C-46/IRPA consolidations. NOT deployed: append to the 'pairings' array, bump 'reviewed', then py -m canlex.commentary && py -m canlex.embed && eval && push.",
3
+ "pairings": [
4
+ {
5
+ "id": "weapons-carry",
6
+ "us_terms": ["unlawful carrying of a weapon", "CCW violation", "carrying a concealed weapon", "unlawful possession of a firearm", "felon in possession", "UCW"],
7
+ "canadian_offence": "Criminal Code s. 90 (carrying a concealed weapon); s. 91 (unauthorized possession of a firearm); s. 92 (possession knowing possession is unauthorized); s. 88 (possession of a weapon for a purpose dangerous to the public peace)",
8
+ "penalty": "Hybrid throughout; indictable maximums verified: s. 90 five years; s. 91 five years; s. 92 ten years; s. 88 ten years",
9
+ "branch": "SPLITS ON THE EQUIVALENT: ss. 90/91 (5 years) -> CRIMINALITY, IRPA s. 36(2)(b); ss. 92/88 (10 years) -> SERIOUS CRIMINALITY, s. 36(1)(b)",
10
+ "status": "guidance-only",
11
+ "analysis": "The most common US weapons conviction — carrying without a permit — usually equates to s. 90 or s. 91 (five-year maximums): criminality, deemed rehabilitation available after 10 years. The branch escalates to serious criminality where the record shows KNOWLEDGE the possession was unauthorized in a s. 92 sense, or a dangerous purpose (s. 88). 'Felon in possession' convictions typically map to s. 91/92 — the s. 92 knowledge element is often satisfied on those facts. The Hill evidence branch matters more here than for most pairings: US statutes bundle conduct Canada splits across four provisions.",
12
+ "caveats": [
13
+ "Constitutional-carry states: lawful-there conduct can still equate to a Canadian offence, but with no US conviction only the act branch (s. 36(2)(c)) is in play.",
14
+ "A weapon lawful in the US but prohibited in Canada (e.g. brass knuckles) engages the classification regulations (SOR/98-462) — check the device, not just the conduct.",
15
+ "Using a firearm in the commission of an offence (s. 85) carries consecutive mandatory minimums — an aggravated US charge deserves its own equivalency analysis."
16
+ ],
17
+ "authorities": []
18
+ },
19
+ {
20
+ "id": "sexual-assault",
21
+ "us_terms": ["sexual assault", "criminal sexual conduct", "sexual battery", "rape", "statutory rape", "sexual abuse"],
22
+ "canadian_offence": "Criminal Code s. 271 (sexual assault); s. 151 (sexual interference, complainant under 16); ss. 272/273 (weapon/bodily harm/aggravated)",
23
+ "penalty": "s. 271 hybrid, indictable maximum 10 years (14 where the complainant is under 16); s. 151 hybrid, 14 years with a one-year mandatory minimum; verified from the corpus text",
24
+ "branch": "SERIOUS CRIMINALITY — IRPA s. 36(1)(b) in effectively every configuration (every plausible equivalent carries a 10-year-or-greater maximum)",
25
+ "status": "guidance-only",
26
+ "analysis": "Any US sexual-offence conviction equates to a 10-years-or-more Canadian offence: serious criminality, no deemed rehabilitation ever, individual rehabilitation application (5 years post-sentence) or TRP the only routes. 'Statutory rape' variants map to s. 151 (14-year maximum) — age thresholds differ by state, so verify the victim's age against Canada's under-16 line (and the close-in-age exceptions in s. 150.1) via the Hill evidence branch.",
27
+ "caveats": [
28
+ "US registration-only dispositions (e.g. a deferred adjudication that still requires registration) remain step-1 questions — registration does not itself make a disposition a conviction.",
29
+ "Canada's s. 150.1 close-in-age defences can defeat equivalency for peer-age statutory-rape convictions — this is the classic Li point that available Canadian defences form part of the comparison.",
30
+ "Misdemeanor 'sexual battery' in some states covers non-consensual touching that still equates to s. 271 — the 10-year branch does not depend on the US felony label."
31
+ ],
32
+ "authorities": [
33
+ {"cite": "Li v Canada (MCI), [1997] 1 FC 235 (CA)", "court": "Federal Court of Appeal", "holding": "Equivalency compares essential elements including available defences; no retrial of the foreign case."}
34
+ ]
35
+ },
36
+ {
37
+ "id": "immigration-fraud",
38
+ "us_terms": ["visa fraud", "immigration fraud", "false statement on immigration application", "alien smuggling", "harboring an alien", "marriage fraud"],
39
+ "canadian_offence": "IRPA s. 127 (misrepresentation offence; penalty s. 128); IRPA ss. 122-123 (document offences); IRPA s. 117 (organizing entry — smuggling); Criminal Code s. 368 (use of forged document)",
40
+ "penalty": "s. 128: hybrid, indictable maximum 5 years; s. 123: up to 14 years for document use/trafficking; s. 117: up to life (with mandatory minimums where aggravated); verified from the corpus text",
41
+ "branch": "SPLITS: false statements/marriage fraud -> s. 127/128 (5 years) = CRIMINALITY, s. 36(2)(b); document fraud with use/trafficking -> s. 123 (14 years) = SERIOUS CRIMINALITY; alien smuggling -> s. 117 = SERIOUS CRIMINALITY",
42
+ "status": "guidance-only",
43
+ "analysis": "A US conviction for lying on an immigration form (18 USC 1546 false-statement branch) generally equates to IRPA s. 127: five-year maximum, criminality only — deemed rehabilitation available. The same US statute's document-use branch reaches s. 123's 14-year maximum: serious criminality. Alien smuggling (8 USC 1324) equates to IRPA s. 117 — serious criminality, and s. 37 organized-criminality may also be in play. NOTE: a misrepresentation to CANADIAN authorities separately grounds s. 40 inadmissibility without any conviction; this pairing addresses foreign convictions only.",
44
+ "caveats": [
45
+ "18 USC 1546 is a classic Hill problem: one US provision straddles two Canadian branches — the record of what was actually done decides between s. 127 (5 years) and s. 123 (14 years).",
46
+ "'Harboring' convictions vary widely; minor-assistance facts may equate to s. 117's lower tiers or fail equivalency entirely — evidence branch required.",
47
+ "Marriage fraud: the US conviction equates to s. 127 (criminality), but the underlying facts may independently engage s. 40 misrepresentation if made to IRCC/CBSA."
48
+ ],
49
+ "authorities": []
50
+ }
51
+ ]
52
+ }
data/curated/us_dispositions.json ADDED
The diff for this file is too large to render. See raw diff
 
data/curated/us_equivalency.json ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "reviewed": "2026-07-15",
3
+ "_provenance": "Authored 2026-07-12. Every Canadian maximum penalty verified against the corpus text of the Criminal Code (C-46) and CDSA (C-38.8), current consolidations; branch classifications apply IRPA s. 36(3)(a) (hybrid offences deemed indictable). Tran v Canada, 2017 SCC 50 (in-corpus) supplies the commission-date rule for assessing maximum penalties. Equivalency methodology per Hill v Canada (MEI) (1987), 73 NR 315 (FCA), as set out in IRB Legal Services, Sponsorship Appeals Ch. 2 (Criminal Refusals). These are the COMMON pairings only -- an unusual offence needs the full Hill analysis against the actual foreign statute.",
4
+ "methodology": [
5
+ {
6
+ "id": "equivalency-framework",
7
+ "title": "How offence equivalency works (step 2 of the s. 36 analysis)",
8
+ "text": "Equivalency asks whether the foreign offence, if committed in Canada, would constitute a federal offence of the required seriousness. The governing test is Hill v Canada (MEI) (1987), 73 NR 315 (FCA): equivalency can be established (1) by comparing the precise wording and essential elements of the two provisions; (2) by examining the evidence of what the person actually did, to see whether it would satisfy the essential elements of the Canadian offence; or (3) by a combination of both. Key rules: (a) HYBRID DEEMING -- IRPA s. 36(3)(a): an offence prosecutable either way in Canada is deemed indictable, at its indictable maximum, even if it would likely proceed summarily; (b) TIMING -- Tran v Canada, 2017 SCC 50: the maximum penalty is assessed as of the DATE OF COMMISSION, so a later increase in the Canadian maximum does not retroactively convert criminality into serious criminality; (c) THRESHOLDS -- Canadian property offences split at $5,000 (theft, fraud, mischief), so the value in the foreign record often decides the branch; (d) the comparison is with the CANADIAN offence's elements -- a foreign offence broader than its Canadian counterpart requires the evidence branch of Hill to see what was actually done; (e) purely SUMMARY Canadian equivalents (e.g. causing a disturbance, s. 175) cannot ground s. 36(2)(b) inadmissibility on a single conviction -- only the two-or-more-convictions branch reaches them."
9
+ }
10
+ ],
11
+ "pairings": [
12
+ {
13
+ "id": "dui",
14
+ "us_terms": [
15
+ "DUI",
16
+ "DWI",
17
+ "OWI",
18
+ "OUI",
19
+ "driving under the influence",
20
+ "driving while intoxicated",
21
+ "operating under the influence",
22
+ "drunk driving",
23
+ "impaired driving"
24
+ ],
25
+ "canadian_offence": "Criminal Code s. 320.14 (operation while impaired / over the legal limit); punishment s. 320.19",
26
+ "penalty": "Hybrid; indictable maximum 10 years (s. 320.19(1)(a): 'liable to imprisonment for a term of not more than 10 years')",
27
+ "branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b): via s. 36(3)(a) hybrid deeming, a single ordinary US DUI conviction equates to an offence punishable by 10 years",
28
+ "status": "judicially-considered",
29
+ "analysis": "The single most consequential everyday pairing: one US misdemeanor DUI grounds serious criminality, because Canada's impaired-driving offence is hybrid with a 10-year indictable maximum since December 18, 2018. Deemed rehabilitation is therefore unavailable (10-year-or-more maximum); only individual rehabilitation or a TRP cures it.",
30
+ "caveats": [
31
+ "TIMING (Tran, 2017 SCC 50): for offences COMMITTED before December 18, 2018, the equivalent was the former s. 253 (maximum 5 years) -- criminality under s. 36(2)(b) only, not serious criminality, and deemed rehabilitation remains available.",
32
+ "Zero-tolerance administrative per-se findings are not convictions (see the DUI entry in the dispositions helper); this pairing addresses criminal DUI convictions.",
33
+ "A US 'wet reckless' plea-down maps to careless/dangerous driving analysis, not s. 320.14 -- check what was actually convicted."
34
+ ],
35
+ "authorities": [
36
+ {
37
+ "cite": "Tran v Canada (Public Safety), 2017 SCC 50",
38
+ "court": "Supreme Court of Canada",
39
+ "holding": "The maximum term for equivalency/seriousness purposes is assessed as of the date of commission; subsequent increases do not apply retrospectively."
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "id": "theft-under",
45
+ "us_terms": [
46
+ "petty theft",
47
+ "petit larceny",
48
+ "shoplifting",
49
+ "misdemeanor theft",
50
+ "theft under"
51
+ ],
52
+ "canadian_offence": "Criminal Code s. 334(b) (theft not exceeding $5,000)",
53
+ "penalty": "Hybrid; indictable maximum 2 years (s. 334(b)(i))",
54
+ "branch": "CRIMINALITY -- IRPA s. 36(2)(b) (foreign national); a permanent resident is NOT inadmissible on this alone",
55
+ "status": "guidance-only",
56
+ "analysis": "Ordinary shoplifting equates to theft under $5,000: hybrid, deemed indictable, 2-year maximum. One conviction makes a foreign national inadmissible for criminality; two convictions not arising from a single occurrence also engage s. 36(2)(b). Deemed rehabilitation after 10 years (single) per IRPR s. 18(2)(a).",
57
+ "caveats": [
58
+ "Convert the stolen value at the offence date: a US 'felony grand theft' at a low state threshold (some states: $500+) may still be theft UNDER $5,000 in Canadian terms -- the CANADIAN threshold controls the branch.",
59
+ "Value over $5,000 -> s. 334(a), 10-year maximum -> serious criminality (see theft-over)."
60
+ ],
61
+ "authorities": []
62
+ },
63
+ {
64
+ "id": "theft-over",
65
+ "us_terms": [
66
+ "grand theft",
67
+ "grand larceny",
68
+ "felony theft",
69
+ "auto theft",
70
+ "grand theft auto"
71
+ ],
72
+ "canadian_offence": "Criminal Code s. 334(a) (theft exceeding $5,000); motor vehicle theft s. 333.1",
73
+ "penalty": "Indictable maximum 10 years (s. 334(a): 'not more than 10 years'); motor vehicle theft (s. 333.1) hybrid, 10 years",
74
+ "branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b), where the value genuinely exceeds $5,000 (or the subject is a motor vehicle under s. 333.1)",
75
+ "status": "guidance-only",
76
+ "analysis": "Theft of property over $5,000 (Canadian dollars, at the offence date) is a 10-year offence: serious criminality; no deemed rehabilitation.",
77
+ "caveats": [
78
+ "The US felony label does not control -- verify the actual value against the Canadian $5,000 line; borderline values with exchange-rate movement deserve the evidence branch of Hill."
79
+ ],
80
+ "authorities": []
81
+ },
82
+ {
83
+ "id": "assault-simple",
84
+ "us_terms": [
85
+ "simple assault",
86
+ "misdemeanor assault",
87
+ "battery",
88
+ "simple battery",
89
+ "assault in the fourth degree"
90
+ ],
91
+ "canadian_offence": "Criminal Code s. 266 (assault)",
92
+ "penalty": "Hybrid; indictable maximum 5 years (s. 266(a): 'not exceeding five years')",
93
+ "branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
94
+ "status": "guidance-only",
95
+ "analysis": "US simple assault/battery generally equates to s. 266: hybrid, 5-year maximum -- criminality, not serious criminality. Deemed rehabilitation available after 10 years (single conviction).",
96
+ "caveats": [
97
+ "Bodily injury, a weapon, or choking moves the equivalent to s. 267 (10 years) -> serious criminality (see assault-aggravated).",
98
+ "Domestic-violence variants equate to the same assault provisions; the DV character matters to sentencing and to some US dispositions, not to the Canadian equivalency.",
99
+ "Some US assault statutes cover mere threats -- threat-only conduct maps to s. 264.1 (uttering threats, 5 years), same branch."
100
+ ],
101
+ "authorities": []
102
+ },
103
+ {
104
+ "id": "assault-aggravated",
105
+ "us_terms": [
106
+ "aggravated assault",
107
+ "assault with a deadly weapon",
108
+ "ADW",
109
+ "felony assault",
110
+ "assault causing injury",
111
+ "domestic battery with injury"
112
+ ],
113
+ "canadian_offence": "Criminal Code s. 267 (assault with a weapon / causing bodily harm / choking); s. 268 (aggravated assault)",
114
+ "penalty": "s. 267: hybrid, indictable maximum 10 years; s. 268: indictable, maximum 14 years",
115
+ "branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b)",
116
+ "status": "guidance-only",
117
+ "analysis": "Weapon involvement or bodily harm lifts the Canadian equivalent to s. 267 (10 years) or s. 268 (wounding/maiming, 14 years): serious criminality either way; no deemed rehabilitation.",
118
+ "caveats": [
119
+ "US 'aggravated' labels sometimes rest only on the victim's status (e.g. assault on an officer) -- if no weapon/injury, the closer equivalent may be s. 270 (assaulting an officer, hybrid 5 years) -> criminality only. Use the evidence branch."
120
+ ],
121
+ "authorities": []
122
+ },
123
+ {
124
+ "id": "fraud",
125
+ "us_terms": [
126
+ "fraud",
127
+ "bad checks",
128
+ "check fraud",
129
+ "insufficient funds",
130
+ "credit card fraud",
131
+ "identity theft",
132
+ "wire fraud"
133
+ ],
134
+ "canadian_offence": "Criminal Code s. 380 (fraud); s. 342 (credit card offences); s. 402.2 (identity theft)",
135
+ "penalty": "s. 380(1)(a): indictable maximum 14 years where value exceeds $5,000; s. 380(1)(b): hybrid, 2 years where value $5,000 or less",
136
+ "branch": "SERIOUS CRIMINALITY over $5,000 (36(1)(b)); CRIMINALITY at or under $5,000 (36(2)(b))",
137
+ "status": "guidance-only",
138
+ "analysis": "Fraud splits on the $5,000 line exactly like theft. Bad-check convictions are usually small-value: hybrid 2-year equivalents -> criminality. Larger frauds (14 years) are serious criminality.",
139
+ "caveats": [
140
+ "Many US bad-check statutes criminalize mere non-payment without dishonesty elements -- where intent to defraud is not an element, the Canadian equivalent may fail on essential elements; use the Hill evidence branch."
141
+ ],
142
+ "authorities": []
143
+ },
144
+ {
145
+ "id": "drug-possession",
146
+ "us_terms": [
147
+ "drug possession",
148
+ "possession of a controlled substance",
149
+ "simple possession",
150
+ "cocaine possession",
151
+ "meth possession",
152
+ "narcotics possession"
153
+ ],
154
+ "canadian_offence": "CDSA s. 4(1) (possession); punishment s. 4(3)-(6) by schedule",
155
+ "penalty": "Schedule I (cocaine, heroin, meth, fentanyl): hybrid, indictable maximum 7 years (s. 4(3)(a): 'not exceeding seven years'); Schedule III: hybrid, 3 years",
156
+ "branch": "CRIMINALITY -- IRPA s. 36(2)(b) (7-year maximum is below the 10-year serious-criminality line)",
157
+ "status": "guidance-only",
158
+ "analysis": "Simple possession of hard drugs equates to CDSA s. 4(1): hybrid, 7-year maximum for Schedule I -- criminality, NOT serious criminality. Deemed rehabilitation available after 10 clean years for a single conviction.",
159
+ "caveats": [
160
+ "CANNABIS IS DIFFERENT: see the cannabis entry -- simple possession within legal limits is no longer an offence in Canada at all.",
161
+ "Possession WITH INTENT to distribute equates to trafficking (see drug-trafficking): a completely different branch."
162
+ ],
163
+ "authorities": []
164
+ },
165
+ {
166
+ "id": "cannabis-possession",
167
+ "us_terms": [
168
+ "marijuana possession",
169
+ "cannabis possession",
170
+ "possession of marijuana",
171
+ "weed possession"
172
+ ],
173
+ "canadian_offence": "None for simple possession within legal limits -- the Cannabis Act legalized adult possession of up to 30 g of dried cannabis (public)",
174
+ "penalty": "Not an offence in Canada (within limits); over-limit possession: Cannabis Act s. 8, hybrid, maximum 5 years less a day",
175
+ "branch": "NO INADMISSIBILITY for simple possession equivalents: with no equivalent Canadian offence, neither s. 36(1)(b) nor s. 36(2)(b) can be made out",
176
+ "status": "guidance-only",
177
+ "analysis": "A US conviction for simple marijuana possession (an amount lawful in Canada) has no Canadian equivalent offence since October 17, 2018 -- there is nothing to equate, so the conviction cannot ground criminal inadmissibility. This is the clearest 'NO' in the pairing table.",
178
+ "caveats": [
179
+ "Quantities beyond Canadian legal limits, distribution, sale, cultivation beyond limits, and import/export DO have Cannabis Act equivalents (ss. 8-14; import/export max 14 years -> serious criminality).",
180
+ "The equivalency is assessed against Canadian law at the ADMISSIBILITY DECISION date for the would-it-be-an-offence question -- post-legalization assessments of old possession convictions find no equivalent offence.",
181
+ "US felony marijuana TRAFFICKING remains serious criminality (Cannabis Act s. 9/10 or CDSA analysis)."
182
+ ],
183
+ "authorities": []
184
+ },
185
+ {
186
+ "id": "drug-trafficking",
187
+ "us_terms": [
188
+ "drug trafficking",
189
+ "possession with intent to distribute",
190
+ "PWID",
191
+ "drug distribution",
192
+ "sale of a controlled substance",
193
+ "drug dealing"
194
+ ],
195
+ "canadian_offence": "CDSA s. 5 (trafficking / possession for the purpose)",
196
+ "penalty": "Schedule I or II: indictable, maximum LIFE (s. 5(3)(a): 'liable to imprisonment for life'); Schedule III/V: hybrid, 10 years",
197
+ "branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b) for any schedule (all branches reach at least 10 years)",
198
+ "status": "guidance-only",
199
+ "analysis": "Any trafficking or possession-with-intent conviction equates to CDSA s. 5: serious criminality at every schedule level. No deemed rehabilitation; individual rehabilitation after 5 years or TRP only.",
200
+ "caveats": [
201
+ "Also engages potential organized-criminality analysis (s. 37) where a criminal organization is involved."
202
+ ],
203
+ "authorities": []
204
+ },
205
+ {
206
+ "id": "disorderly-conduct",
207
+ "us_terms": [
208
+ "disorderly conduct",
209
+ "disturbing the peace",
210
+ "public disturbance",
211
+ "breach of the peace",
212
+ "public intoxication"
213
+ ],
214
+ "canadian_offence": "Criminal Code s. 175 (causing a disturbance)",
215
+ "penalty": "SUMMARY ONLY -- s. 175 is a pure summary conviction offence",
216
+ "branch": "A SINGLE conviction grounds NO inadmissibility: s. 36(2)(b) requires an indictable-equivalent, and s. 175 is not one. TWO OR MORE convictions (not from a single occurrence) engage s. 36(2)(b)'s second branch",
217
+ "status": "guidance-only",
218
+ "analysis": "The most common harmless record: one US disorderly conduct/disturbing-the-peace conviction, equated to s. 175 (summary only), makes NOBODY inadmissible -- not even a foreign national. The two-or-more branch changes that, and IRPR s. 18(2)(b) then offers deemed rehabilitation five years after the sentences.",
219
+ "caveats": [
220
+ "Check what the conviction actually covers: some US disorderly statutes bundle assaultive or threatening conduct that would equate to hybrid offences -- the evidence branch of Hill controls where the statute is broader than s. 175.",
221
+ "Public intoxication alone typically has no federal Canadian equivalent at all (provincial regulatory matter) -- even cleaner than s. 175."
222
+ ],
223
+ "authorities": []
224
+ },
225
+ {
226
+ "id": "resisting-arrest",
227
+ "us_terms": [
228
+ "resisting arrest",
229
+ "obstruction",
230
+ "obstructing an officer",
231
+ "evading arrest",
232
+ "failure to comply with police"
233
+ ],
234
+ "canadian_offence": "Criminal Code s. 129 (resisting or obstructing a peace officer)",
235
+ "penalty": "Hybrid; indictable maximum 2 years (s. 129(d): 'not exceeding two years')",
236
+ "branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
237
+ "status": "guidance-only",
238
+ "analysis": "Non-violent resisting/obstruction equates to s. 129: hybrid, 2-year maximum -- criminality only.",
239
+ "caveats": [
240
+ "Vehicle flight equates to s. 320.17 (flight from police: hybrid, 10 years) -> SERIOUS criminality -- distinguish on-foot obstruction from vehicular flight.",
241
+ "Assaulting the officer moves it to s. 270 or s. 267 territory."
242
+ ],
243
+ "authorities": []
244
+ },
245
+ {
246
+ "id": "threats",
247
+ "us_terms": [
248
+ "criminal threatening",
249
+ "terroristic threats",
250
+ "menacing",
251
+ "harassment threats",
252
+ "intimidation"
253
+ ],
254
+ "canadian_offence": "Criminal Code s. 264.1 (uttering threats)",
255
+ "penalty": "Hybrid; indictable maximum 5 years for threats to cause death or bodily harm (s. 264.1(2)(a))",
256
+ "branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
257
+ "status": "guidance-only",
258
+ "analysis": "Threat offences equate to s. 264.1: hybrid, 5-year maximum -- criminality. Criminal harassment courses of conduct equate to s. 264 (10 years) -> serious criminality instead.",
259
+ "caveats": [
260
+ "'Terroristic threats' is a state-law label with no terrorism content -- do not confuse with IRPA s. 34 security grounds absent actual terrorism facts.",
261
+ "Repeated/stalking conduct: s. 264 criminal harassment (10 years) -> serious criminality."
262
+ ],
263
+ "authorities": []
264
+ },
265
+ {
266
+ "id": "mischief-vandalism",
267
+ "us_terms": [
268
+ "vandalism",
269
+ "criminal mischief",
270
+ "malicious mischief",
271
+ "property damage",
272
+ "graffiti"
273
+ ],
274
+ "canadian_offence": "Criminal Code s. 430 (mischief)",
275
+ "penalty": "Value over $5,000: hybrid, indictable maximum 10 years (s. 430(3)); $5,000 or under: hybrid, 2 years (s. 430(4))",
276
+ "branch": "CRIMINALITY at or under $5,000 (36(2)(b)); SERIOUS CRIMINALITY over $5,000 (36(1)(b))",
277
+ "status": "guidance-only",
278
+ "analysis": "Vandalism splits on the $5,000 damage value like the other property offences. Ordinary graffiti/vandalism records are 2-year equivalents -> criminality.",
279
+ "caveats": [
280
+ "The damage figure in the US record (restitution ordered is a good proxy) decides the branch -- convert to CAD at the offence date."
281
+ ],
282
+ "authorities": []
283
+ }
284
+ ]
285
+ }
data/eval/questions.json ADDED
@@ -0,0 +1,2042 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "How soon must the Immigration Division review the detention of a foreign national?",
4
+ "answers": [
5
+ [
6
+ "IRPA",
7
+ "57"
8
+ ]
9
+ ],
10
+ "holdout": true
11
+ },
12
+ {
13
+ "query": "On what security grounds is a foreign national inadmissible to Canada?",
14
+ "answers": [
15
+ [
16
+ "IRPA",
17
+ "34"
18
+ ]
19
+ ]
20
+ },
21
+ {
22
+ "query": "When is a permanent resident inadmissible for serious criminality?",
23
+ "answers": [
24
+ [
25
+ "IRPA",
26
+ "36"
27
+ ]
28
+ ]
29
+ },
30
+ {
31
+ "query": "What makes a person inadmissible for organized criminality?",
32
+ "answers": [
33
+ [
34
+ "IRPA",
35
+ "37"
36
+ ]
37
+ ]
38
+ },
39
+ {
40
+ "query": "Can someone be found inadmissible to Canada for misrepresentation?",
41
+ "answers": [
42
+ [
43
+ "IRPA",
44
+ "40"
45
+ ]
46
+ ]
47
+ },
48
+ {
49
+ "query": "Is a foreign national inadmissible on health grounds?",
50
+ "answers": [
51
+ [
52
+ "IRPA",
53
+ "38"
54
+ ]
55
+ ]
56
+ },
57
+ {
58
+ "query": "Inadmissibility for violating human or international rights",
59
+ "answers": [
60
+ [
61
+ "IRPA",
62
+ "35"
63
+ ]
64
+ ],
65
+ "holdout": true
66
+ },
67
+ {
68
+ "query": "Can a person be inadmissible to Canada for financial reasons?",
69
+ "answers": [
70
+ [
71
+ "IRPA",
72
+ "39"
73
+ ]
74
+ ]
75
+ },
76
+ {
77
+ "query": "Is someone inadmissible because an accompanying family member is inadmissible?",
78
+ "answers": [
79
+ [
80
+ "IRPA",
81
+ "42"
82
+ ]
83
+ ]
84
+ },
85
+ {
86
+ "query": "What humanitarian and compassionate relief can the Minister grant a foreign national?",
87
+ "answers": [
88
+ [
89
+ "IRPA",
90
+ "25"
91
+ ],
92
+ [
93
+ "IRPA",
94
+ "25.1"
95
+ ]
96
+ ]
97
+ },
98
+ {
99
+ "query": "When can an officer arrest and detain a foreign national without a warrant?",
100
+ "answers": [
101
+ [
102
+ "IRPA",
103
+ "55"
104
+ ]
105
+ ]
106
+ },
107
+ {
108
+ "query": "Who prepares a report that a permanent resident is inadmissible?",
109
+ "answers": [
110
+ [
111
+ "IRPA",
112
+ "44"
113
+ ]
114
+ ]
115
+ },
116
+ {
117
+ "query": "Must a person appear for an examination when seeking to enter Canada?",
118
+ "answers": [
119
+ [
120
+ "IRPA",
121
+ "18"
122
+ ]
123
+ ],
124
+ "holdout": true
125
+ },
126
+ {
127
+ "query": "What must a person establish to be allowed to enter Canada?",
128
+ "answers": [
129
+ [
130
+ "IRPA",
131
+ "20"
132
+ ]
133
+ ]
134
+ },
135
+ {
136
+ "query": "What is the definition of a Convention refugee?",
137
+ "answers": [
138
+ [
139
+ "IRPA",
140
+ "96"
141
+ ]
142
+ ]
143
+ },
144
+ {
145
+ "query": "Who qualifies as a person in need of protection?",
146
+ "answers": [
147
+ [
148
+ "IRPA",
149
+ "97"
150
+ ]
151
+ ]
152
+ },
153
+ {
154
+ "query": "When is a refugee claim ineligible to be referred to the Refugee Protection Division?",
155
+ "answers": [
156
+ [
157
+ "IRPA",
158
+ "101"
159
+ ]
160
+ ]
161
+ },
162
+ {
163
+ "query": "What is a pre-removal risk assessment and who can apply for one?",
164
+ "answers": [
165
+ [
166
+ "IRPA",
167
+ "112"
168
+ ]
169
+ ]
170
+ },
171
+ {
172
+ "query": "When does a removal order become enforceable?",
173
+ "answers": [
174
+ [
175
+ "IRPA",
176
+ "48"
177
+ ]
178
+ ],
179
+ "holdout": true
180
+ },
181
+ {
182
+ "query": "Is it an offence to organize the illegal entry of people into Canada?",
183
+ "answers": [
184
+ [
185
+ "IRPA",
186
+ "117"
187
+ ]
188
+ ]
189
+ },
190
+ {
191
+ "query": "Must a person report to a customs officer when arriving in Canada?",
192
+ "answers": [
193
+ [
194
+ "Customs Act",
195
+ "11"
196
+ ]
197
+ ]
198
+ },
199
+ {
200
+ "query": "What is the duty to report goods imported into Canada?",
201
+ "answers": [
202
+ [
203
+ "Customs Act",
204
+ "12"
205
+ ]
206
+ ]
207
+ },
208
+ {
209
+ "query": "Can a customs officer examine imported goods?",
210
+ "answers": [
211
+ [
212
+ "Customs Act",
213
+ "99"
214
+ ]
215
+ ]
216
+ },
217
+ {
218
+ "query": "When can a customs officer search a person at the border?",
219
+ "answers": [
220
+ [
221
+ "Customs Act",
222
+ "98"
223
+ ]
224
+ ]
225
+ },
226
+ {
227
+ "query": "What happens when goods are seized for a customs contravention?",
228
+ "answers": [
229
+ [
230
+ "Customs Act",
231
+ "110"
232
+ ]
233
+ ],
234
+ "holdout": true
235
+ },
236
+ {
237
+ "query": "What is ascertained forfeiture under the Customs Act?",
238
+ "answers": [
239
+ [
240
+ "Customs Act",
241
+ "124"
242
+ ]
243
+ ]
244
+ },
245
+ {
246
+ "query": "When do imported goods become forfeit after a customs contravention?",
247
+ "answers": [
248
+ [
249
+ "Customs Act",
250
+ "122"
251
+ ]
252
+ ]
253
+ },
254
+ {
255
+ "query": "How can a person appeal a customs seizure or penalty decision to the Federal Court?",
256
+ "answers": [
257
+ [
258
+ "Customs Act",
259
+ "135"
260
+ ]
261
+ ]
262
+ },
263
+ {
264
+ "query": "Advance information about commercial goods before they arrive in Canada",
265
+ "answers": [
266
+ [
267
+ "Customs Act",
268
+ "12.1"
269
+ ]
270
+ ]
271
+ },
272
+ {
273
+ "query": "How is the value for duty of imported goods determined?",
274
+ "answers": [
275
+ [
276
+ "Customs Act",
277
+ "46"
278
+ ],
279
+ [
280
+ "Customs Act",
281
+ "47"
282
+ ],
283
+ [
284
+ "Customs Act",
285
+ "48"
286
+ ]
287
+ ]
288
+ },
289
+ {
290
+ "query": "How are imported goods classified under the Customs Tariff?",
291
+ "answers": [
292
+ [
293
+ "Customs Tariff",
294
+ "10"
295
+ ]
296
+ ],
297
+ "holdout": true
298
+ },
299
+ {
300
+ "query": "Which tariff heading in the Customs Tariff Schedule covers a settler's effects on first arrival?",
301
+ "answers": [
302
+ [
303
+ "Customs Tariff",
304
+ "Sch-98.07"
305
+ ]
306
+ ]
307
+ },
308
+ {
309
+ "query": "What allowance of alcohol and tobacco may a traveller bring back to Canada duty free?",
310
+ "answers": [
311
+ [
312
+ "Customs Tariff",
313
+ "Sch-98.27"
314
+ ]
315
+ ]
316
+ },
317
+ {
318
+ "query": "Conveyance temporarily imported by a non-resident visiting Canada -- customs tariff treatment",
319
+ "answers": [
320
+ [
321
+ "Customs Tariff",
322
+ "Sch-98.03"
323
+ ]
324
+ ]
325
+ },
326
+ {
327
+ "query": "Tariff classification of prohibited firearms and weapons under the Customs Tariff",
328
+ "answers": [
329
+ [
330
+ "Customs Tariff",
331
+ "Sch-98.98"
332
+ ]
333
+ ]
334
+ },
335
+ {
336
+ "query": "Tariff treatment of articles imported for personal use of representatives of foreign countries",
337
+ "answers": [
338
+ [
339
+ "Customs Tariff",
340
+ "Sch-98.08"
341
+ ]
342
+ ]
343
+ },
344
+ {
345
+ "query": "Must travellers report large amounts of currency when crossing the border?",
346
+ "answers": [
347
+ [
348
+ "PCMLTFA",
349
+ "12"
350
+ ]
351
+ ],
352
+ "holdout": true
353
+ },
354
+ {
355
+ "query": "Can an officer seize currency that was not reported at the border?",
356
+ "answers": [
357
+ [
358
+ "PCMLTFA",
359
+ "18"
360
+ ]
361
+ ]
362
+ },
363
+ {
364
+ "query": "How does someone appeal the forfeiture of seized currency to the Federal Court?",
365
+ "answers": [
366
+ [
367
+ "PCMLTFA",
368
+ "30"
369
+ ]
370
+ ]
371
+ },
372
+ {
373
+ "query": "Is simple possession of a controlled substance an offence?",
374
+ "answers": [
375
+ [
376
+ "CDSA",
377
+ "4"
378
+ ]
379
+ ]
380
+ },
381
+ {
382
+ "query": "What is the offence of trafficking in a controlled substance?",
383
+ "answers": [
384
+ [
385
+ "CDSA",
386
+ "5"
387
+ ]
388
+ ]
389
+ },
390
+ {
391
+ "query": "Is it an offence to import or export a controlled substance?",
392
+ "answers": [
393
+ [
394
+ "CDSA",
395
+ "6"
396
+ ]
397
+ ]
398
+ },
399
+ {
400
+ "query": "Is possession of cannabis an offence?",
401
+ "answers": [
402
+ [
403
+ "Cannabis Act",
404
+ "8"
405
+ ]
406
+ ],
407
+ "holdout": true
408
+ },
409
+ {
410
+ "query": "Can cannabis be imported into or exported from Canada?",
411
+ "answers": [
412
+ [
413
+ "Cannabis Act",
414
+ "11"
415
+ ]
416
+ ]
417
+ },
418
+ {
419
+ "query": "When can a peace officer arrest a person without a warrant?",
420
+ "answers": [
421
+ [
422
+ "Criminal Code",
423
+ "495"
424
+ ]
425
+ ]
426
+ },
427
+ {
428
+ "query": "What right does a person have to access their own personal information held by a government institution?",
429
+ "answers": [
430
+ [
431
+ "Privacy Act",
432
+ "12"
433
+ ]
434
+ ]
435
+ },
436
+ {
437
+ "query": "When may a government institution disclose someone's personal information?",
438
+ "answers": [
439
+ [
440
+ "Privacy Act",
441
+ "8"
442
+ ]
443
+ ]
444
+ },
445
+ {
446
+ "query": "Can an employee refuse to do work that presents a danger?",
447
+ "answers": [
448
+ [
449
+ "Canada Labour Code",
450
+ "128"
451
+ ]
452
+ ]
453
+ },
454
+ {
455
+ "query": "What are the standard hours of work for an employee?",
456
+ "answers": [
457
+ [
458
+ "Canada Labour Code",
459
+ "169"
460
+ ]
461
+ ],
462
+ "holdout": true
463
+ },
464
+ {
465
+ "query": "What is the standard of review of an administrative decision on judicial review?",
466
+ "answers": [
467
+ [
468
+ "Vavilov",
469
+ ""
470
+ ]
471
+ ]
472
+ },
473
+ {
474
+ "query": "How does the Refugee Appeal Division review a decision of the Refugee Protection Division?",
475
+ "answers": [
476
+ [
477
+ "Huruglica",
478
+ ""
479
+ ]
480
+ ]
481
+ },
482
+ {
483
+ "query": "To get back currency seized at the border, what must the claimant show about the money?",
484
+ "answers": [
485
+ [
486
+ "Sellathurai",
487
+ ""
488
+ ]
489
+ ]
490
+ },
491
+ {
492
+ "query": "What factors shape the content of the duty of procedural fairness in an administrative decision?",
493
+ "answers": [
494
+ [
495
+ "Baker",
496
+ ""
497
+ ]
498
+ ]
499
+ },
500
+ {
501
+ "query": "Can Canada remove a person to a country where they face a substantial risk of torture?",
502
+ "answers": [
503
+ [
504
+ "Suresh",
505
+ ""
506
+ ]
507
+ ]
508
+ },
509
+ {
510
+ "query": "Is the security certificate regime for detaining and removing non-citizens consistent with the Charter?",
511
+ "answers": [
512
+ [
513
+ "Charkaoui",
514
+ ""
515
+ ]
516
+ ],
517
+ "holdout": true
518
+ },
519
+ {
520
+ "query": "Do refugee claimants have a right to an oral hearing under the Charter?",
521
+ "answers": [
522
+ [
523
+ "Singh",
524
+ ""
525
+ ]
526
+ ]
527
+ },
528
+ {
529
+ "query": "What is a particular social group in the definition of a Convention refugee?",
530
+ "answers": [
531
+ [
532
+ "Ward",
533
+ ""
534
+ ]
535
+ ]
536
+ },
537
+ {
538
+ "query": "When is a person excluded from refugee protection for acts contrary to the purposes of the United Nations?",
539
+ "answers": [
540
+ [
541
+ "Pushpanathan",
542
+ ""
543
+ ]
544
+ ]
545
+ },
546
+ {
547
+ "query": "What degree of involvement makes a person complicit in international crimes and excluded from refugee protection?",
548
+ "answers": [
549
+ [
550
+ "Ezokola",
551
+ ""
552
+ ]
553
+ ]
554
+ },
555
+ {
556
+ "query": "Can a person be denied refugee protection for a serious crime committed abroad before claiming asylum?",
557
+ "answers": [
558
+ [
559
+ "Febles",
560
+ ""
561
+ ]
562
+ ]
563
+ },
564
+ {
565
+ "query": "How must a decision-maker weigh the best interests of a child in a humanitarian and compassionate application?",
566
+ "answers": [
567
+ [
568
+ "Kanthasamy",
569
+ ""
570
+ ]
571
+ ],
572
+ "holdout": true
573
+ },
574
+ {
575
+ "query": "What does the national interest mean when the Minister grants relief from security inadmissibility?",
576
+ "answers": [
577
+ [
578
+ "Agraira",
579
+ ""
580
+ ]
581
+ ]
582
+ },
583
+ {
584
+ "query": "Does helping fellow asylum seekers enter a country illegally amount to people smuggling for inadmissibility?",
585
+ "answers": [
586
+ [
587
+ "B010",
588
+ ""
589
+ ]
590
+ ]
591
+ },
592
+ {
593
+ "query": "Is the offence of human smuggling unconstitutionally overbroad if it captures humanitarian aid?",
594
+ "answers": [
595
+ [
596
+ "Appulonappa",
597
+ ""
598
+ ]
599
+ ]
600
+ },
601
+ {
602
+ "query": "Does a conditional sentence count as a term of imprisonment for serious criminality?",
603
+ "answers": [
604
+ [
605
+ "Tran",
606
+ ""
607
+ ]
608
+ ]
609
+ },
610
+ {
611
+ "query": "Can an immigration detainee challenge their detention through habeas corpus?",
612
+ "answers": [
613
+ [
614
+ "Chhina",
615
+ ""
616
+ ]
617
+ ]
618
+ },
619
+ {
620
+ "query": "What kinds of border search engage the Charter protection against unreasonable search and seizure?",
621
+ "answers": [
622
+ [
623
+ "Simmons",
624
+ ""
625
+ ]
626
+ ],
627
+ "holdout": true
628
+ },
629
+ {
630
+ "query": "Does inadmissibility for membership in a terrorist organization require a complicity analysis?",
631
+ "answers": [
632
+ [
633
+ "Kanagendren",
634
+ ""
635
+ ]
636
+ ]
637
+ },
638
+ {
639
+ "query": "How broadly is a criminal organization interpreted for organized criminality inadmissibility?",
640
+ "answers": [
641
+ [
642
+ "Sittampalam",
643
+ ""
644
+ ]
645
+ ]
646
+ },
647
+ {
648
+ "query": "What principles govern a finding of inadmissibility for misrepresentation?",
649
+ "answers": [
650
+ [
651
+ "Goburdhun",
652
+ ""
653
+ ]
654
+ ]
655
+ },
656
+ {
657
+ "query": "What is the innocent-misrepresentation defence to inadmissibility under IRPA s. 40?",
658
+ "answers": [
659
+ [
660
+ "Wang",
661
+ ""
662
+ ]
663
+ ]
664
+ },
665
+ {
666
+ "query": "Must a misrepresentation under IRPA s. 40 be intentional, deliberate or negligent to be material?",
667
+ "answers": [
668
+ [
669
+ "Bellido",
670
+ ""
671
+ ]
672
+ ]
673
+ },
674
+ {
675
+ "query": "At an immigration detention review, who bears the onus and how are earlier detention rulings treated?",
676
+ "answers": [
677
+ [
678
+ "Thanabalasingham",
679
+ ""
680
+ ]
681
+ ],
682
+ "holdout": true
683
+ },
684
+ {
685
+ "query": "What is the test for admitting new evidence in a pre-removal risk assessment?",
686
+ "answers": [
687
+ [
688
+ "Raza",
689
+ ""
690
+ ]
691
+ ]
692
+ },
693
+ {
694
+ "query": "Are gold coins currency or monetary instruments that must be reported when imported?",
695
+ "answers": [
696
+ [
697
+ "Hociung",
698
+ ""
699
+ ]
700
+ ]
701
+ },
702
+ {
703
+ "query": "How do the courts review a customs tariff classification decision?",
704
+ "answers": [
705
+ [
706
+ "Best Buy",
707
+ ""
708
+ ]
709
+ ]
710
+ },
711
+ {
712
+ "query": "What does CBSA policy say about how the value for duty of imported goods is established?",
713
+ "answers": [
714
+ [
715
+ "D-Memo",
716
+ "D13-1-1"
717
+ ]
718
+ ]
719
+ },
720
+ {
721
+ "query": "What are CBSA's requirements for marking imported goods with their country of origin?",
722
+ "answers": [
723
+ [
724
+ "D-Memo",
725
+ "D11-3-1"
726
+ ]
727
+ ]
728
+ },
729
+ {
730
+ "query": "What is CBSA's guidance on importing or exporting cannabis and controlled substances?",
731
+ "answers": [
732
+ [
733
+ "D-Memo",
734
+ "D19-9-2"
735
+ ]
736
+ ],
737
+ "holdout": true
738
+ },
739
+ {
740
+ "query": "What personal exemptions can a resident claim when returning to Canada, per CBSA guidance?",
741
+ "answers": [
742
+ [
743
+ "D-Memo",
744
+ "D2-3-1"
745
+ ]
746
+ ]
747
+ },
748
+ {
749
+ "query": "What is CBSA's guidance on cross-border currency and monetary instruments reporting?",
750
+ "answers": [
751
+ [
752
+ "D-Memo",
753
+ "D19-14-1"
754
+ ]
755
+ ]
756
+ },
757
+ {
758
+ "query": "How does CBSA decide whether imported material is obscene?",
759
+ "answers": [
760
+ [
761
+ "D-Memo",
762
+ "D9-1-1"
763
+ ]
764
+ ]
765
+ },
766
+ {
767
+ "query": "What proof of origin does CBSA require for imported goods?",
768
+ "answers": [
769
+ [
770
+ "D-Memo",
771
+ "D11-4-2"
772
+ ]
773
+ ]
774
+ },
775
+ {
776
+ "query": "What is the Canadian Goods Abroad Program for goods sent outside Canada for repair?",
777
+ "answers": [
778
+ [
779
+ "D-Memo",
780
+ "D8-2-1"
781
+ ]
782
+ ]
783
+ },
784
+ {
785
+ "query": "How does the FB Border Services collective agreement deal with discipline of an employee?",
786
+ "answers": [
787
+ [
788
+ "FB Agreement",
789
+ "17"
790
+ ]
791
+ ],
792
+ "holdout": true
793
+ },
794
+ {
795
+ "query": "What is the grievance procedure under the FB collective agreement?",
796
+ "answers": [
797
+ [
798
+ "FB Agreement",
799
+ "18"
800
+ ]
801
+ ]
802
+ },
803
+ {
804
+ "query": "What are the hours of work under the FB Border Services collective agreement?",
805
+ "answers": [
806
+ [
807
+ "FB Agreement",
808
+ "25"
809
+ ]
810
+ ]
811
+ },
812
+ {
813
+ "query": "How is overtime compensated under the FB collective agreement?",
814
+ "answers": [
815
+ [
816
+ "FB Agreement",
817
+ "28"
818
+ ]
819
+ ]
820
+ },
821
+ {
822
+ "query": "How much vacation leave with pay do FB-group employees earn?",
823
+ "answers": [
824
+ [
825
+ "FB Agreement",
826
+ "34"
827
+ ]
828
+ ]
829
+ },
830
+ {
831
+ "query": "What is the bilingualism bonus and who is eligible to receive it?",
832
+ "answers": [
833
+ [
834
+ "Bilingualism Bonus Directive",
835
+ ""
836
+ ]
837
+ ]
838
+ },
839
+ {
840
+ "query": "What assistance is available to a federal employee who faces unusual daily commuting costs?",
841
+ "answers": [
842
+ [
843
+ "Commuting Assistance Directive",
844
+ ""
845
+ ]
846
+ ],
847
+ "holdout": true
848
+ },
849
+ {
850
+ "query": "When may the Immigration Division order the release of a detained person?",
851
+ "answers": [
852
+ [
853
+ "IRPA",
854
+ "58"
855
+ ]
856
+ ]
857
+ },
858
+ {
859
+ "query": "When is there no right to appeal a removal order to the Immigration Appeal Division?",
860
+ "answers": [
861
+ [
862
+ "IRPA",
863
+ "64"
864
+ ]
865
+ ]
866
+ },
867
+ {
868
+ "query": "Is distributing cannabis an offence?",
869
+ "answers": [
870
+ [
871
+ "Cannabis Act",
872
+ "9"
873
+ ]
874
+ ]
875
+ },
876
+ {
877
+ "query": "What is the offence of smuggling goods into Canada under the Customs Act?",
878
+ "answers": [
879
+ [
880
+ "Customs Act",
881
+ "159"
882
+ ]
883
+ ]
884
+ },
885
+ {
886
+ "query": "Is an employee entitled to medical leave under the Canada Labour Code?",
887
+ "answers": [
888
+ [
889
+ "Canada Labour Code",
890
+ "239"
891
+ ]
892
+ ]
893
+ },
894
+ {
895
+ "query": "How should an employer apply progressive discipline to a border services officer?",
896
+ "answers": [
897
+ [
898
+ "Menzies",
899
+ ""
900
+ ]
901
+ ],
902
+ "holdout": true
903
+ },
904
+ {
905
+ "query": "What is the burden and standard of proof when an employer defends a disciplinary grievance?",
906
+ "answers": [
907
+ [
908
+ "Basra (2007)",
909
+ ""
910
+ ]
911
+ ]
912
+ },
913
+ {
914
+ "query": "What factors determine whether an employer had just cause to discipline or terminate an employee?",
915
+ "answers": [
916
+ [
917
+ "Pepper",
918
+ ""
919
+ ]
920
+ ]
921
+ },
922
+ {
923
+ "query": "What is the test for whether a union breached its duty of fair representation?",
924
+ "answers": [
925
+ [
926
+ "McRaeJackson",
927
+ ""
928
+ ]
929
+ ]
930
+ },
931
+ {
932
+ "query": "Can a union breach the duty of fair representation in how it handles a vaccination-policy grievance?",
933
+ "answers": [
934
+ [
935
+ "Watson",
936
+ ""
937
+ ]
938
+ ]
939
+ },
940
+ {
941
+ "query": "Is it consistent with the Charter of Rights to deport a permanent resident convicted of serious crimes?",
942
+ "answers": [
943
+ [
944
+ "Chiarelli",
945
+ ""
946
+ ]
947
+ ]
948
+ },
949
+ {
950
+ "query": "When the Immigration Appeal Division hears an appeal from a removal order, may it weigh the hardship the person would face in the country of removal?",
951
+ "answers": [
952
+ [
953
+ "Chieu",
954
+ ""
955
+ ]
956
+ ],
957
+ "holdout": true
958
+ },
959
+ {
960
+ "query": "Does a non-citizen have a Charter right to remain in Canada?",
961
+ "answers": [
962
+ [
963
+ "Medovarski",
964
+ ""
965
+ ]
966
+ ]
967
+ },
968
+ {
969
+ "query": "What must be established for inadmissibility for incitement to genocide or a crime against humanity?",
970
+ "answers": [
971
+ [
972
+ "Mugesera",
973
+ ""
974
+ ]
975
+ ]
976
+ },
977
+ {
978
+ "query": "Is the IRPA security-certificate regime, with its special advocate scheme, consistent with the Charter?",
979
+ "answers": [
980
+ [
981
+ "Harkat",
982
+ ""
983
+ ]
984
+ ]
985
+ },
986
+ {
987
+ "query": "How does the principle of non-refoulement constrain Canada when it extradites a person who holds refugee protection?",
988
+ "answers": [
989
+ [
990
+ "Németh",
991
+ ""
992
+ ]
993
+ ]
994
+ },
995
+ {
996
+ "query": "What procedural fairness is owed to a sponsor before the government enforces a sponsorship-undertaking debt?",
997
+ "answers": [
998
+ [
999
+ "Mavi",
1000
+ ""
1001
+ ]
1002
+ ]
1003
+ },
1004
+ {
1005
+ "query": "Can a sentencing court take the immigration consequences of a sentence into account?",
1006
+ "answers": [
1007
+ [
1008
+ "Pham",
1009
+ ""
1010
+ ]
1011
+ ],
1012
+ "holdout": true
1013
+ },
1014
+ {
1015
+ "query": "Can a fear of coerced sterilization under a one-child population-control policy support a refugee claim?",
1016
+ "answers": [
1017
+ [
1018
+ "Chan",
1019
+ ""
1020
+ ]
1021
+ ]
1022
+ },
1023
+ {
1024
+ "query": "Can a customs officer stop and search a vehicle near the border on reasonable suspicion of a customs offence?",
1025
+ "answers": [
1026
+ [
1027
+ "Jacques",
1028
+ ""
1029
+ ]
1030
+ ]
1031
+ },
1032
+ {
1033
+ "query": "Is an ascertained-forfeiture proceeding under the Customs Act penal enough to engage the right against self-incrimination?",
1034
+ "answers": [
1035
+ [
1036
+ "Martineau",
1037
+ ""
1038
+ ]
1039
+ ]
1040
+ },
1041
+ {
1042
+ "query": "Does a person's age at the time of involvement matter to inadmissibility for membership in a terrorist organization?",
1043
+ "answers": [
1044
+ [
1045
+ "Poshteh",
1046
+ ""
1047
+ ]
1048
+ ]
1049
+ },
1050
+ {
1051
+ "query": "Can a deserter from the United States military succeed in a refugee or humanitarian and compassionate claim in Canada?",
1052
+ "answers": [
1053
+ [
1054
+ "Hinzman",
1055
+ ""
1056
+ ]
1057
+ ]
1058
+ },
1059
+ {
1060
+ "query": "Does a Refugee Protection Division guideline that sets the order of questioning improperly fetter a member's discretion?",
1061
+ "answers": [
1062
+ [
1063
+ "Thamotharem",
1064
+ ""
1065
+ ]
1066
+ ],
1067
+ "holdout": true
1068
+ },
1069
+ {
1070
+ "query": "Can a person with no immigration status obtain coverage under the Interim Federal Health Program?",
1071
+ "answers": [
1072
+ [
1073
+ "Toussaint",
1074
+ ""
1075
+ ]
1076
+ ]
1077
+ },
1078
+ {
1079
+ "query": "What does a finding of no credible basis mean for a refugee claim?",
1080
+ "answers": [
1081
+ [
1082
+ "Rahaman",
1083
+ ""
1084
+ ]
1085
+ ]
1086
+ },
1087
+ {
1088
+ "query": "How is the reasonableness of a security certificate assessed on a re-determination after Charkaoui?",
1089
+ "answers": [
1090
+ [
1091
+ "Almrei",
1092
+ ""
1093
+ ]
1094
+ ]
1095
+ },
1096
+ {
1097
+ "query": "Can a permanent resident be found inadmissible for acts of violence that endanger safety in Canada even without a conviction?",
1098
+ "answers": [
1099
+ [
1100
+ "Mason",
1101
+ ""
1102
+ ]
1103
+ ]
1104
+ },
1105
+ {
1106
+ "query": "How much deference does the Immigration Appeal Division receive when it declines to grant discretionary relief from removal?",
1107
+ "answers": [
1108
+ [
1109
+ "Khosa",
1110
+ ""
1111
+ ]
1112
+ ]
1113
+ },
1114
+ {
1115
+ "query": "Is lengthy immigration detention consistent with the Charter protection against arbitrary detention?",
1116
+ "answers": [
1117
+ [
1118
+ "Brown",
1119
+ ""
1120
+ ]
1121
+ ],
1122
+ "holdout": true
1123
+ },
1124
+ {
1125
+ "query": "Is the designation of the United States as a safe third country for refugee claimants constitutional?",
1126
+ "answers": [
1127
+ [
1128
+ "CCRf",
1129
+ ""
1130
+ ]
1131
+ ]
1132
+ },
1133
+ {
1134
+ "query": "Can an employee with an addiction be dismissed for breach of a drug and alcohol policy in a safety-sensitive workplace?",
1135
+ "answers": [
1136
+ [
1137
+ "Elk Valley",
1138
+ ""
1139
+ ]
1140
+ ]
1141
+ },
1142
+ {
1143
+ "query": "When is random alcohol testing permitted in a unionized safety-sensitive workplace?",
1144
+ "answers": [
1145
+ [
1146
+ "Irving",
1147
+ ""
1148
+ ]
1149
+ ]
1150
+ },
1151
+ {
1152
+ "query": "Do RCMP members have a Charter right to collective bargaining?",
1153
+ "answers": [
1154
+ [
1155
+ "MPAO",
1156
+ ""
1157
+ ]
1158
+ ]
1159
+ },
1160
+ {
1161
+ "query": "Is the right to strike protected by the Charter?",
1162
+ "answers": [
1163
+ [
1164
+ "SFL",
1165
+ ""
1166
+ ]
1167
+ ]
1168
+ },
1169
+ {
1170
+ "query": "What meal and incidental allowances can a federal employee claim while travelling on government business?",
1171
+ "answers": [
1172
+ [
1173
+ "Travel Directive",
1174
+ ""
1175
+ ]
1176
+ ],
1177
+ "holdout": true
1178
+ },
1179
+ {
1180
+ "query": "What is a federal employee entitled to when the employer requires them to wear a uniform?",
1181
+ "answers": [
1182
+ [
1183
+ "Uniforms Directive",
1184
+ ""
1185
+ ]
1186
+ ]
1187
+ },
1188
+ {
1189
+ "query": "What protections does an indeterminate employee have when their position is declared surplus?",
1190
+ "answers": [
1191
+ [
1192
+ "Work Force Adjustment Directive",
1193
+ ""
1194
+ ]
1195
+ ]
1196
+ },
1197
+ {
1198
+ "query": "Is there an allowance for an employee required to administer first aid to the general public?",
1199
+ "answers": [
1200
+ [
1201
+ "First Aid to the General Public - Allowance for Employees",
1202
+ ""
1203
+ ]
1204
+ ]
1205
+ },
1206
+ {
1207
+ "query": "What allowances are available to a federal employee posted at an isolated post?",
1208
+ "answers": [
1209
+ [
1210
+ "Isolated Posts and Government Housing Directive",
1211
+ ""
1212
+ ]
1213
+ ]
1214
+ },
1215
+ {
1216
+ "query": "What relocation expenses are reimbursed when a federal employee must move for work?",
1217
+ "answers": [
1218
+ [
1219
+ "NJC Relocation Directive",
1220
+ ""
1221
+ ]
1222
+ ]
1223
+ },
1224
+ {
1225
+ "query": "What occupational health and safety obligations does the NJC directive place on the employer?",
1226
+ "answers": [
1227
+ [
1228
+ "Occupational Health and Safety Directive",
1229
+ ""
1230
+ ]
1231
+ ],
1232
+ "holdout": true
1233
+ },
1234
+ {
1235
+ "query": "What coverage does the Public Service Health Care Plan provide?",
1236
+ "answers": [
1237
+ [
1238
+ "Public Service Health Care Plan Directive",
1239
+ ""
1240
+ ],
1241
+ [
1242
+ "PSHCP",
1243
+ ""
1244
+ ]
1245
+ ]
1246
+ },
1247
+ {
1248
+ "query": "What does the PSHCP member booklet say about prescription drug coverage?",
1249
+ "answers": [
1250
+ [
1251
+ "PSHCP",
1252
+ ""
1253
+ ]
1254
+ ]
1255
+ },
1256
+ {
1257
+ "query": "What dental services are covered under the Public Service Dental Care Plan for employees?",
1258
+ "answers": [
1259
+ [
1260
+ "PSDCP",
1261
+ ""
1262
+ ]
1263
+ ]
1264
+ },
1265
+ {
1266
+ "query": "What dental coverage do federal pensioners get under the Pensioners' Dental Services Plan?",
1267
+ "answers": [
1268
+ [
1269
+ "PDSP",
1270
+ ""
1271
+ ]
1272
+ ]
1273
+ },
1274
+ {
1275
+ "query": "What life and disability insurance does the Public Service Management Insurance Plan provide for excluded employees?",
1276
+ "answers": [
1277
+ [
1278
+ "PSMIP",
1279
+ ""
1280
+ ]
1281
+ ]
1282
+ },
1283
+ {
1284
+ "query": "What support do the Foreign Service Directives give an employee posted outside Canada?",
1285
+ "answers": [
1286
+ [
1287
+ "Foreign Service Directives",
1288
+ ""
1289
+ ]
1290
+ ],
1291
+ "holdout": true
1292
+ },
1293
+ {
1294
+ "query": "Who has a right to appeal a decision to the Immigration Appeal Division?",
1295
+ "answers": [
1296
+ [
1297
+ "IRPA",
1298
+ "63"
1299
+ ]
1300
+ ]
1301
+ },
1302
+ {
1303
+ "query": "What are the objectives of the Immigration and Refugee Protection Act?",
1304
+ "answers": [
1305
+ [
1306
+ "IRPA",
1307
+ "3"
1308
+ ]
1309
+ ]
1310
+ },
1311
+ {
1312
+ "query": "Can a non-unionized federally regulated employee complain that they were unjustly dismissed?",
1313
+ "answers": [
1314
+ [
1315
+ "Canada Labour Code",
1316
+ "240"
1317
+ ]
1318
+ ]
1319
+ },
1320
+ {
1321
+ "query": "What does CBSA disclose about normal values and export prices under the Special Import Measures Act?",
1322
+ "answers": [
1323
+ [
1324
+ "D-Memo",
1325
+ "D14-1-2"
1326
+ ]
1327
+ ]
1328
+ },
1329
+ {
1330
+ "query": "What are CBSA's requirements for importing or exporting cultural property?",
1331
+ "answers": [
1332
+ [
1333
+ "D-Memo",
1334
+ "D19-4-1"
1335
+ ]
1336
+ ]
1337
+ },
1338
+ {
1339
+ "query": "When assessing criminal inadmissibility for a conviction abroad, must a decision-maker consider whether the defence of duress was practically available in that country?",
1340
+ "answers": [
1341
+ [
1342
+ "Rodriguez Anzola",
1343
+ ""
1344
+ ]
1345
+ ],
1346
+ "holdout": true
1347
+ },
1348
+ {
1349
+ "query": "What is the mandate of the Canada Border Services Agency?",
1350
+ "answers": [
1351
+ [
1352
+ "CBSA Act",
1353
+ ""
1354
+ ]
1355
+ ]
1356
+ },
1357
+ {
1358
+ "query": "How is a grievance referred to adjudication in the federal public service?",
1359
+ "answers": [
1360
+ [
1361
+ "FPSLRA",
1362
+ ""
1363
+ ]
1364
+ ]
1365
+ },
1366
+ {
1367
+ "query": "What is the Federal Public Sector Labour Relations and Employment Board?",
1368
+ "answers": [
1369
+ [
1370
+ "FPSLREB Act",
1371
+ ""
1372
+ ],
1373
+ [
1374
+ "FPSLRA",
1375
+ ""
1376
+ ]
1377
+ ]
1378
+ },
1379
+ {
1380
+ "query": "What are the requirements to become a Canadian citizen by grant?",
1381
+ "answers": [
1382
+ [
1383
+ "Citizenship Act",
1384
+ ""
1385
+ ]
1386
+ ]
1387
+ },
1388
+ {
1389
+ "query": "How are dumping and subsidizing of imported goods investigated and what duties apply?",
1390
+ "answers": [
1391
+ [
1392
+ "SIMA",
1393
+ ""
1394
+ ]
1395
+ ]
1396
+ },
1397
+ {
1398
+ "query": "When is a permit required to export goods on the Export Control List?",
1399
+ "answers": [
1400
+ [
1401
+ "EIPA",
1402
+ ""
1403
+ ]
1404
+ ],
1405
+ "holdout": true
1406
+ },
1407
+ {
1408
+ "query": "What authorization is required to import a firearm into Canada?",
1409
+ "answers": [
1410
+ [
1411
+ "Firearms Act",
1412
+ ""
1413
+ ]
1414
+ ]
1415
+ },
1416
+ {
1417
+ "query": "What are the procedures for an admissibility hearing before the Immigration Division?",
1418
+ "answers": [
1419
+ [
1420
+ "Immigration Division Rules",
1421
+ ""
1422
+ ],
1423
+ [
1424
+ "ENF 3",
1425
+ ""
1426
+ ]
1427
+ ]
1428
+ },
1429
+ {
1430
+ "query": "What rules govern the hearing of a refugee protection claim before the RPD?",
1431
+ "answers": [
1432
+ [
1433
+ "RPD Rules",
1434
+ ""
1435
+ ]
1436
+ ]
1437
+ },
1438
+ {
1439
+ "query": "What are the Refugee Appeal Division Rules for filing and perfecting an appeal?",
1440
+ "answers": [
1441
+ [
1442
+ "RAD Rules",
1443
+ ""
1444
+ ]
1445
+ ]
1446
+ },
1447
+ {
1448
+ "query": "What is the right to withdraw from a preclearance area, and what powers does a US preclearance officer have in Canada?",
1449
+ "answers": [
1450
+ [
1451
+ "Preclearance Act",
1452
+ ""
1453
+ ]
1454
+ ]
1455
+ },
1456
+ {
1457
+ "query": "What does WAPPRIITA prohibit for international trade in protected wild animals and plants?",
1458
+ "answers": [
1459
+ [
1460
+ "WAPPRIITA",
1461
+ ""
1462
+ ]
1463
+ ],
1464
+ "holdout": true
1465
+ },
1466
+ {
1467
+ "query": "How must imported goods be reported to a customs officer?",
1468
+ "answers": [
1469
+ [
1470
+ "Reporting of Imported Goods Regs",
1471
+ ""
1472
+ ],
1473
+ [
1474
+ "Customs Act",
1475
+ "12"
1476
+ ]
1477
+ ]
1478
+ },
1479
+ {
1480
+ "query": "Returning Persons Exemption Regulations prescribed value of goods for a returning resident",
1481
+ "answers": [
1482
+ [
1483
+ "Returning Persons Exemption Regs",
1484
+ ""
1485
+ ],
1486
+ [
1487
+ "D-Memo",
1488
+ "D2-3-1"
1489
+ ]
1490
+ ]
1491
+ },
1492
+ {
1493
+ "query": "What tariff item covers goods acquired abroad by a returning resident of Canada for personal use?",
1494
+ "answers": [
1495
+ [
1496
+ "Customs Tariff",
1497
+ "Sch-98.04"
1498
+ ]
1499
+ ]
1500
+ },
1501
+ {
1502
+ "query": "Under what tariff heading are a settler's household effects imported free of duty?",
1503
+ "answers": [
1504
+ [
1505
+ "Customs Tariff",
1506
+ "Sch-98.07"
1507
+ ]
1508
+ ]
1509
+ },
1510
+ {
1511
+ "query": "Can goods classified in Chapter 99 also qualify under Chapter 98 of the tariff schedule?",
1512
+ "answers": [
1513
+ [
1514
+ "Customs Tariff",
1515
+ "Sch-Ch98-Notes"
1516
+ ],
1517
+ [
1518
+ "Customs Tariff",
1519
+ "Sch-Ch99-Notes"
1520
+ ]
1521
+ ]
1522
+ },
1523
+ {
1524
+ "query": "What does the public service health care plan cover for out-of-province emergency travel?",
1525
+ "answers": [
1526
+ [
1527
+ "PSHCP",
1528
+ ""
1529
+ ]
1530
+ ],
1531
+ "holdout": true
1532
+ },
1533
+ {
1534
+ "query": "Can border officers search a traveller's cell phone without any grounds under the Customs Act?",
1535
+ "answers": [
1536
+ [
1537
+ "Canfield",
1538
+ ""
1539
+ ],
1540
+ [
1541
+ "Pike",
1542
+ ""
1543
+ ]
1544
+ ]
1545
+ },
1546
+ {
1547
+ "query": "What limits did the Supreme Court place on searching a cell phone incident to arrest?",
1548
+ "answers": [
1549
+ [
1550
+ "Fearon",
1551
+ ""
1552
+ ]
1553
+ ]
1554
+ },
1555
+ {
1556
+ "query": "What did the Ontario Court of Appeal decide about suspicionless searches of personal electronic devices at the border?",
1557
+ "answers": [
1558
+ [
1559
+ "Pike",
1560
+ ""
1561
+ ]
1562
+ ]
1563
+ },
1564
+ {
1565
+ "query": "How does the RAD jurisprudential guide interpret the generalized risk exception for gang extortion claims?",
1566
+ "answers": [
1567
+ [
1568
+ "TC1-05038",
1569
+ ""
1570
+ ]
1571
+ ]
1572
+ },
1573
+ {
1574
+ "query": "Which CBSA officers are designated to form an inadmissibility report under IRPA section 44?",
1575
+ "answers": [
1576
+ [
1577
+ "CBSA Deleg 2023-05-08",
1578
+ ""
1579
+ ],
1580
+ [
1581
+ "CBSA Deleg Amend 2025-07-10",
1582
+ ""
1583
+ ],
1584
+ [
1585
+ "CBSA Deleg Amend 2023-09-08",
1586
+ ""
1587
+ ]
1588
+ ]
1589
+ },
1590
+ {
1591
+ "query": "Is a Texas deferred adjudication a conviction for Canadian immigration purposes?",
1592
+ "answers": [
1593
+ [
1594
+ "US Dispositions Helper",
1595
+ "deferred-adjudication"
1596
+ ],
1597
+ [
1598
+ "US Dispositions Helper",
1599
+ "state-texas"
1600
+ ]
1601
+ ],
1602
+ "holdout": true
1603
+ },
1604
+ {
1605
+ "query": "Does a Florida withheld adjudication make someone inadmissible to Canada?",
1606
+ "answers": [
1607
+ [
1608
+ "US Dispositions Helper",
1609
+ "withheld-adjudication"
1610
+ ],
1611
+ [
1612
+ "US Dispositions Helper",
1613
+ "state-florida"
1614
+ ]
1615
+ ]
1616
+ },
1617
+ {
1618
+ "query": "How does Canada treat a US state pardon for immigration inadmissibility?",
1619
+ "answers": [
1620
+ [
1621
+ "US Dispositions Helper",
1622
+ "state-pardon"
1623
+ ]
1624
+ ]
1625
+ },
1626
+ {
1627
+ "query": "What US criminal dispositions does Georgia law treat as non-convictions?",
1628
+ "answers": [
1629
+ [
1630
+ "US Dispositions Helper",
1631
+ "state-georgia"
1632
+ ]
1633
+ ]
1634
+ },
1635
+ {
1636
+ "query": "Is a juvenile delinquency adjudication in the United States a conviction under IRPA?",
1637
+ "answers": [
1638
+ [
1639
+ "US Dispositions Helper",
1640
+ "juvenile-adjudication"
1641
+ ]
1642
+ ]
1643
+ },
1644
+ {
1645
+ "query": "What is the standard of proof for criminal inadmissibility findings under IRPA?",
1646
+ "answers": [
1647
+ [
1648
+ "IRPA",
1649
+ "33"
1650
+ ],
1651
+ [
1652
+ "US Dispositions Helper",
1653
+ "conviction-framework"
1654
+ ]
1655
+ ]
1656
+ },
1657
+ {
1658
+ "query": "Does a single US DUI conviction make a person inadmissible to Canada for serious criminality?",
1659
+ "answers": [
1660
+ [
1661
+ "US Dispositions Helper",
1662
+ "equiv-dui"
1663
+ ]
1664
+ ],
1665
+ "holdout": true
1666
+ },
1667
+ {
1668
+ "query": "What does a US shoplifting conviction equate to under Canadian law for inadmissibility?",
1669
+ "answers": [
1670
+ [
1671
+ "US Dispositions Helper",
1672
+ "equiv-theft-under"
1673
+ ]
1674
+ ]
1675
+ },
1676
+ {
1677
+ "query": "Is an old US marijuana possession conviction still a ground of inadmissibility to Canada?",
1678
+ "answers": [
1679
+ [
1680
+ "US Dispositions Helper",
1681
+ "equiv-cannabis-possession"
1682
+ ]
1683
+ ]
1684
+ },
1685
+ {
1686
+ "query": "Does one disorderly conduct conviction make a foreign national criminally inadmissible?",
1687
+ "answers": [
1688
+ [
1689
+ "US Dispositions Helper",
1690
+ "equiv-disorderly-conduct"
1691
+ ]
1692
+ ]
1693
+ },
1694
+ {
1695
+ "query": "When is a person deemed rehabilitated after a single foreign conviction?",
1696
+ "answers": [
1697
+ [
1698
+ "IRPR",
1699
+ "18"
1700
+ ]
1701
+ ]
1702
+ },
1703
+ {
1704
+ "query": "How long after completing a sentence can someone apply for criminal rehabilitation?",
1705
+ "answers": [
1706
+ [
1707
+ "IRPR",
1708
+ "17"
1709
+ ]
1710
+ ]
1711
+ },
1712
+ {
1713
+ "query": "Traveller referred to secondary arriving from Amsterdam; a white powder in their bag field-tests as ketamine. Which CDSA schedule lists ketamine and what does the possession offence say?",
1714
+ "answers": [
1715
+ [
1716
+ "CDSA",
1717
+ "Schedule I"
1718
+ ],
1719
+ [
1720
+ "CDSA",
1721
+ "4"
1722
+ ]
1723
+ ],
1724
+ "style": "fact-pattern"
1725
+ },
1726
+ {
1727
+ "query": "Secondary examination turns up a 2019 Texas deferred adjudication for misdemeanor theft, successfully completed in 2021. Is the traveller criminally inadmissible on the conviction branch?",
1728
+ "answers": [
1729
+ [
1730
+ "IRPA",
1731
+ "36"
1732
+ ],
1733
+ [
1734
+ "US Dispositions Helper",
1735
+ ""
1736
+ ]
1737
+ ],
1738
+ "style": "fact-pattern"
1739
+ },
1740
+ {
1741
+ "query": "A permanent resident was detained at the port of entry on Tuesday morning. When must the Immigration Division review the detention, and when are the later reviews?",
1742
+ "answers": [
1743
+ [
1744
+ "IRPA",
1745
+ "57"
1746
+ ]
1747
+ ],
1748
+ "style": "fact-pattern"
1749
+ },
1750
+ {
1751
+ "query": "Traveller declares nothing but a currency detector dog indicates; officers find US$12,000 in an envelope. What reporting duty was breached and what can be seized?",
1752
+ "answers": [
1753
+ [
1754
+ "PCMLTFA",
1755
+ "12"
1756
+ ],
1757
+ [
1758
+ "PCMLTFA",
1759
+ "18"
1760
+ ]
1761
+ ],
1762
+ "style": "fact-pattern"
1763
+ },
1764
+ {
1765
+ "query": "A NEXUS member used the members-only lane while transporting a firearm part they failed to declare. What regulations govern presentation in an alternative manner and cancellation of the authorization?",
1766
+ "answers": [
1767
+ [
1768
+ "Presentation of Persons Regs",
1769
+ ""
1770
+ ]
1771
+ ],
1772
+ "style": "fact-pattern"
1773
+ },
1774
+ {
1775
+ "query": "Baggage exam finds brass knuckles bought as a souvenir. Where are they classified as a prohibited weapon?",
1776
+ "answers": [
1777
+ [
1778
+ "Firearms Classification Regs",
1779
+ ""
1780
+ ]
1781
+ ],
1782
+ "style": "fact-pattern"
1783
+ },
1784
+ {
1785
+ "query": "A traveller detained for a strip search at secondary asks to call a lawyer before it happens. What Charter right is engaged?",
1786
+ "answers": [
1787
+ [
1788
+ "Charter",
1789
+ "10"
1790
+ ],
1791
+ [
1792
+ "Dehghani",
1793
+ ""
1794
+ ]
1795
+ ],
1796
+ "style": "fact-pattern"
1797
+ },
1798
+ {
1799
+ "query": "An officer believes a foreign national in secondary is inadmissible for serious criminality and wants to write the report. Which operational manual covers writing A44(1) reports?",
1800
+ "answers": [
1801
+ [
1802
+ "ENF 5",
1803
+ ""
1804
+ ]
1805
+ ],
1806
+ "style": "fact-pattern"
1807
+ },
1808
+ {
1809
+ "query": "currency seizure threshold",
1810
+ "answers": [
1811
+ [
1812
+ "PCMLTFA",
1813
+ "12"
1814
+ ],
1815
+ [
1816
+ "PCMLTFA",
1817
+ "18"
1818
+ ]
1819
+ ],
1820
+ "style": "keyword"
1821
+ },
1822
+ {
1823
+ "query": "detention review timelines",
1824
+ "answers": [
1825
+ [
1826
+ "IRPA",
1827
+ "57"
1828
+ ]
1829
+ ],
1830
+ "style": "keyword"
1831
+ },
1832
+ {
1833
+ "query": "ketamine schedule",
1834
+ "answers": [
1835
+ [
1836
+ "CDSA",
1837
+ "Schedule I"
1838
+ ]
1839
+ ],
1840
+ "style": "keyword"
1841
+ },
1842
+ {
1843
+ "query": "personal exemption returning resident",
1844
+ "answers": [
1845
+ [
1846
+ "Customs Tariff",
1847
+ ""
1848
+ ],
1849
+ [
1850
+ "D-Memo",
1851
+ "D2-3-1"
1852
+ ]
1853
+ ],
1854
+ "style": "keyword"
1855
+ },
1856
+ {
1857
+ "query": "AMPS contravention penalty amounts",
1858
+ "answers": [
1859
+ [
1860
+ "AMPS",
1861
+ ""
1862
+ ]
1863
+ ],
1864
+ "style": "keyword"
1865
+ },
1866
+ {
1867
+ "query": "IAD appeal rules",
1868
+ "answers": [
1869
+ [
1870
+ "IAD Rules",
1871
+ ""
1872
+ ],
1873
+ [
1874
+ "ENF 19",
1875
+ ""
1876
+ ]
1877
+ ],
1878
+ "style": "keyword"
1879
+ },
1880
+ {
1881
+ "query": "detension review timlines permanent resident",
1882
+ "answers": [
1883
+ [
1884
+ "IRPA",
1885
+ "57"
1886
+ ]
1887
+ ],
1888
+ "style": "typo"
1889
+ },
1890
+ {
1891
+ "query": "innadmissibility for serius criminality",
1892
+ "answers": [
1893
+ [
1894
+ "IRPA",
1895
+ "36"
1896
+ ]
1897
+ ],
1898
+ "style": "typo"
1899
+ },
1900
+ {
1901
+ "query": "seisure of undeclraed goods",
1902
+ "answers": [
1903
+ [
1904
+ "Customs Act",
1905
+ "110"
1906
+ ]
1907
+ ],
1908
+ "style": "typo"
1909
+ },
1910
+ {
1911
+ "query": "criminal rehabilitaton aplication period",
1912
+ "answers": [
1913
+ [
1914
+ "IRPR",
1915
+ "17"
1916
+ ]
1917
+ ],
1918
+ "style": "typo"
1919
+ },
1920
+ {
1921
+ "query": "délai de contrôle de la détention d'un résident permanent",
1922
+ "answers": [
1923
+ [
1924
+ "IRPA",
1925
+ "57"
1926
+ ]
1927
+ ],
1928
+ "style": "french"
1929
+ },
1930
+ {
1931
+ "query": "interdiction de territoire pour grande criminalité",
1932
+ "answers": [
1933
+ [
1934
+ "IRPA",
1935
+ "36"
1936
+ ]
1937
+ ],
1938
+ "style": "french"
1939
+ },
1940
+ {
1941
+ "query": "saisie de devises non déclarées à la frontière",
1942
+ "answers": [
1943
+ [
1944
+ "PCMLTFA",
1945
+ "18"
1946
+ ]
1947
+ ],
1948
+ "style": "french"
1949
+ },
1950
+ {
1951
+ "query": "renvoi d'un étranger interdit de territoire",
1952
+ "answers": [
1953
+ [
1954
+ "IRPA",
1955
+ "48"
1956
+ ],
1957
+ [
1958
+ "ENF 10",
1959
+ ""
1960
+ ]
1961
+ ],
1962
+ "style": "french"
1963
+ },
1964
+ {
1965
+ "query": "fouille d'une personne à la frontière par l'agent des douanes",
1966
+ "answers": [
1967
+ [
1968
+ "Customs Act",
1969
+ "98"
1970
+ ]
1971
+ ],
1972
+ "style": "french"
1973
+ },
1974
+ {
1975
+ "query": "How many demerit points for speeding in Ontario?",
1976
+ "answers": [],
1977
+ "style": "no-answer"
1978
+ },
1979
+ {
1980
+ "query": "What is the process to renew a US H-1B visa?",
1981
+ "answers": [],
1982
+ "style": "no-answer"
1983
+ },
1984
+ {
1985
+ "query": "How is child custody decided after divorce in Quebec?",
1986
+ "answers": [],
1987
+ "style": "no-answer"
1988
+ },
1989
+ {
1990
+ "query": "What notice must an Ontario landlord give to evict a tenant?",
1991
+ "answers": [],
1992
+ "style": "no-answer"
1993
+ },
1994
+ {
1995
+ "query": "What do the CIU by-laws say about how branch elections are conducted?",
1996
+ "answers": [
1997
+ [
1998
+ "CIU By-Laws",
1999
+ ""
2000
+ ],
2001
+ [
2002
+ "CIU Elections Kit",
2003
+ ""
2004
+ ],
2005
+ [
2006
+ "CIU Branch Rules",
2007
+ ""
2008
+ ]
2009
+ ],
2010
+ "style": "union"
2011
+ },
2012
+ {
2013
+ "query": "What is the variable shift schedule agreement for Windsor Bridge?",
2014
+ "answers": [
2015
+ [
2016
+ "VSSA",
2017
+ ""
2018
+ ]
2019
+ ],
2020
+ "style": "union"
2021
+ },
2022
+ {
2023
+ "query": "How does the PSAC constitution deal with membership discipline and expulsion?",
2024
+ "answers": [
2025
+ [
2026
+ "PSAC Constitution",
2027
+ ""
2028
+ ]
2029
+ ],
2030
+ "style": "union"
2031
+ },
2032
+ {
2033
+ "query": "What are the timelines for presenting a grievance according to the union's grievance guide?",
2034
+ "answers": [
2035
+ [
2036
+ "CIU Grievance Guide",
2037
+ ""
2038
+ ]
2039
+ ],
2040
+ "style": "union"
2041
+ }
2042
+ ]
data/eval/sweep.json ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "best": {
3
+ "CANLEX_MN_WEIGHT": 0.0024,
4
+ "CANLEX_MN_CAP": 0.012,
5
+ "CANLEX_REG_PENALTY": 0.004,
6
+ "CANLEX_BACKMATTER_PENALTY": 0.004
7
+ },
8
+ "best_metrics": {
9
+ "hit1": 0.79,
10
+ "hit3": 0.96,
11
+ "hit5": 0.99,
12
+ "hit10": 0.99,
13
+ "mrr": 0.88
14
+ },
15
+ "runs": [
16
+ {
17
+ "values": {
18
+ "CANLEX_MN_WEIGHT": 0.0024,
19
+ "CANLEX_MN_CAP": 0.012,
20
+ "CANLEX_REG_PENALTY": 0.008,
21
+ "CANLEX_BACKMATTER_PENALTY": 0.008
22
+ },
23
+ "metrics": {
24
+ "hit1": 0.79,
25
+ "hit3": 0.96,
26
+ "hit5": 0.98,
27
+ "hit10": 0.99,
28
+ "mrr": 0.87
29
+ }
30
+ },
31
+ {
32
+ "values": {
33
+ "CANLEX_MN_WEIGHT": 0.0012,
34
+ "CANLEX_MN_CAP": 0.012,
35
+ "CANLEX_REG_PENALTY": 0.008,
36
+ "CANLEX_BACKMATTER_PENALTY": 0.008
37
+ },
38
+ "metrics": {
39
+ "hit1": 0.78,
40
+ "hit3": 0.94,
41
+ "hit5": 0.98,
42
+ "hit10": 0.99,
43
+ "mrr": 0.86
44
+ }
45
+ },
46
+ {
47
+ "values": {
48
+ "CANLEX_MN_WEIGHT": 0.0024,
49
+ "CANLEX_MN_CAP": 0.012,
50
+ "CANLEX_REG_PENALTY": 0.008,
51
+ "CANLEX_BACKMATTER_PENALTY": 0.008
52
+ },
53
+ "metrics": {
54
+ "hit1": 0.79,
55
+ "hit3": 0.96,
56
+ "hit5": 0.98,
57
+ "hit10": 0.99,
58
+ "mrr": 0.87
59
+ }
60
+ },
61
+ {
62
+ "values": {
63
+ "CANLEX_MN_WEIGHT": 0.005,
64
+ "CANLEX_MN_CAP": 0.012,
65
+ "CANLEX_REG_PENALTY": 0.008,
66
+ "CANLEX_BACKMATTER_PENALTY": 0.008
67
+ },
68
+ "metrics": {
69
+ "hit1": 0.78,
70
+ "hit3": 0.94,
71
+ "hit5": 0.97,
72
+ "hit10": 0.99,
73
+ "mrr": 0.86
74
+ }
75
+ },
76
+ {
77
+ "values": {
78
+ "CANLEX_MN_WEIGHT": 0.01,
79
+ "CANLEX_MN_CAP": 0.012,
80
+ "CANLEX_REG_PENALTY": 0.008,
81
+ "CANLEX_BACKMATTER_PENALTY": 0.008
82
+ },
83
+ "metrics": {
84
+ "hit1": 0.75,
85
+ "hit3": 0.94,
86
+ "hit5": 0.96,
87
+ "hit10": 0.99,
88
+ "mrr": 0.84
89
+ }
90
+ },
91
+ {
92
+ "values": {
93
+ "CANLEX_MN_WEIGHT": 0.0024,
94
+ "CANLEX_MN_CAP": 0.006,
95
+ "CANLEX_REG_PENALTY": 0.008,
96
+ "CANLEX_BACKMATTER_PENALTY": 0.008
97
+ },
98
+ "metrics": {
99
+ "hit1": 0.77,
100
+ "hit3": 0.93,
101
+ "hit5": 0.96,
102
+ "hit10": 0.99,
103
+ "mrr": 0.85
104
+ }
105
+ },
106
+ {
107
+ "values": {
108
+ "CANLEX_MN_WEIGHT": 0.0024,
109
+ "CANLEX_MN_CAP": 0.012,
110
+ "CANLEX_REG_PENALTY": 0.008,
111
+ "CANLEX_BACKMATTER_PENALTY": 0.008
112
+ },
113
+ "metrics": {
114
+ "hit1": 0.79,
115
+ "hit3": 0.96,
116
+ "hit5": 0.98,
117
+ "hit10": 0.99,
118
+ "mrr": 0.87
119
+ }
120
+ },
121
+ {
122
+ "values": {
123
+ "CANLEX_MN_WEIGHT": 0.0024,
124
+ "CANLEX_MN_CAP": 0.024,
125
+ "CANLEX_REG_PENALTY": 0.008,
126
+ "CANLEX_BACKMATTER_PENALTY": 0.008
127
+ },
128
+ "metrics": {
129
+ "hit1": 0.77,
130
+ "hit3": 0.94,
131
+ "hit5": 0.98,
132
+ "hit10": 0.99,
133
+ "mrr": 0.86
134
+ }
135
+ },
136
+ {
137
+ "values": {
138
+ "CANLEX_MN_WEIGHT": 0.0024,
139
+ "CANLEX_MN_CAP": 0.05,
140
+ "CANLEX_REG_PENALTY": 0.008,
141
+ "CANLEX_BACKMATTER_PENALTY": 0.008
142
+ },
143
+ "metrics": {
144
+ "hit1": 0.77,
145
+ "hit3": 0.94,
146
+ "hit5": 0.97,
147
+ "hit10": 0.99,
148
+ "mrr": 0.86
149
+ }
150
+ },
151
+ {
152
+ "values": {
153
+ "CANLEX_MN_WEIGHT": 0.0024,
154
+ "CANLEX_MN_CAP": 0.012,
155
+ "CANLEX_REG_PENALTY": 0.004,
156
+ "CANLEX_BACKMATTER_PENALTY": 0.008
157
+ },
158
+ "metrics": {
159
+ "hit1": 0.79,
160
+ "hit3": 0.96,
161
+ "hit5": 0.98,
162
+ "hit10": 0.99,
163
+ "mrr": 0.88
164
+ }
165
+ },
166
+ {
167
+ "values": {
168
+ "CANLEX_MN_WEIGHT": 0.0024,
169
+ "CANLEX_MN_CAP": 0.012,
170
+ "CANLEX_REG_PENALTY": 0.008,
171
+ "CANLEX_BACKMATTER_PENALTY": 0.008
172
+ },
173
+ "metrics": {
174
+ "hit1": 0.79,
175
+ "hit3": 0.96,
176
+ "hit5": 0.98,
177
+ "hit10": 0.99,
178
+ "mrr": 0.87
179
+ }
180
+ },
181
+ {
182
+ "values": {
183
+ "CANLEX_MN_WEIGHT": 0.0024,
184
+ "CANLEX_MN_CAP": 0.012,
185
+ "CANLEX_REG_PENALTY": 0.016,
186
+ "CANLEX_BACKMATTER_PENALTY": 0.008
187
+ },
188
+ "metrics": {
189
+ "hit1": 0.79,
190
+ "hit3": 0.96,
191
+ "hit5": 0.98,
192
+ "hit10": 0.99,
193
+ "mrr": 0.88
194
+ }
195
+ },
196
+ {
197
+ "values": {
198
+ "CANLEX_MN_WEIGHT": 0.0024,
199
+ "CANLEX_MN_CAP": 0.012,
200
+ "CANLEX_REG_PENALTY": 0.032,
201
+ "CANLEX_BACKMATTER_PENALTY": 0.008
202
+ },
203
+ "metrics": {
204
+ "hit1": 0.79,
205
+ "hit3": 0.96,
206
+ "hit5": 0.98,
207
+ "hit10": 0.99,
208
+ "mrr": 0.88
209
+ }
210
+ },
211
+ {
212
+ "values": {
213
+ "CANLEX_MN_WEIGHT": 0.0024,
214
+ "CANLEX_MN_CAP": 0.012,
215
+ "CANLEX_REG_PENALTY": 0.004,
216
+ "CANLEX_BACKMATTER_PENALTY": 0.004
217
+ },
218
+ "metrics": {
219
+ "hit1": 0.79,
220
+ "hit3": 0.96,
221
+ "hit5": 0.99,
222
+ "hit10": 0.99,
223
+ "mrr": 0.88
224
+ }
225
+ },
226
+ {
227
+ "values": {
228
+ "CANLEX_MN_WEIGHT": 0.0024,
229
+ "CANLEX_MN_CAP": 0.012,
230
+ "CANLEX_REG_PENALTY": 0.004,
231
+ "CANLEX_BACKMATTER_PENALTY": 0.008
232
+ },
233
+ "metrics": {
234
+ "hit1": 0.79,
235
+ "hit3": 0.96,
236
+ "hit5": 0.98,
237
+ "hit10": 0.99,
238
+ "mrr": 0.88
239
+ }
240
+ },
241
+ {
242
+ "values": {
243
+ "CANLEX_MN_WEIGHT": 0.0024,
244
+ "CANLEX_MN_CAP": 0.012,
245
+ "CANLEX_REG_PENALTY": 0.004,
246
+ "CANLEX_BACKMATTER_PENALTY": 0.016
247
+ },
248
+ "metrics": {
249
+ "hit1": 0.79,
250
+ "hit3": 0.96,
251
+ "hit5": 0.98,
252
+ "hit10": 0.99,
253
+ "mrr": 0.87
254
+ }
255
+ },
256
+ {
257
+ "values": {
258
+ "CANLEX_MN_WEIGHT": 0.0024,
259
+ "CANLEX_MN_CAP": 0.012,
260
+ "CANLEX_REG_PENALTY": 0.004,
261
+ "CANLEX_BACKMATTER_PENALTY": 0.032
262
+ },
263
+ "metrics": {
264
+ "hit1": 0.79,
265
+ "hit3": 0.96,
266
+ "hit5": 0.99,
267
+ "hit10": 0.99,
268
+ "mrr": 0.87
269
+ }
270
+ }
271
+ ]
272
+ }
data/eval/sweep.log ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CanLex tuning sweep -- 2026-05-23 12:59:35
2
+
3
+ Baseline: {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008}
4
+ Hit@5=0.980 MRR=0.870
5
+
6
+ Sweeping CANLEX_MN_WEIGHT in [0.0012, 0.0024, 0.005, 0.01] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
7
+ CANLEX_MN_WEIGHT=0.0012 -> Hit@1=0.780 Hit@3=0.940 Hit@5=0.980 Hit@10=0.990 MRR=0.860
8
+ CANLEX_MN_WEIGHT=0.0024 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
9
+ CANLEX_MN_WEIGHT=0.005 -> Hit@1=0.780 Hit@3=0.940 Hit@5=0.970 Hit@10=0.990 MRR=0.860
10
+ CANLEX_MN_WEIGHT=0.01 -> Hit@1=0.750 Hit@3=0.940 Hit@5=0.960 Hit@10=0.990 MRR=0.840
11
+
12
+ Sweeping CANLEX_MN_CAP in [0.006, 0.012, 0.024, 0.05] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
13
+ CANLEX_MN_CAP=0.006 -> Hit@1=0.770 Hit@3=0.930 Hit@5=0.960 Hit@10=0.990 MRR=0.850
14
+ CANLEX_MN_CAP=0.012 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
15
+ CANLEX_MN_CAP=0.024 -> Hit@1=0.770 Hit@3=0.940 Hit@5=0.980 Hit@10=0.990 MRR=0.860
16
+ CANLEX_MN_CAP=0.05 -> Hit@1=0.770 Hit@3=0.940 Hit@5=0.970 Hit@10=0.990 MRR=0.860
17
+
18
+ Sweeping CANLEX_REG_PENALTY in [0.004, 0.008, 0.016, 0.032] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
19
+ CANLEX_REG_PENALTY=0.004 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
20
+ CANLEX_REG_PENALTY=0.008 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
21
+ CANLEX_REG_PENALTY=0.016 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
22
+ CANLEX_REG_PENALTY=0.032 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
23
+ ! CANLEX_REG_PENALTY: 0.008 -> 0.004
24
+
25
+ Sweeping CANLEX_BACKMATTER_PENALTY in [0.004, 0.008, 0.016, 0.032] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.004, 'CANLEX_BACKMATTER_PENALTY': 0.008})
26
+ CANLEX_BACKMATTER_PENALTY=0.004 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.990 Hit@10=0.990 MRR=0.880
27
+ CANLEX_BACKMATTER_PENALTY=0.008 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
28
+ CANLEX_BACKMATTER_PENALTY=0.016 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
29
+ CANLEX_BACKMATTER_PENALTY=0.032 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.990 Hit@10=0.990 MRR=0.870
30
+ ! CANLEX_BACKMATTER_PENALTY: 0.008 -> 0.004
31
+
32
+ Best: {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.004, 'CANLEX_BACKMATTER_PENALTY': 0.004}
33
+ Hit@1=0.790 Hit@5=0.990 MRR=0.880
data/processed/A-8.8-fr.json ADDED
@@ -0,0 +1,942 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "act_code": "A-8.8",
4
+ "act_short": "AAAMPA",
5
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
6
+ "section": "1",
7
+ "marginal_note": "Titre abrégé",
8
+ "part": "",
9
+ "division": "",
10
+ "heading": "Titre abrégé",
11
+ "history": "",
12
+ "last_amended": "2002-12-31",
13
+ "in_force": "2002-12-31",
14
+ "status": "in force",
15
+ "current_to": "2022-03-22",
16
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-1.html",
17
+ "id": "A-8.8-fr-s1",
18
+ "text": "1 Loi sur les sanctions administratives pécuniaires en matière d’agriculture et d’agroalimentaire.",
19
+ "citation": "AAAMPA, art. 1",
20
+ "lang": "fr"
21
+ },
22
+ {
23
+ "act_code": "A-8.8",
24
+ "act_short": "AAAMPA",
25
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
26
+ "section": "2",
27
+ "marginal_note": "Définitions",
28
+ "part": "",
29
+ "division": "",
30
+ "heading": "Définitions",
31
+ "history": "1995, ch. 40, art. 2; 1997, ch. 21, art. 30; 2002, ch. 28, art. 82; 2005, ch. 38, art. 30 et 145; 2012, ch. 24, art. 98; 2015, ch. 2, art. 113",
32
+ "last_amended": "2019-01-15",
33
+ "in_force": "2015-02-27",
34
+ "status": "in force",
35
+ "current_to": "2022-03-22",
36
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-2.html",
37
+ "id": "A-8.8-fr-s2",
38
+ "text": "2 Les définitions qui suivent s’appliquent à la présente loi.\nCommission La Commission de révision prorogée par le paragraphe 27(1). (Tribunal)\nloi agroalimentaire La Loi sur la médiation en matière d’endettement agricole, la Loi relative aux aliments du bétail, la Loi sur les engrais, la Loi sur la santé des animaux, la Loi sur les produits antiparasitaires, la Loi sur la protection des végétaux, la Loi sur la salubrité des aliments au Canada ou la Loi sur les semences. (agri-food Act)\nministre Le ministre de l’Agriculture et de l’Agroalimentaire ou :\na) le ministre de la Santé, pour toute violation relative à une contravention :\n(i) soit à la Loi sur les produits antiparasitaires,\n(ii) soit à une disposition portant sur la salubrité alimentaire dans une loi agroalimentaire ou dans un règlement pris en vertu d’une telle loi;\nb) le ministre de la Sécurité publique et de la Protection civile, pour toute question relative aux procès-verbaux relatifs aux contraventions à la législation frontalière visée au paragraphe 11(5) de la Loi sur l’Agence canadienne d’inspection des aliments. (Minister)\nsanction Sanction administrative pécuniaire infligée pour une violation. (penalty)",
39
+ "citation": "AAAMPA, art. 2",
40
+ "lang": "fr"
41
+ },
42
+ {
43
+ "act_code": "A-8.8",
44
+ "act_short": "AAAMPA",
45
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
46
+ "section": "3",
47
+ "marginal_note": "Principe",
48
+ "part": "",
49
+ "division": "",
50
+ "heading": "Objet",
51
+ "history": "",
52
+ "last_amended": "2002-12-31",
53
+ "in_force": "2002-12-31",
54
+ "status": "in force",
55
+ "current_to": "2022-03-22",
56
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-3.html",
57
+ "id": "A-8.8-fr-s3",
58
+ "text": "3 La présente loi a pour objet d’établir, comme solution de rechange au régime pénal et complément aux autres mesures d’application des lois agroalimentaires déjà en vigueur, un régime juste et efficace de sanctions administratives pécuniaires.",
59
+ "citation": "AAAMPA, art. 3",
60
+ "lang": "fr"
61
+ },
62
+ {
63
+ "act_code": "A-8.8",
64
+ "act_short": "AAAMPA",
65
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
66
+ "section": "4",
67
+ "marginal_note": "Pouvoir réglementaire",
68
+ "part": "",
69
+ "division": "",
70
+ "heading": "Attributions du ministre",
71
+ "history": "1995, ch. 40, art. 4; 2012, ch. 24, art. 99; 2015, ch. 2, art. 114; 2016, ch. 9, art. 70 et 72",
72
+ "last_amended": "2019-01-15",
73
+ "in_force": "2016-12-12",
74
+ "status": "in force",
75
+ "current_to": "2022-03-22",
76
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-4.html",
77
+ "id": "A-8.8-fr-s4",
78
+ "text": "4\n(1) Le ministre peut, par règlement :\na) désigner comme violation punissable au titre de la présente loi la contravention — si elle constitue une infraction à une loi agroalimentaire :\n(i) aux dispositions spécifiées d’une loi agroalimentaire ou de ses règlements,\n(ii) à tout arrêté spécifié pris par le ministre au titre de la Loi sur la protection des végétaux, ou à toute catégorie de ces arrêtés,\n(iii) à toute obligation ou catégorie d’obligations spécifiée — par refus ou omission de l’accomplir — découlant de la Loi sur la santé des animaux, de la Loi sur la protection des végétaux, de la Loi sur les produits antiparasitaires ou de la Loi sur la salubrité des aliments au Canada;\nb) qualifier les violations, selon le cas, de mineures, de graves ou de très graves;\nb.1) établir le sommaire caractérisant la violation dans les procès-verbaux;\nc) fixer le montant — notamment par barème — de la sanction applicable à chaque violation;\nd) prévoir les critères de majoration ou de minoration — notamment pour les transactions — de ce montant, ainsi que les modalités de cette opération;\ne) régir la détermination d’un montant inférieur à la sanction infligée dont le paiement, dans le délai et selon les modalités réglementaires, vaut règlement;\nf) prévoir les cas dans lesquels la Commission peut procéder, dans le cadre du paragraphe 14(1), par écrit ou par la tenue d’une audience;\ng) régir, notamment par l’établissement de présomptions et de règles de preuve, la notification des documents autorisés ou exigés par la présente loi;\nh) prendre toute mesure d’ordre réglementaire prévue par la présente loi;\ni) prendre toute autre mesure d’application de la présente loi.\n(2) [Plafond de la sanction] Le plafond de la sanction est de 2 000 $ pour toute violation commise par une personne physique, sauf dans le cadre d’une entreprise ou à des fins lucratives, et, dans les autres cas, de 5 000 $, 15 000 $ ou 25 000 $, selon que la violation est mineure, grave ou très grave.\n(3) [Critères] Figurent parmi les critères prévus par le ministre au titre de l’alinéa (1)d) notamment :\na) la nature de l’intention ou de la négligence du contrevenant;\nb) la gravité du tort causé par la violation;\nc) les antécédents du contrevenant relatifs aux violations d’une loi agroalimentaire ou aux condamnations pour infraction à une telle loi dans les cinq ans précédant la violation.",
79
+ "citation": "AAAMPA, art. 4",
80
+ "lang": "fr"
81
+ },
82
+ {
83
+ "act_code": "A-8.8",
84
+ "act_short": "AAAMPA",
85
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
86
+ "section": "5",
87
+ "marginal_note": "Précision",
88
+ "part": "",
89
+ "division": "",
90
+ "heading": "Attributions du ministre",
91
+ "history": "",
92
+ "last_amended": "2002-12-31",
93
+ "in_force": "2002-12-31",
94
+ "status": "in force",
95
+ "current_to": "2022-03-22",
96
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-5.html",
97
+ "id": "A-8.8-fr-s5",
98
+ "text": "5 Tout acte ou omission qualifiable à la fois de violation et d’infraction peut être réprimé soit comme violation par le ministre, soit, sur sa recommandation, comme infraction, les poursuites pour violation et celles pour infraction s’excluant toutefois mutuellement.",
99
+ "citation": "AAAMPA, art. 5",
100
+ "lang": "fr"
101
+ },
102
+ {
103
+ "act_code": "A-8.8",
104
+ "act_short": "AAAMPA",
105
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
106
+ "section": "6",
107
+ "marginal_note": "Pouvoir du ministre : agents verbalisateurs",
108
+ "part": "",
109
+ "division": "",
110
+ "heading": "Attributions du ministre",
111
+ "history": "1995, ch. 40, art. 6; 2015, ch. 2, art. 115",
112
+ "last_amended": "2015-02-27",
113
+ "in_force": "2015-02-27",
114
+ "status": "in force",
115
+ "current_to": "2022-03-22",
116
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-6.html",
117
+ "id": "A-8.8-fr-s6",
118
+ "text": "6 Le ministre peut désigner, individuellement ou par catégorie, les agents verbalisateurs.",
119
+ "citation": "AAAMPA, art. 6",
120
+ "lang": "fr"
121
+ },
122
+ {
123
+ "act_code": "A-8.8",
124
+ "act_short": "AAAMPA",
125
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
126
+ "section": "7",
127
+ "marginal_note": "Violation",
128
+ "part": "",
129
+ "division": "",
130
+ "heading": "Ouverture de la poursuite",
131
+ "history": "1995, ch. 40, art. 7; 2012, ch. 24, art. 100(A); 2015, ch. 2, art. 116(A); 2016, ch. 9, art. 71(A) et 72(A)",
132
+ "last_amended": "2019-01-15",
133
+ "in_force": "2016-12-12",
134
+ "status": "in force",
135
+ "current_to": "2022-03-22",
136
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-7.html",
137
+ "id": "A-8.8-fr-s7",
138
+ "text": "7\n(1) Toute contravention désignée au titre de l’alinéa 4(1)a) constitue une violation pour laquelle le contrevenant s’expose à l’avertissement ou à la sanction prévus par la présente loi.\n(2) [Verbalisation] L’agent verbalisateur qui a des motifs raisonnables de croire qu’une violation a été commise peut dresser un procès-verbal qu’il fait notifier au contrevenant. Le procès-verbal comporte, outre le nom du contrevenant et les faits reprochés, soit un avertissement, soit le montant, établi en application du règlement, de la sanction à payer — auquel cas il précise le délai et les modalités de paiement — et, sous réserve des règlements, le montant inférieur de la sanction infligée prévu au procès-verbal dont le paiement, dans le délai et selon les modalités, vaut règlement.\n(3) [Sommaire des droits] Figurent aussi au procès-verbal en langage clair un sommaire des droits et obligations du contrevenant prévus par la présente loi, notamment le droit de contester les faits reprochés auprès du ministre ou de la Commission et la procédure pour le faire.",
139
+ "citation": "AAAMPA, art. 7",
140
+ "lang": "fr"
141
+ },
142
+ {
143
+ "act_code": "A-8.8",
144
+ "act_short": "AAAMPA",
145
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
146
+ "section": "8",
147
+ "marginal_note": "Option",
148
+ "part": "",
149
+ "division": "",
150
+ "heading": "Avertissements",
151
+ "history": "",
152
+ "last_amended": "2002-12-31",
153
+ "in_force": "2002-12-31",
154
+ "status": "in force",
155
+ "current_to": "2022-03-22",
156
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-8.html",
157
+ "id": "A-8.8-fr-s8",
158
+ "text": "8\n(1) Si le procès-verbal comporte un avertissement, le contrevenant peut, dans le délai et selon les modalités réglementaires, contester les faits reprochés auprès du ministre ou de la Commission.\n(2) [Présomption] Le défaut du contrevenant d’exercer l’option dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
159
+ "citation": "AAAMPA, art. 8",
160
+ "lang": "fr"
161
+ },
162
+ {
163
+ "act_code": "A-8.8",
164
+ "act_short": "AAAMPA",
165
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
166
+ "section": "9",
167
+ "marginal_note": "Paiement",
168
+ "part": "",
169
+ "division": "",
170
+ "heading": "Sanctions",
171
+ "history": "",
172
+ "last_amended": "2002-12-31",
173
+ "in_force": "2002-12-31",
174
+ "status": "in force",
175
+ "current_to": "2022-03-22",
176
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-9.html",
177
+ "id": "A-8.8-fr-s9",
178
+ "text": "9\n(1) Si le procès-verbal inflige une sanction et que le contrevenant paie, dans le délai et selon les modalités réglementaires, le montant de celle-ci — ou, sous réserve des règlements, le montant inférieur prévu au procès-verbal — , le paiement, que le ministre accepte en règlement, vaut déclaration de responsabilité à l’égard de la violation et met fin à la poursuite.\n(2) [Option] À défaut d’effectuer le paiement, le contrevenant peut, dans le délai et selon les modalités réglementaires :\na) si la sanction est de 2 000 $ ou plus, demander au ministre de conclure une transaction en vue de la bonne application de la loi agroalimentaire ou du règlement en cause;\nb) contester auprès du ministre les faits reprochés;\nc) demander à la Commission de l’entendre sur les faits reprochés.\n(3) [Présomption] Le défaut du contrevenant d’exercer l’option visée au paragraphe (2) dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
179
+ "citation": "AAAMPA, art. 9",
180
+ "lang": "fr"
181
+ },
182
+ {
183
+ "act_code": "A-8.8",
184
+ "act_short": "AAAMPA",
185
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
186
+ "section": "10",
187
+ "marginal_note": "Conclusion d’une transaction",
188
+ "part": "",
189
+ "division": "",
190
+ "heading": "Transactions",
191
+ "history": "",
192
+ "last_amended": "2002-12-31",
193
+ "in_force": "2002-12-31",
194
+ "status": "in force",
195
+ "current_to": "2022-03-22",
196
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-10.html",
197
+ "id": "A-8.8-fr-s10",
198
+ "text": "10\n(1) Sur demande du contrevenant, le ministre peut conclure une transaction qui, d’une part, est subordonnée aux conditions qu’il estime indiquées, notamment au dépôt d’une caution raisonnable — dont le montant et la nature doivent lui agréer — , en garantie de l’exécution de la transaction et, d’autre part, peut prévoir la réduction partielle ou complète du montant de la sanction.\n(2) [Présomption] La conclusion de la transaction par le ministre vaut déclaration de responsabilité à l’égard de la violation.\n(3) [Avis d’exécution] La notification à l’intéressé d’un avis du ministre déclarant que celui-ci estime la transaction exécutée met fin à la poursuite; dès lors la caution est remise à l’intéressé.\n(4) [Avis de défaut d’exécution] S’il estime la transaction inexécutée, le ministre fait notifier à l’intéressé un avis de défaut qui l’informe soit qu’il doit payer, au lieu du montant de la sanction initiale et sans qu’il soit tenu compte du plafond fixé au paragraphe 4(2), le double de ce montant, soit qu’il y aura confiscation de la caution au profit de Sa Majesté du chef du Canada.\n(5) [Effet de l’inexécution] Sur notification de l’avis, l’intéressé perd tout droit à la compensation pour les sommes exposées dans le cadre de la transaction. Aux termes de l’avis, il est tenu de payer le montant mentionné, ou la confiscation de la caution s’opère au profit de Sa Majesté du chef du Canada, ce qui met fin à la poursuite.\n(6) [Paiement] Le paiement, que le ministre accepte en règlement, met fin à la poursuite.",
199
+ "citation": "AAAMPA, art. 10",
200
+ "lang": "fr"
201
+ },
202
+ {
203
+ "act_code": "A-8.8",
204
+ "act_short": "AAAMPA",
205
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
206
+ "section": "11",
207
+ "marginal_note": "Option en cas de refus de transiger",
208
+ "part": "",
209
+ "division": "",
210
+ "heading": "Transactions",
211
+ "history": "1995, ch. 40, art. 11; 2015, ch. 2, art. 117(A)",
212
+ "last_amended": "2015-02-27",
213
+ "in_force": "2015-02-27",
214
+ "status": "in force",
215
+ "current_to": "2022-03-22",
216
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-11.html",
217
+ "id": "A-8.8-fr-s11",
218
+ "text": "11\n(1) Si le ministre refuse de transiger, le contrevenant peut, dans le délai et selon les modalités réglementaires, soit payer le montant de la sanction infligée initialement, soit demander à la Commission de l’entendre sur les faits reprochés.\n(2) [Paiement] Le paiement, que le ministre accepte en règlement, vaut déclaration de responsabilité à l’égard de la violation et met fin à la poursuite.\n(3) [Présomption] Le défaut du contrevenant d’exercer l’option dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
219
+ "citation": "AAAMPA, art. 11",
220
+ "lang": "fr"
221
+ },
222
+ {
223
+ "act_code": "A-8.8",
224
+ "act_short": "AAAMPA",
225
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
226
+ "section": "12",
227
+ "marginal_note": "Décision du ministre : avertissement",
228
+ "part": "",
229
+ "division": "",
230
+ "heading": "Contestation devant le ministre",
231
+ "history": "",
232
+ "last_amended": "2002-12-31",
233
+ "in_force": "2002-12-31",
234
+ "status": "in force",
235
+ "current_to": "2022-03-22",
236
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-12.html",
237
+ "id": "A-8.8-fr-s12",
238
+ "text": "12\n(1) Saisi d’une contestation au titre de l’article 8, le ministre détermine la responsabilité du contrevenant et lui fait notifier sa décision.\n(2) [Demande de révision] Le contrevenant peut alors, dans le délai et selon les modalités réglementaires, demander à la Commission de l’entendre sur la décision du ministre.",
239
+ "citation": "AAAMPA, art. 12",
240
+ "lang": "fr"
241
+ },
242
+ {
243
+ "act_code": "A-8.8",
244
+ "act_short": "AAAMPA",
245
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
246
+ "section": "13",
247
+ "marginal_note": "Décision du ministre : sanction",
248
+ "part": "",
249
+ "division": "",
250
+ "heading": "Contestation devant le ministre",
251
+ "history": "",
252
+ "last_amended": "2002-12-31",
253
+ "in_force": "2002-12-31",
254
+ "status": "in force",
255
+ "current_to": "2022-03-22",
256
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-13.html",
257
+ "id": "A-8.8-fr-s13",
258
+ "text": "13\n(1) Saisi d’une contestation au titre de l’alinéa 9(2)b), le ministre détermine la responsabilité du contrevenant et lui fait notifier sa décision. S’il juge que le montant de la sanction n’a pas été établi en application des règlements, il y substitue le montant qu’il estime conforme.\n(2) [Option] Le contrevenant peut, dans le délai et selon les modalités réglementaires, soit payer le montant mentionné — paiement que le ministre accepte en règlement et qui met fin à la poursuite — , soit demander à la Commission de l’entendre sur la décision du ministre.",
259
+ "citation": "AAAMPA, art. 13",
260
+ "lang": "fr"
261
+ },
262
+ {
263
+ "act_code": "A-8.8",
264
+ "act_short": "AAAMPA",
265
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
266
+ "section": "14",
267
+ "marginal_note": "Pouvoir de la Commission",
268
+ "part": "",
269
+ "division": "",
270
+ "heading": "Révision par la Commission",
271
+ "history": "",
272
+ "last_amended": "2002-12-31",
273
+ "in_force": "2002-12-31",
274
+ "status": "in force",
275
+ "current_to": "2022-03-22",
276
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-14.html",
277
+ "id": "A-8.8-fr-s14",
278
+ "text": "14\n(1) Saisie d’une affaire au titre de la présente loi, la Commission, par ordonnance et selon le cas, soit confirme, modifie ou annule la décision du ministre, soit détermine la responsabilité du contrevenant; en outre, si elle estime que le montant de la sanction n’a pas été établi en application des règlements, elle y substitue le montant qu’elle juge conforme. Elle fait notifier l’ordonnance à l’intéressé et au ministre.\n(2) [Paiement] Le paiement du montant conformément à l’ordonnance, que le ministre accepte en règlement, met fin à la poursuite.",
279
+ "citation": "AAAMPA, art. 14",
280
+ "lang": "fr"
281
+ },
282
+ {
283
+ "act_code": "A-8.8",
284
+ "act_short": "AAAMPA",
285
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
286
+ "section": "15",
287
+ "marginal_note": "Créance de Sa Majesté",
288
+ "part": "",
289
+ "division": "",
290
+ "heading": "Exécution des sanctions",
291
+ "history": "1995, ch. 40, art. 15; 2012, ch. 24, art. 101; 2015, ch. 2, art. 118(F)",
292
+ "last_amended": "2019-01-15",
293
+ "in_force": "2015-02-27",
294
+ "status": "in force",
295
+ "current_to": "2022-03-22",
296
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-15.html",
297
+ "id": "A-8.8-fr-s15",
298
+ "text": "15\n(1) Constitue une créance de Sa Majesté du chef du Canada, dont le recouvrement peut être poursuivi à ce titre devant la Cour fédérale :\na) le montant de la sanction, à compter de la date de notification du procès-verbal;\nb) tout montant prévu dans une transaction conclue au titre du paragraphe 10(1), à compter de la date de sa conclusion;\nc) le montant mentionné dans l’avis de défaut notifié au titre du paragraphe 10(4), à compter de la date de sa notification;\nd) le montant mentionné dans la décision notifiée au titre du paragraphe 13(1), à compter de la date de sa notification;\ne) le montant mentionné dans l’ordonnance visée au paragraphe 14(1), à compter de l’expiration du délai fixé par la Commission pour le payer;\nf) le montant des frais raisonnables visés à l’article 22, à compter de la date où ils ont été faits.\n(2) [Prescription] Le recouvrement de la créance se prescrit par cinq ans à compter de la date à laquelle elle est devenue exigible.\n(3) [Conditions de révision] La créance est définitive et n’est susceptible de contestation ou de révision que dans la mesure et selon les modalités prévues aux articles 9 à 14.",
299
+ "citation": "AAAMPA, art. 15",
300
+ "lang": "fr"
301
+ },
302
+ {
303
+ "act_code": "A-8.8",
304
+ "act_short": "AAAMPA",
305
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
306
+ "section": "16",
307
+ "marginal_note": "Certificat de non-paiement",
308
+ "part": "",
309
+ "division": "",
310
+ "heading": "Exécution des sanctions",
311
+ "history": "",
312
+ "last_amended": "2002-12-31",
313
+ "in_force": "2002-12-31",
314
+ "status": "in force",
315
+ "current_to": "2022-03-22",
316
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-16.html",
317
+ "id": "A-8.8-fr-s16",
318
+ "text": "16\n(1) Le ministre peut établir un certificat de non-paiement pour la partie impayée des créances visées au paragraphe 15(1).\n(2) [Enregistrement en Cour fédérale] L’enregistrement à la Cour fédérale confère au certificat la valeur d’un jugement de cette juridiction pour la somme visée et les frais afférents.",
319
+ "citation": "AAAMPA, art. 16",
320
+ "lang": "fr"
321
+ },
322
+ {
323
+ "act_code": "A-8.8",
324
+ "act_short": "AAAMPA",
325
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
326
+ "section": "17",
327
+ "marginal_note": "Précision",
328
+ "part": "",
329
+ "division": "",
330
+ "heading": "Règles spécifiques aux violations",
331
+ "history": "",
332
+ "last_amended": "2002-12-31",
333
+ "in_force": "2002-12-31",
334
+ "status": "in force",
335
+ "current_to": "2022-03-22",
336
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-17.html",
337
+ "id": "A-8.8-fr-s17",
338
+ "text": "17 Les violations n’ont pas valeur d’infractions; en conséquence nul ne peut être poursuivi à ce titre sur le fondement de l’article 126 du Code criminel.",
339
+ "citation": "AAAMPA, art. 17",
340
+ "lang": "fr"
341
+ },
342
+ {
343
+ "act_code": "A-8.8",
344
+ "act_short": "AAAMPA",
345
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
346
+ "section": "18",
347
+ "marginal_note": "Exclusion de certains moyens de défense",
348
+ "part": "",
349
+ "division": "",
350
+ "heading": "Règles spécifiques aux violations",
351
+ "history": "",
352
+ "last_amended": "2002-12-31",
353
+ "in_force": "2002-12-31",
354
+ "status": "in force",
355
+ "current_to": "2022-03-22",
356
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-18.html",
357
+ "id": "A-8.8-fr-s18",
358
+ "text": "18\n(1) Le contrevenant ne peut invoquer en défense le fait qu’il a pris les mesures nécessaires pour empêcher la violation ou qu’il croyait raisonnablement et en toute honnêteté à l’existence de faits qui, avérés, l’exonéreraient.\n(2) [Principes de la common law] Les règles et principes de la common law qui font d’une circonstance une justification ou une excuse dans le cadre d’une poursuite pour infraction à une loi agroalimentaire s’appliquent à l’égard d’une violation sauf dans la mesure où ils sont incompatibles avec la présente loi.",
359
+ "citation": "AAAMPA, art. 18",
360
+ "lang": "fr"
361
+ },
362
+ {
363
+ "act_code": "A-8.8",
364
+ "act_short": "AAAMPA",
365
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
366
+ "section": "19",
367
+ "marginal_note": "Charge de la preuve",
368
+ "part": "",
369
+ "division": "",
370
+ "heading": "Règles spécifiques aux violations",
371
+ "history": "",
372
+ "last_amended": "2002-12-31",
373
+ "in_force": "2002-12-31",
374
+ "status": "in force",
375
+ "current_to": "2022-03-22",
376
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-19.html",
377
+ "id": "A-8.8-fr-s19",
378
+ "text": "19 En cas de contestation devant le ministre ou de révision par la Commission, portant sur les faits, il appartient au ministre d’établir, selon la prépondérance des probabilités, la responsabilité du contrevenant.",
379
+ "citation": "AAAMPA, art. 19",
380
+ "lang": "fr"
381
+ },
382
+ {
383
+ "act_code": "A-8.8",
384
+ "act_short": "AAAMPA",
385
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
386
+ "section": "20",
387
+ "marginal_note": "Responsabilité indirecte : titulaires",
388
+ "part": "",
389
+ "division": "",
390
+ "heading": "Règles spécifiques aux violations",
391
+ "history": "",
392
+ "last_amended": "2002-12-31",
393
+ "in_force": "2002-12-31",
394
+ "status": "in force",
395
+ "current_to": "2022-03-22",
396
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-20.html",
397
+ "id": "A-8.8-fr-s20",
398
+ "text": "20\n(1) Le titulaire d’un agrément — licence, permis ou autre type d’autorisation — délivré en vertu d’une loi agroalimentaire est responsable de la violation commise dans le cadre des activités ou des obligations visées par l’agrément, que l’auteur de la contravention soit ou non connu ou poursuivi aux termes de la présente loi.\n(2) [Responsabilité indirecte : employeurs et mandants] L’employeur ou le mandant est responsable de la violation commise, dans le cadre de son emploi ou du mandat, par un employé ou un mandataire, que l’auteur de la contravention soit ou non connu ou poursuivi aux termes de la présente loi.",
399
+ "citation": "AAAMPA, art. 20",
400
+ "lang": "fr"
401
+ },
402
+ {
403
+ "act_code": "A-8.8",
404
+ "act_short": "AAAMPA",
405
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
406
+ "section": "21",
407
+ "marginal_note": "Violation continue",
408
+ "part": "",
409
+ "division": "",
410
+ "heading": "Règles spécifiques aux violations",
411
+ "history": "",
412
+ "last_amended": "2002-12-31",
413
+ "in_force": "2002-12-31",
414
+ "status": "in force",
415
+ "current_to": "2022-03-22",
416
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-21.html",
417
+ "id": "A-8.8-fr-s21",
418
+ "text": "21 Il est compté une violation distincte pour chacun des jours au cours desquels se continue une violation.",
419
+ "citation": "AAAMPA, art. 21",
420
+ "lang": "fr"
421
+ },
422
+ {
423
+ "act_code": "A-8.8",
424
+ "act_short": "AAAMPA",
425
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
426
+ "section": "22",
427
+ "marginal_note": "Confiscation",
428
+ "part": "",
429
+ "division": "",
430
+ "heading": "Règles spécifiques aux violations",
431
+ "history": "",
432
+ "last_amended": "2002-12-31",
433
+ "in_force": "2002-12-31",
434
+ "status": "in force",
435
+ "current_to": "2022-03-22",
436
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-22.html",
437
+ "id": "A-8.8-fr-s22",
438
+ "text": "22 Il y a confiscation au profit de Sa Majesté du chef du Canada — si elle en décide ainsi — de tout objet détenu ou saisi, relativement à une violation, au titre d’une loi agroalimentaire dès lors que le contrevenant est déclaré ou réputé être responsable de la violation et que, dans ce dernier cas, il n’a pas, dans le délai et selon les modalités réglementaires, saisi la Commission d’une demande de révision; il en est alors disposé, aux frais du saisi, conformément, sous réserve des instructions du ministre, au règlement pris au titre de la loi agroalimentaire en cause.",
439
+ "citation": "AAAMPA, art. 22",
440
+ "lang": "fr"
441
+ },
442
+ {
443
+ "act_code": "A-8.8",
444
+ "act_short": "AAAMPA",
445
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
446
+ "section": "23",
447
+ "marginal_note": "Dossiers",
448
+ "part": "",
449
+ "division": "",
450
+ "heading": "Dispositions générales",
451
+ "history": "",
452
+ "last_amended": "2002-12-31",
453
+ "in_force": "2002-12-31",
454
+ "status": "in force",
455
+ "current_to": "2022-03-22",
456
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-23.html",
457
+ "id": "A-8.8-fr-s23",
458
+ "text": "23\n(1) Sur demande du contrevenant, toute mention relative à une violation est rayée du dossier que le ministre tient à son égard cinq ans après la date soit du paiement de toute créance visée au paragraphe 15(1), soit de la notification d’un procès-verbal comportant un avertissement, à moins que celui-ci estime que ce serait contraire à l’intérêt public ou qu’une autre mention ait été portée au dossier au sujet de l’intéressé par la suite, mais n’ait pas été rayée.\n(2) [Notification] Le ministre fait notifier un avis de radiation à l’intéressé.",
459
+ "citation": "AAAMPA, art. 23",
460
+ "lang": "fr"
461
+ },
462
+ {
463
+ "act_code": "A-8.8",
464
+ "act_short": "AAAMPA",
465
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
466
+ "section": "24",
467
+ "marginal_note": "Notification",
468
+ "part": "",
469
+ "division": "",
470
+ "heading": "Dispositions générales",
471
+ "history": "",
472
+ "last_amended": "2002-12-31",
473
+ "in_force": "2002-12-31",
474
+ "status": "in force",
475
+ "current_to": "2022-03-22",
476
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-24.html",
477
+ "id": "A-8.8-fr-s24",
478
+ "text": "24 Toute notification autorisée ou exigée par la présente loi s’effectue conformément au règlement, par remise à personne ou de toute autre manière qui y est autorisée.",
479
+ "citation": "AAAMPA, art. 24",
480
+ "lang": "fr"
481
+ },
482
+ {
483
+ "act_code": "A-8.8",
484
+ "act_short": "AAAMPA",
485
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
486
+ "section": "25",
487
+ "marginal_note": "Admissibilité du procès-verbal de violation",
488
+ "part": "",
489
+ "division": "",
490
+ "heading": "Dispositions générales",
491
+ "history": "",
492
+ "last_amended": "2002-12-31",
493
+ "in_force": "2002-12-31",
494
+ "status": "in force",
495
+ "current_to": "2022-03-22",
496
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-25.html",
497
+ "id": "A-8.8-fr-s25",
498
+ "text": "25 Dans les poursuites pour violation ou pour infraction, le procès-verbal censé délivré en application de la présente loi est admissible en preuve sans qu’il soit nécessaire de prouver l’authenticité de la signature ni la qualité officielle du signataire.",
499
+ "citation": "AAAMPA, art. 25",
500
+ "lang": "fr"
501
+ },
502
+ {
503
+ "act_code": "A-8.8",
504
+ "act_short": "AAAMPA",
505
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
506
+ "section": "26",
507
+ "marginal_note": "Prescription",
508
+ "part": "",
509
+ "division": "",
510
+ "heading": "Dispositions générales",
511
+ "history": "1995, ch. 40, art. 26; 2015, ch. 2, art. 119",
512
+ "last_amended": "2015-02-27",
513
+ "in_force": "2015-02-27",
514
+ "status": "in force",
515
+ "current_to": "2022-03-22",
516
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-26.html",
517
+ "id": "A-8.8-fr-s26",
518
+ "text": "26 Les poursuites pour violation se prescrivent par six mois à compter de la date à laquelle elle a été commise, lorsque celle-ci est mineure, et par deux ans, lorsqu’elle est grave ou très grave.",
519
+ "citation": "AAAMPA, art. 26",
520
+ "lang": "fr"
521
+ },
522
+ {
523
+ "act_code": "A-8.8",
524
+ "act_short": "AAAMPA",
525
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
526
+ "section": "27",
527
+ "marginal_note": "Prorogation de la Commission",
528
+ "part": "",
529
+ "division": "",
530
+ "heading": "Composition",
531
+ "history": "1995, ch. 40, art. 27; 2012, ch. 24, art. 102",
532
+ "last_amended": "2019-01-15",
533
+ "in_force": "2019-01-15",
534
+ "status": "in force",
535
+ "current_to": "2022-03-22",
536
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-27.html",
537
+ "id": "A-8.8-fr-s27",
538
+ "text": "27\n(1) Est prorogée la Commission de révision prorogée par le paragraphe 4.1(1) la Loi sur les produits agricoles au Canada, chapitre 20 du 4e supplément des Lois révisées du Canada (1985).\n(2) [Composition] La Commission est composée des membres, dont le président, nommés par le gouverneur en conseil.",
539
+ "citation": "AAAMPA, art. 27",
540
+ "lang": "fr"
541
+ },
542
+ {
543
+ "act_code": "A-8.8",
544
+ "act_short": "AAAMPA",
545
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
546
+ "section": "28",
547
+ "marginal_note": "Compétences",
548
+ "part": "",
549
+ "division": "",
550
+ "heading": "Composition",
551
+ "history": "1995, ch. 40, art. 28; 2012, ch. 24, art. 102",
552
+ "last_amended": "2019-01-15",
553
+ "in_force": "2019-01-15",
554
+ "status": "in force",
555
+ "current_to": "2022-03-22",
556
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-28.html",
557
+ "id": "A-8.8-fr-s28",
558
+ "text": "28 Les membres sont nommés en raison de leurs connaissances ou de leur expérience dans le domaine de l’agriculture ou de l’agroalimentaire et le président et au moins un autre membre sont obligatoirement choisis parmi les avocats ou notaires inscrits respectivement, depuis au moins dix ans, au barreau d’une province ou à la Chambre des notaires du Québec.",
559
+ "citation": "AAAMPA, art. 28",
560
+ "lang": "fr"
561
+ },
562
+ {
563
+ "act_code": "A-8.8",
564
+ "act_short": "AAAMPA",
565
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
566
+ "section": "29",
567
+ "marginal_note": "Exercice des fonctions",
568
+ "part": "",
569
+ "division": "",
570
+ "heading": "Composition",
571
+ "history": "1995, ch. 40, art. 29; 2012, ch. 24, art. 102",
572
+ "last_amended": "2019-01-15",
573
+ "in_force": "2019-01-15",
574
+ "status": "in force",
575
+ "current_to": "2022-03-22",
576
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-29.html",
577
+ "id": "A-8.8-fr-s29",
578
+ "text": "29 Le président exerce ses fonctions à temps plein; les autres membres, à temps plein ou à temps partiel.",
579
+ "citation": "AAAMPA, art. 29",
580
+ "lang": "fr"
581
+ },
582
+ {
583
+ "act_code": "A-8.8",
584
+ "act_short": "AAAMPA",
585
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
586
+ "section": "30",
587
+ "marginal_note": "Mandat",
588
+ "part": "",
589
+ "division": "",
590
+ "heading": "Composition",
591
+ "history": "1995, ch. 40, art. 30; 2012, ch. 24, art. 102",
592
+ "last_amended": "2019-01-15",
593
+ "in_force": "2019-01-15",
594
+ "status": "in force",
595
+ "current_to": "2022-03-22",
596
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-30.html",
597
+ "id": "A-8.8-fr-s30",
598
+ "text": "30\n(1) Les membres occupent leur poste à titre inamovible pour un mandat maximal de cinq ans, sous réserve de révocation motivée prononcée par le gouverneur en conseil.\n(2) [Nouveau mandat] Les membres peuvent recevoir un nouveau mandat, aux fonctions identiques ou non.",
599
+ "citation": "AAAMPA, art. 30",
600
+ "lang": "fr"
601
+ },
602
+ {
603
+ "act_code": "A-8.8",
604
+ "act_short": "AAAMPA",
605
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
606
+ "section": "31",
607
+ "marginal_note": "Incompatibilité de fonctions",
608
+ "part": "",
609
+ "division": "",
610
+ "heading": "Composition",
611
+ "history": "1995, ch. 40, art. 31; 2012, ch. 24, art. 102",
612
+ "last_amended": "2019-01-15",
613
+ "in_force": "2019-01-15",
614
+ "status": "in force",
615
+ "current_to": "2022-03-22",
616
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-31.html",
617
+ "id": "A-8.8-fr-s31",
618
+ "text": "31 La charge de membre est incompatible avec d’autres fonctions dans l’administration publique fédérale.",
619
+ "citation": "AAAMPA, art. 31",
620
+ "lang": "fr"
621
+ },
622
+ {
623
+ "act_code": "A-8.8",
624
+ "act_short": "AAAMPA",
625
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
626
+ "section": "32",
627
+ "marginal_note": "Conflits d’intérêts",
628
+ "part": "",
629
+ "division": "",
630
+ "heading": "Composition",
631
+ "history": "1995, ch. 40, art. 32; 2012, ch. 24, art. 102",
632
+ "last_amended": "2019-01-15",
633
+ "in_force": "2019-01-15",
634
+ "status": "in force",
635
+ "current_to": "2022-03-22",
636
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-32.html",
637
+ "id": "A-8.8-fr-s32",
638
+ "text": "32 Les membres ne peuvent accepter ni occuper de charge ou d’emploi incompatibles avec leurs fonctions, ni se saisir d’une affaire dans laquelle ils ont un intérêt.",
639
+ "citation": "AAAMPA, art. 32",
640
+ "lang": "fr"
641
+ },
642
+ {
643
+ "act_code": "A-8.8",
644
+ "act_short": "AAAMPA",
645
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
646
+ "section": "33",
647
+ "marginal_note": "Fonctions du président",
648
+ "part": "",
649
+ "division": "",
650
+ "heading": "Président",
651
+ "history": "1995, ch. 40, art. 33; 2012, ch. 24, art. 102; 2014, ch. 20, art. 480",
652
+ "last_amended": "2019-01-15",
653
+ "in_force": "2019-01-15",
654
+ "status": "in force",
655
+ "current_to": "2022-03-22",
656
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-33.html",
657
+ "id": "A-8.8-fr-s33",
658
+ "text": "33\n(1) Le président de la Commission répartit les tâches entre les membres.\n(2) [Absence ou empêchement] En cas d’absence ou d’empêchement du président, ou de vacance de son poste, les autres membres confient l’intérim à l’un des membres dotés de la compétence juridique prévue à l’article 28. Cependant, l’intérim ne peut dépasser soixante jours sans l’approbation du gouverneur en conseil.",
659
+ "citation": "AAAMPA, art. 33",
660
+ "lang": "fr"
661
+ },
662
+ {
663
+ "act_code": "A-8.8",
664
+ "act_short": "AAAMPA",
665
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
666
+ "section": "34",
667
+ "marginal_note": "Indemnités",
668
+ "part": "",
669
+ "division": "",
670
+ "heading": "Indemnités et frais",
671
+ "history": "1995, ch. 40, art. 34; 2012, ch. 24, art. 102",
672
+ "last_amended": "2019-01-15",
673
+ "in_force": "2019-01-15",
674
+ "status": "in force",
675
+ "current_to": "2022-03-22",
676
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-34.html",
677
+ "id": "A-8.8-fr-s34",
678
+ "text": "34\n(1) Les membres à temps plein reçoivent le traitement, et les autres membres reçoivent les honoraires ou toute autre rémunération, que fixe le gouverneur en conseil.\n(2) [Frais] Les membres sont indemnisés des frais de déplacement et de séjour entraînés par l’exercice de leurs attributions hors de leur lieu habituel soit de travail, s’ils sont à temps plein, soit de résidence, s’ils sont à temps partiel, conformément aux directives du Conseil du Trésor.",
679
+ "citation": "AAAMPA, art. 34",
680
+ "lang": "fr"
681
+ },
682
+ {
683
+ "act_code": "A-8.8",
684
+ "act_short": "AAAMPA",
685
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
686
+ "section": "35",
687
+ "marginal_note": "",
688
+ "part": "",
689
+ "division": "",
690
+ "heading": "Indemnités et frais",
691
+ "history": "",
692
+ "last_amended": "2019-01-15",
693
+ "in_force": "2019-01-15",
694
+ "status": "repealed",
695
+ "current_to": "2022-03-22",
696
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-35.html",
697
+ "id": "A-8.8-fr-s35",
698
+ "text": "35 [Abrogé, 2014, ch. 20, art. 480]",
699
+ "citation": "AAAMPA, art. 35",
700
+ "lang": "fr"
701
+ },
702
+ {
703
+ "act_code": "A-8.8",
704
+ "act_short": "AAAMPA",
705
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
706
+ "section": "36",
707
+ "marginal_note": "",
708
+ "part": "",
709
+ "division": "",
710
+ "heading": "Indemnités et frais",
711
+ "history": "",
712
+ "last_amended": "2019-01-15",
713
+ "in_force": "2019-01-15",
714
+ "status": "repealed",
715
+ "current_to": "2022-03-22",
716
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-36.html",
717
+ "id": "A-8.8-fr-s36",
718
+ "text": "36 [Abrogé, 2014, ch. 20, art. 480]",
719
+ "citation": "AAAMPA, art. 36",
720
+ "lang": "fr"
721
+ },
722
+ {
723
+ "act_code": "A-8.8",
724
+ "act_short": "AAAMPA",
725
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
726
+ "section": "37",
727
+ "marginal_note": "Siège",
728
+ "part": "",
729
+ "division": "",
730
+ "heading": "Siège",
731
+ "history": "1995, ch. 40, art. 37; 2012, ch. 24, art. 102",
732
+ "last_amended": "2019-01-15",
733
+ "in_force": "2019-01-15",
734
+ "status": "in force",
735
+ "current_to": "2022-03-22",
736
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-37.html",
737
+ "id": "A-8.8-fr-s37",
738
+ "text": "37\n(1) Le siège de la Commission est fixé dans la région de la capitale nationale au sens de l’article 2 de la Loi sur la capitale nationale.\n(2) [Réunions] La Commission siège en tout lieu du Canada fixé par le gouverneur en conseil.",
739
+ "citation": "AAAMPA, art. 37",
740
+ "lang": "fr"
741
+ },
742
+ {
743
+ "act_code": "A-8.8",
744
+ "act_short": "AAAMPA",
745
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
746
+ "section": "38",
747
+ "marginal_note": "Commission",
748
+ "part": "",
749
+ "division": "",
750
+ "heading": "Compétence",
751
+ "history": "1995, ch. 40, art. 38; 2012, ch. 24, art. 102",
752
+ "last_amended": "2019-01-15",
753
+ "in_force": "2019-01-15",
754
+ "status": "in force",
755
+ "current_to": "2022-03-22",
756
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-38.html",
757
+ "id": "A-8.8-fr-s38",
758
+ "text": "38\n(1) La Commission a compétence exclusive pour les affaires relevant des domaines qui lui sont attribués sous le régime de la présente loi ou de toute autre loi fédérale.\n(2) [Révision en Cour fédérale] Les ordonnances de la Commission ne sont susceptibles de révision qu’au titre de la Loi sur les Cours fédérales.",
759
+ "citation": "AAAMPA, art. 38",
760
+ "lang": "fr"
761
+ },
762
+ {
763
+ "act_code": "A-8.8",
764
+ "act_short": "AAAMPA",
765
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
766
+ "section": "39",
767
+ "marginal_note": "Président",
768
+ "part": "",
769
+ "division": "",
770
+ "heading": "Compétence",
771
+ "history": "1995, ch. 40, art. 39; 2012, ch. 24, art. 102",
772
+ "last_amended": "2019-01-15",
773
+ "in_force": "2019-01-15",
774
+ "status": "in force",
775
+ "current_to": "2022-03-22",
776
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-39.html",
777
+ "id": "A-8.8-fr-s39",
778
+ "text": "39\n(1) Le président de la Commission a seul compétence pour entendre :\na) les demandes touchant les avertissements formées au titre des paragraphes 8(1) ou 12(2);\nb) les demandes touchant les sanctions de moins de 2 000 $ formées au titre de l’alinéa 9(2)c) ou du paragraphe 13(2).\n(2) [Délégation] Le président peut déléguer aux autres membres ses attributions relativement aux demandes mentionnées au paragraphe (1) s’ils sont dotés de la compétence juridique prévue à l’article 28.",
779
+ "citation": "AAAMPA, art. 39",
780
+ "lang": "fr"
781
+ },
782
+ {
783
+ "act_code": "A-8.8",
784
+ "act_short": "AAAMPA",
785
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
786
+ "section": "40",
787
+ "marginal_note": "Révision",
788
+ "part": "",
789
+ "division": "",
790
+ "heading": "Compétence",
791
+ "history": "1995, ch. 40, art. 40; 2012, ch. 24, art. 102",
792
+ "last_amended": "2019-01-15",
793
+ "in_force": "2019-01-15",
794
+ "status": "in force",
795
+ "current_to": "2022-03-22",
796
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-40.html",
797
+ "id": "A-8.8-fr-s40",
798
+ "text": "40 Les demandes de révision sont entendues par un membre seul.",
799
+ "citation": "AAAMPA, art. 40",
800
+ "lang": "fr"
801
+ },
802
+ {
803
+ "act_code": "A-8.8",
804
+ "act_short": "AAAMPA",
805
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
806
+ "section": "41",
807
+ "marginal_note": "Cour d’archives",
808
+ "part": "",
809
+ "division": "",
810
+ "heading": "Pouvoirs",
811
+ "history": "1995, ch. 40, art. 41; 2012, ch. 24, art. 102",
812
+ "last_amended": "2019-01-15",
813
+ "in_force": "2019-01-15",
814
+ "status": "in force",
815
+ "current_to": "2022-03-22",
816
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-41.html",
817
+ "id": "A-8.8-fr-s41",
818
+ "text": "41\n(1) La Commission est une cour d’archives; elle a un sceau officiel, dont l’authenticité est admise d’office.\n(2) [Interrogatoire des témoins] En outre, la Commission a, pour la comparution, la prestation des serments et l’interrogatoire des témoins, ainsi que pour la production et l’examen des documents et autres pièces, l’exécution de ses ordonnances et toutes autres questions relevant de sa compétence, les attributions d’une juridiction supérieure d’archives. Elle peut notamment :\na) par citation adressée aux personnes ayant connaissance de faits se rapportant à l’affaire dont elle est saisie, leur enjoindre de comparaître comme témoins aux date, heure et lieu indiqués et d’apporter et de produire tous documents, livres ou pièces utiles à l’affaire, dont elles ont la possession ou la responsabilité;\nb) faire prêter serment et interroger sous serment;\nc) recevoir en cours d’audition les éléments de preuve supplémentaires qu’elle estime utiles et dignes de foi.",
819
+ "citation": "AAAMPA, art. 41",
820
+ "lang": "fr"
821
+ },
822
+ {
823
+ "act_code": "A-8.8",
824
+ "act_short": "AAAMPA",
825
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
826
+ "section": "42",
827
+ "marginal_note": "Règles",
828
+ "part": "",
829
+ "division": "",
830
+ "heading": "Règles",
831
+ "history": "1995, ch. 40, art. 42; 2012, ch. 24, art. 102",
832
+ "last_amended": "2019-01-15",
833
+ "in_force": "2019-01-15",
834
+ "status": "in force",
835
+ "current_to": "2022-03-22",
836
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-42.html",
837
+ "id": "A-8.8-fr-s42",
838
+ "text": "42 La Commission peut, avec l’agrément du gouverneur en conseil, établir des règles régissant :\na) la pratique et la procédure des audiences;\nb) les modalités, y compris les délais, d’établissement des demandes et des avis à donner;\nc) de façon générale, l’exercice de ses activités sous le régime de la présente loi ou de toute autre loi fédérale.",
839
+ "citation": "AAAMPA, art. 42",
840
+ "lang": "fr"
841
+ },
842
+ {
843
+ "act_code": "A-8.8",
844
+ "act_short": "AAAMPA",
845
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
846
+ "section": "43",
847
+ "marginal_note": "Consultations",
848
+ "part": "",
849
+ "division": "",
850
+ "heading": "Généralités",
851
+ "history": "1995, ch. 40, art. 43; 2012, ch. 24, art. 102",
852
+ "last_amended": "2019-01-15",
853
+ "in_force": "2019-01-15",
854
+ "status": "in force",
855
+ "current_to": "2022-03-22",
856
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-43.html",
857
+ "id": "A-8.8-fr-s43",
858
+ "text": "43 Les membres peuvent, dans le cadre des affaires dont la Commission est saisie, consulter d’autres membres.",
859
+ "citation": "AAAMPA, art. 43",
860
+ "lang": "fr"
861
+ },
862
+ {
863
+ "act_code": "A-8.8",
864
+ "act_short": "AAAMPA",
865
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
866
+ "section": "44",
867
+ "marginal_note": "Règles en matière de preuve",
868
+ "part": "",
869
+ "division": "",
870
+ "heading": "Généralités",
871
+ "history": "1995, ch. 40, art. 44; 2012, ch. 24, art. 102",
872
+ "last_amended": "2019-01-15",
873
+ "in_force": "2019-01-15",
874
+ "status": "in force",
875
+ "current_to": "2022-03-22",
876
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-44.html",
877
+ "id": "A-8.8-fr-s44",
878
+ "text": "44 La Commission n’est pas liée par les règles juridiques ou techniques applicables en matière de preuve lors des audiences. Dans la mesure où les circonstances, l’équité et la justice naturelle le permettent, il lui appartient d’agir rapidement et sans formalités.",
879
+ "citation": "AAAMPA, art. 44",
880
+ "lang": "fr"
881
+ },
882
+ {
883
+ "act_code": "A-8.8",
884
+ "act_short": "AAAMPA",
885
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
886
+ "section": "45",
887
+ "marginal_note": "Exception en matière de preuve",
888
+ "part": "",
889
+ "division": "",
890
+ "heading": "Généralités",
891
+ "history": "1995, ch. 40, art. 45; 2012, ch. 24, art. 102",
892
+ "last_amended": "2019-01-15",
893
+ "in_force": "2019-01-15",
894
+ "status": "in force",
895
+ "current_to": "2022-03-22",
896
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-45.html",
897
+ "id": "A-8.8-fr-s45",
898
+ "text": "45 La Commission ne peut recevoir ni admettre en preuve les éléments protégés par le droit de la preuve et rendus, de ce fait, inadmissibles en justice devant un tribunal judiciaire.",
899
+ "citation": "AAAMPA, art. 45",
900
+ "lang": "fr"
901
+ },
902
+ {
903
+ "act_code": "A-8.8",
904
+ "act_short": "AAAMPA",
905
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
906
+ "section": "46 à 89",
907
+ "marginal_note": "",
908
+ "part": "",
909
+ "division": "",
910
+ "heading": "Généralités",
911
+ "history": "",
912
+ "last_amended": "2019-01-15",
913
+ "in_force": "2019-01-15",
914
+ "status": "repealed",
915
+ "current_to": "2022-03-22",
916
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-46 à 89.html",
917
+ "id": "A-8.8-fr-s46 à 89",
918
+ "text": "46 à 89 [Abrogés, 2012, ch. 24, art. 102]",
919
+ "citation": "AAAMPA, art. 46 à 89",
920
+ "lang": "fr"
921
+ },
922
+ {
923
+ "act_code": "A-8.8",
924
+ "act_short": "AAAMPA",
925
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
926
+ "section": "90",
927
+ "marginal_note": "",
928
+ "part": "",
929
+ "division": "",
930
+ "heading": "Généralités",
931
+ "history": "",
932
+ "last_amended": "2019-01-15",
933
+ "in_force": "2019-01-15",
934
+ "status": "repealed",
935
+ "current_to": "2022-03-22",
936
+ "source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-90.html",
937
+ "id": "A-8.8-fr-s90",
938
+ "text": "90 [Abrogé, 2012, ch. 24, art. 102]",
939
+ "citation": "AAAMPA, art. 90",
940
+ "lang": "fr"
941
+ }
942
+ ]
data/processed/A-8.8.json ADDED
@@ -0,0 +1,933 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "act_code": "A-8.8",
4
+ "act_short": "AAAMPA",
5
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
6
+ "section": "1",
7
+ "marginal_note": "Short title",
8
+ "part": "",
9
+ "division": "",
10
+ "heading": "Short Title",
11
+ "history": "",
12
+ "last_amended": "2002-12-31",
13
+ "in_force": "2002-12-31",
14
+ "status": "in force",
15
+ "current_to": "2022-03-22",
16
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-1.html",
17
+ "id": "A-8.8-s1",
18
+ "text": "1 This Act may be cited as the Agriculture and Agri-Food Administrative Monetary Penalties Act.",
19
+ "citation": "AAAMPA, s. 1"
20
+ },
21
+ {
22
+ "act_code": "A-8.8",
23
+ "act_short": "AAAMPA",
24
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
25
+ "section": "2",
26
+ "marginal_note": "Definitions",
27
+ "part": "",
28
+ "division": "",
29
+ "heading": "Interpretation",
30
+ "history": "1995, c. 40, s. 2; 1997, c. 21, s. 30; 2002, c. 28, s. 82; 2005, c. 38, ss. 30, 145; 2012, c. 24, s. 98; 2015, c. 2, s. 113",
31
+ "last_amended": "2019-01-15",
32
+ "in_force": "2015-02-27",
33
+ "status": "in force",
34
+ "current_to": "2022-03-22",
35
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-2.html",
36
+ "id": "A-8.8-s2",
37
+ "text": "2 In this Act,\nagri-food Act means the Farm Debt Mediation Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Pest Control Products Act, the Plant Protection Act, the Safe Food for Canadians Act or the Seeds Act; (loi agroalimentaire)\nMinister means the Minister of Agriculture and Agri-Food, except that\n(a) it means the Minister of Health in relation to a violation involving a contravention of\n(i) the Pest Control Products Act, or\n(ii) a provision relating to food safety of an agri-food Act or of a regulation made under such an Act, and\n(b) it means the Minister of Public Safety and Emergency Preparedness in relation to a notice of violation issued in respect of the contravention of program legislation referred to in subsection 11(5) of the Canadian Food Inspection Agency Act; (ministre)\npenalty means an administrative monetary penalty imposed under this Act for a violation; (sanction)\nprescribed means prescribed by regulation; (Version anglaise seulement)\nTribunal means the Review Tribunal continued by subsection 27(1); (Commission)",
38
+ "citation": "AAAMPA, s. 2"
39
+ },
40
+ {
41
+ "act_code": "A-8.8",
42
+ "act_short": "AAAMPA",
43
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
44
+ "section": "3",
45
+ "marginal_note": "Purpose of Act",
46
+ "part": "",
47
+ "division": "",
48
+ "heading": "Purpose of Act",
49
+ "history": "",
50
+ "last_amended": "2002-12-31",
51
+ "in_force": "2002-12-31",
52
+ "status": "in force",
53
+ "current_to": "2022-03-22",
54
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-3.html",
55
+ "id": "A-8.8-s3",
56
+ "text": "3 The purpose of this Act is to establish, as an alternative to the existing penal system and as a supplement to existing enforcement measures, a fair and efficient administrative monetary penalty system for the enforcement of the agri-food Acts.",
57
+ "citation": "AAAMPA, s. 3"
58
+ },
59
+ {
60
+ "act_code": "A-8.8",
61
+ "act_short": "AAAMPA",
62
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
63
+ "section": "4",
64
+ "marginal_note": "Regulations",
65
+ "part": "",
66
+ "division": "",
67
+ "heading": "Powers of Minister",
68
+ "history": "1995, c. 40, s. 4; 2012, c. 24, s. 99; 2015, c. 2, s. 114; 2016, c. 9, ss. 70, 72",
69
+ "last_amended": "2019-01-15",
70
+ "in_force": "2016-12-12",
71
+ "status": "in force",
72
+ "current_to": "2022-03-22",
73
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-4.html",
74
+ "id": "A-8.8-s4-p1",
75
+ "text": "4\n(1) The Minister may make regulations\n(a) designating as a violation that may be proceeded with in accordance with this Act\n(i) the contravention of any specified provision of an agri-food Act or of a regulation made under an agri-food Act,\n(ii) the contravention of any specified order, or class of orders, made by the Minister under the Plant Protection Act, or\n(iii) the refusal or neglect to perform any specified duty, or class of duties, imposed by or under the Plant Protection Act, the Health of Animals Act, the Pest Control Products Act or the Safe Food for Canadians Act;\nif the contravention, or the failure or neglect to perform the duty, as the case may be, is an offence under an agri-food Act;\n(b) classifying each violation as a minor violation, a serious violation or a very serious violation;\n(b.1) establishing, in respect of each violation, a short-form description to be used in notices of violation;\n(c) fixing a penalty, or a range of penalties, in respect of each violation;\n(d) respecting the circumstances under which, the criteria by which and the manner in which a penalty may be increased or reduced, including the reduction of a penalty pursuant to a compliance agreement under subsection 10(1);\n(e) respecting the determination of a lesser amount that may be paid in complete satisfaction of a penalty if paid within the prescribed time and manner;\n(f) respecting the circumstances under which reviews under this Act by the Tribunal shall be oral or in writing;",
76
+ "citation": "AAAMPA, s. 4 (part 1 of 2)"
77
+ },
78
+ {
79
+ "act_code": "A-8.8",
80
+ "act_short": "AAAMPA",
81
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
82
+ "section": "4",
83
+ "marginal_note": "Regulations",
84
+ "part": "",
85
+ "division": "",
86
+ "heading": "Powers of Minister",
87
+ "history": "1995, c. 40, s. 4; 2012, c. 24, s. 99; 2015, c. 2, s. 114; 2016, c. 9, ss. 70, 72",
88
+ "last_amended": "2019-01-15",
89
+ "in_force": "2016-12-12",
90
+ "status": "in force",
91
+ "current_to": "2022-03-22",
92
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-4.html",
93
+ "id": "A-8.8-s4-p2",
94
+ "text": "(g) respecting the service of documents required or authorized to be served under this Act including, without restricting the generality of the foregoing, the manner of serving such documents, the proof of their service and the circumstances under which such documents shall be deemed to have been served;\n(h) prescribing anything that by this Act is to be prescribed; and\n(i) generally, for carrying out the purposes and provisions of this Act.\n(2) [Maximum penalties] The maximum penalty for a violation is\n(a) in the case of a violation that is committed by an individual otherwise than in the course of a business and that is not committed to obtain a financial benefit, $2,000; and\n(b) in any other case, $5,000 for a minor violation, $15,000 for a serious violation and $25,000 for a very serious violation.\n(3) [Criteria] Without restricting the generality of paragraph (1)(d), in making regulations respecting the criteria for increasing or reducing the amount of the penalty for a violation, the Minister shall include the following in any such criteria:\n(a) the degree of intention or negligence on the part of the person who committed the violation;\n(b) the harm done by the violation; and\n(c) the history of the person who committed the violation of prior violations or convictions under agri-food Acts within the five year period immediately before the violation.",
95
+ "citation": "AAAMPA, s. 4 (part 2 of 2)"
96
+ },
97
+ {
98
+ "act_code": "A-8.8",
99
+ "act_short": "AAAMPA",
100
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
101
+ "section": "5",
102
+ "marginal_note": "How act or omission may be proceeded with",
103
+ "part": "",
104
+ "division": "",
105
+ "heading": "Powers of Minister",
106
+ "history": "",
107
+ "last_amended": "2002-12-31",
108
+ "in_force": "2002-12-31",
109
+ "status": "in force",
110
+ "current_to": "2022-03-22",
111
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-5.html",
112
+ "id": "A-8.8-s5",
113
+ "text": "5 Where any act or omission can be proceeded with as a violation or as an offence, the Minister may commence proceedings in respect of that act or omission as a violation or recommend that it be proceeded with as an offence, but proceeding with it as a violation precludes proceeding with it as an offence, and proceeding with it as an offence precludes proceeding with it as a violation.",
114
+ "citation": "AAAMPA, s. 5"
115
+ },
116
+ {
117
+ "act_code": "A-8.8",
118
+ "act_short": "AAAMPA",
119
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
120
+ "section": "6",
121
+ "marginal_note": "Power of Minister — notices of violation",
122
+ "part": "",
123
+ "division": "",
124
+ "heading": "Powers of Minister",
125
+ "history": "1995, c. 40, s. 6; 2015, c. 2, s. 115",
126
+ "last_amended": "2015-02-27",
127
+ "in_force": "2015-02-27",
128
+ "status": "in force",
129
+ "current_to": "2022-03-22",
130
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-6.html",
131
+ "id": "A-8.8-s6",
132
+ "text": "6 The Minister may designate persons, or classes of persons, who are authorized to issue notices of violation.",
133
+ "citation": "AAAMPA, s. 6"
134
+ },
135
+ {
136
+ "act_code": "A-8.8",
137
+ "act_short": "AAAMPA",
138
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
139
+ "section": "7",
140
+ "marginal_note": "Commission of violation",
141
+ "part": "",
142
+ "division": "",
143
+ "heading": "Proceedings",
144
+ "history": "1995, c. 40, s. 7; 2012, c. 24, s. 100(E); 2015, c. 2, s. 116(E); 2016, c. 9, ss. 71(E), 72(E)",
145
+ "last_amended": "2019-01-15",
146
+ "in_force": "2016-12-12",
147
+ "status": "in force",
148
+ "current_to": "2022-03-22",
149
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-7.html",
150
+ "id": "A-8.8-s7",
151
+ "text": "7\n(1) Every person who\n(a) contravenes any provision of an agri-food Act or of a regulation made under an agri-food Act,\n(b) contravenes any order made by the Minister under the Plant Protection Act, or\n(c) refuses or neglects to perform any duty imposed by or under the Plant Protection Act, the Health of Animals Act, the Pest Control Products Act or the Safe Food for Canadians Act\nthe contravention of which, or the refusal or neglect of which, is designated to be a violation by a regulation made under paragraph 4(1)(a) commits a violation and is liable to a warning or to a penalty in accordance with this Act.\n(2) [Issuance of notice of violation] If a person designated under section 6 has reasonable grounds to believe that a person has committed a violation, the designated person may issue, and shall cause to be served on the person, a notice of violation that names the person, identifies the violation and\n(a) contains a warning that the person has committed a violation; or\n(b) sets out\n(i) the penalty, established in accordance with the regulations, for the violation that the person is liable to pay,\n(ii) particulars concerning the time for paying and the manner of paying the penalty, and\n(iii) subject to the regulations, a lesser amount that may be paid in complete satisfaction of the penalty if paid within the time and manner specified in the notice.\n(3) [Summary of rights] A notice of violation must clearly summarize, in plain language, the rights and obligations under this Act of the person on whom it is served, including the right to have the facts of the violation reviewed by the Minister or the Tribunal, and the procedure for requesting such a review.",
152
+ "citation": "AAAMPA, s. 7"
153
+ },
154
+ {
155
+ "act_code": "A-8.8",
156
+ "act_short": "AAAMPA",
157
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
158
+ "section": "8",
159
+ "marginal_note": "Notices with warning — request for review",
160
+ "part": "",
161
+ "division": "",
162
+ "heading": "Notices of Violation with Warning",
163
+ "history": "",
164
+ "last_amended": "2002-12-31",
165
+ "in_force": "2002-12-31",
166
+ "status": "in force",
167
+ "current_to": "2022-03-22",
168
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-8.html",
169
+ "id": "A-8.8-s8",
170
+ "text": "8\n(1) Where a notice of violation contains a warning, the person named in the notice may, in the prescribed time and manner, request a review of the facts of the violation by the Minister or the Tribunal.\n(2) [Deeming] Where a person who is served with a notice of violation that contains a warning does not request a review under subsection (1) in the prescribed time and manner, the person is deemed to have committed the violation identified in the notice of violation.",
171
+ "citation": "AAAMPA, s. 8"
172
+ },
173
+ {
174
+ "act_code": "A-8.8",
175
+ "act_short": "AAAMPA",
176
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
177
+ "section": "9",
178
+ "marginal_note": "Notices with penalty — payment",
179
+ "part": "",
180
+ "division": "",
181
+ "heading": "Notices of Violation with Penalty",
182
+ "history": "",
183
+ "last_amended": "2002-12-31",
184
+ "in_force": "2002-12-31",
185
+ "status": "in force",
186
+ "current_to": "2022-03-22",
187
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-9.html",
188
+ "id": "A-8.8-s9",
189
+ "text": "9\n(1) Where a notice of violation sets out a penalty and the person named in the notice pays, in the prescribed time and manner, the amount of the penalty or, subject to the regulations, the lesser amount set out in the notice that may be paid in lieu of the penalty,\n(a) the person is deemed to have committed the violation in respect of which the amount is paid;\n(b) the Minister shall accept that amount as and in complete satisfaction of the penalty; and\n(c) the proceedings commenced in respect of the violation under section 7 are ended.\n(2) [Alternatives to payment] Instead of paying the penalty set out in a notice of violation or, where applicable, the lesser amount that may be paid in lieu of the penalty, the person named in the notice may, in the prescribed time and manner,\n(a) if the penalty is $2,000 or more, request to enter into a compliance agreement with the Minister that ensures the person’s compliance with the agri-food Act or regulation to which the violation relates;\n(b) request a review by the Minister of the facts of the violation; or\n(c) request a review by the Tribunal of the facts of the violation.\n(3) [Deeming] Where a person who is served with a notice of violation that sets out a penalty does not pay the penalty in the prescribed time and manner or, where applicable, the lesser amount that may be paid in lieu of the penalty, and does not exercise any right referred to in subsection (2) in the prescribed time and manner, the person is deemed to have committed the violation identified in the notice.",
190
+ "citation": "AAAMPA, s. 9"
191
+ },
192
+ {
193
+ "act_code": "A-8.8",
194
+ "act_short": "AAAMPA",
195
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
196
+ "section": "10",
197
+ "marginal_note": "Compliance agreements",
198
+ "part": "",
199
+ "division": "",
200
+ "heading": "Compliance Agreements",
201
+ "history": "",
202
+ "last_amended": "2002-12-31",
203
+ "in_force": "2002-12-31",
204
+ "status": "in force",
205
+ "current_to": "2022-03-22",
206
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-10.html",
207
+ "id": "A-8.8-s10-p1",
208
+ "text": "10\n(1) After considering a request under paragraph 9(2)(a), the Minister may enter into a compliance agreement, as described in that paragraph, with the person making the request on such terms and conditions as are satisfactory to the Minister, which terms may\n(a) include a provision for the giving of reasonable security, in a form and in an amount satisfactory to the Minister, as a guarantee that the person will comply with the compliance agreement; and\n(b) provide for the reduction, in whole or in part, of the penalty for the violation.\n(2) [Deeming] A person who makes a request under paragraph 9(2)(a) and who enters into a compliance agreement with the Minister shall, on entering into the compliance agreement, be deemed to have committed the violation in respect of which the compliance agreement was entered into.\n(3) [Where compliance agreement complied with] Where the Minister is satisfied that a person who has entered into a compliance agreement has complied with the agreement, the Minister shall cause a notice to that effect to be served on the person and, on the service of that notice,\n(a) the proceedings commenced in respect of the violation under section 7 are ended; and\n(b) any security given under the compliance agreement by the person shall be returned to the person.\n(4) [Where compliance agreement not complied with] Where the Minister is of the opinion that a person who has entered into a compliance agreement has not complied with the agreement, the Minister shall cause a notice of default to be served on the person to the effect that",
209
+ "citation": "AAAMPA, s. 10 (part 1 of 2)"
210
+ },
211
+ {
212
+ "act_code": "A-8.8",
213
+ "act_short": "AAAMPA",
214
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
215
+ "section": "10",
216
+ "marginal_note": "Compliance agreements",
217
+ "part": "",
218
+ "division": "",
219
+ "heading": "Compliance Agreements",
220
+ "history": "",
221
+ "last_amended": "2002-12-31",
222
+ "in_force": "2002-12-31",
223
+ "status": "in force",
224
+ "current_to": "2022-03-22",
225
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-10.html",
226
+ "id": "A-8.8-s10-p2",
227
+ "text": "(a) instead of the penalty set out in the notice of violation in respect of which the compliance agreement was entered into, the person is liable to pay twice the amount of that penalty and, for greater certainty, subsection 4(2) does not apply in respect of that amount; or\n(b) the security, if any, given under the compliance agreement by the person shall be forfeited to Her Majesty in right of Canada.\n(5) [Effect of notice of default] On the service of a notice under subsection (4), the person served has no right of set-off against any amount spent by the person under the compliance agreement and\n(a) the person served is liable to pay the amount set out in the notice; or\n(b) where the notice of default provides for the forfeiture of the security given under the compliance agreement, that security is forfeited to Her Majesty in right of Canada and the proceedings commenced in respect of the violation under section 7 are ended.\n(6) [Effect of payment] Where a person pays the amount set out in a notice of default under subsection (4) in the prescribed time and manner,\n(a) the Minister shall accept the amount as and in complete satisfaction of the amount owing; and\n(b) the proceedings commenced in respect of the violation under section 7 are ended.",
228
+ "citation": "AAAMPA, s. 10 (part 2 of 2)"
229
+ },
230
+ {
231
+ "act_code": "A-8.8",
232
+ "act_short": "AAAMPA",
233
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
234
+ "section": "11",
235
+ "marginal_note": "Refusal to enter into compliance agreement",
236
+ "part": "",
237
+ "division": "",
238
+ "heading": "Compliance Agreements",
239
+ "history": "1995, c. 40, s. 11; 2015, c. 2, s. 117(E)",
240
+ "last_amended": "2015-02-27",
241
+ "in_force": "2015-02-27",
242
+ "status": "in force",
243
+ "current_to": "2022-03-22",
244
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-11.html",
245
+ "id": "A-8.8-s11",
246
+ "text": "11\n(1) Where the Minister refuses to enter into a compliance agreement pursuant to a request under paragraph 9(2)(a), the person who made the request may, in the prescribed time and manner,\n(a) pay the amount of the penalty; or\n(b) request a review by the Tribunal of the facts of the violation.\n(2) [Effect of payment] Where a person pays the amount referred to in paragraph (1)(a),\n(a) the person is deemed to have committed the violation in respect of which the payment is made;\n(b) the Minister shall accept the amount as and in complete satisfaction of the penalty; and\n(c) the proceedings commenced in respect of the violation under section 7 are ended.\n(3) [Deeming] If a person does not, in the prescribed time and manner, either pay the amount referred to in paragraph (1)(a) or request a review under paragraph (1)(b), the person is deemed to have committed the violation identified in the notice of violation.",
247
+ "citation": "AAAMPA, s. 11"
248
+ },
249
+ {
250
+ "act_code": "A-8.8",
251
+ "act_short": "AAAMPA",
252
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
253
+ "section": "12",
254
+ "marginal_note": "Review under section 8",
255
+ "part": "",
256
+ "division": "",
257
+ "heading": "Review by Minister",
258
+ "history": "",
259
+ "last_amended": "2002-12-31",
260
+ "in_force": "2002-12-31",
261
+ "status": "in force",
262
+ "current_to": "2022-03-22",
263
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-12.html",
264
+ "id": "A-8.8-s12",
265
+ "text": "12\n(1) After concluding a review requested under section 8, the Minister shall determine whether or not the person committed the violation, and the Minister shall cause a notice of any decision under this subsection to be served on the person who requested the review.\n(2) [Right to review] Where the Minister decides under subsection (1) that a person has committed a violation, the person may, in the prescribed time and manner, request a review of the Minister’s decision by the Tribunal.",
266
+ "citation": "AAAMPA, s. 12"
267
+ },
268
+ {
269
+ "act_code": "A-8.8",
270
+ "act_short": "AAAMPA",
271
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
272
+ "section": "13",
273
+ "marginal_note": "Review under paragraph 9(2)(b)",
274
+ "part": "",
275
+ "division": "",
276
+ "heading": "Review by Minister",
277
+ "history": "",
278
+ "last_amended": "2002-12-31",
279
+ "in_force": "2002-12-31",
280
+ "status": "in force",
281
+ "current_to": "2022-03-22",
282
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-13.html",
283
+ "id": "A-8.8-s13",
284
+ "text": "13\n(1) After concluding a review requested under paragraph 9(2)(b), the Minister shall determine whether or not the person requesting the review committed a violation and, where the Minister decides that the person committed a violation but considers that the amount of the penalty for the violation was not established in accordance with the regulations, the Minister shall correct the amount of the penalty for the violation, and the Minister shall cause a notice of any decision under this subsection to be served on the person who requested the review.\n(2) [Payment or right to review] Where the Minister decides under subsection (1) that a person has committed a violation, the person may, in the prescribed time and manner,\n(a) pay the amount of the penalty set out in the notice referred to in subsection (1), in which case\n(i) the Minister shall accept the amount as and in complete satisfaction of the penalty, and\n(ii) the proceedings commenced in respect of the violation under section 7 are ended; or\n(b) request a review of the Minister’s decision by the Tribunal.",
285
+ "citation": "AAAMPA, s. 13"
286
+ },
287
+ {
288
+ "act_code": "A-8.8",
289
+ "act_short": "AAAMPA",
290
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
291
+ "section": "14",
292
+ "marginal_note": "Review by Tribunal",
293
+ "part": "",
294
+ "division": "",
295
+ "heading": "Review by Tribunal",
296
+ "history": "",
297
+ "last_amended": "2002-12-31",
298
+ "in_force": "2002-12-31",
299
+ "status": "in force",
300
+ "current_to": "2022-03-22",
301
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-14.html",
302
+ "id": "A-8.8-s14",
303
+ "text": "14\n(1) After concluding a review requested under this Act, the Tribunal shall, by order, as the case may be,\n(a) confirm, vary or set aside any decision of the Minister under section 12 or 13, or\n(b) determine whether or not the person requesting the review committed a violation and, where the Tribunal decides that the person committed a violation but considers that the amount of the penalty for the violation, if any, was not established in accordance with the regulations, the Tribunal shall correct the amount of the penalty,\nand the Tribunal shall cause a notice of any order made under this subsection to be served on the person who requested the review, and on the Minister.\n(2) [Payment] Where the Tribunal decides under subsection (1) that a person has committed a violation, the person is liable for the amount of the penalty as set out in the order of the Tribunal and, on the payment of that amount in the time and manner specified in the order,\n(a) the Minister shall accept the amount as and in complete satisfaction of the penalty; and\n(b) the proceedings commenced in respect of the violation under section 7 are ended.",
304
+ "citation": "AAAMPA, s. 14"
305
+ },
306
+ {
307
+ "act_code": "A-8.8",
308
+ "act_short": "AAAMPA",
309
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
310
+ "section": "15",
311
+ "marginal_note": "Debts to Her Majesty",
312
+ "part": "",
313
+ "division": "",
314
+ "heading": "Enforcement",
315
+ "history": "1995, c. 40, s. 15; 2012, c. 24, s. 101; 2015, c. 2, s. 118(F)",
316
+ "last_amended": "2019-01-15",
317
+ "in_force": "2015-02-27",
318
+ "status": "in force",
319
+ "current_to": "2022-03-22",
320
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-15.html",
321
+ "id": "A-8.8-s15",
322
+ "text": "15\n(1) The following amounts constitute debts due to Her Majesty in right of Canada that may be recovered as such in the Federal Court:\n(a) the amount of a penalty, from the time the notice of violation setting out the penalty is served;\n(b) every amount undertaken to be paid pursuant to a compliance agreement entered into with the Minister under subsection 10(1), from the time the compliance agreement is entered into;\n(c) the amount set out in a notice of default referred to in subsection 10(4), from the time the notice is served;\n(d) the amount of a penalty as set out in a decision of the Minister under subsection 13(1), from the time the notice under that subsection is served;\n(e) the amount of a penalty as set out in an order of the Tribunal under subsection 14(1), from the expiration of the time specified in the order for the payment of that amount; and\n(f) the amount of any reasonable expenses incurred pursuant to section 22, from the date they are incurred.\n(2) [Time limit] No proceedings to recover a debt referred to in subsection (1) may be commenced later than five years after the debt became payable.\n(3) [Debt final] A debt referred to in subsection (1) is final and not subject to review or to be restrained, prohibited, removed, set aside or otherwise dealt with except to the extent and in the manner provided by sections 9 to 14.",
323
+ "citation": "AAAMPA, s. 15"
324
+ },
325
+ {
326
+ "act_code": "A-8.8",
327
+ "act_short": "AAAMPA",
328
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
329
+ "section": "16",
330
+ "marginal_note": "Certificate of default",
331
+ "part": "",
332
+ "division": "",
333
+ "heading": "Enforcement",
334
+ "history": "",
335
+ "last_amended": "2002-12-31",
336
+ "in_force": "2002-12-31",
337
+ "status": "in force",
338
+ "current_to": "2022-03-22",
339
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-16.html",
340
+ "id": "A-8.8-s16",
341
+ "text": "16\n(1) Any debt referred to in subsection 15(1) in respect of which there is a default of payment, or the part of any such debt that has not been paid, may be certified by the Minister.\n(2) [Judgments] On production to the Federal Court, a certificate made under subsection (1) shall be registered in that Court and, when registered, has the same force and effect, and all proceedings may be taken on the certificate, as if it were a judgment obtained in that Court for a debt of the amount specified in the certificate and all reasonable costs and charges attendant in the registration of the certificate.",
342
+ "citation": "AAAMPA, s. 16"
343
+ },
344
+ {
345
+ "act_code": "A-8.8",
346
+ "act_short": "AAAMPA",
347
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
348
+ "section": "17",
349
+ "marginal_note": "Violations not offences",
350
+ "part": "",
351
+ "division": "",
352
+ "heading": "Rules of Law About Violations",
353
+ "history": "",
354
+ "last_amended": "2002-12-31",
355
+ "in_force": "2002-12-31",
356
+ "status": "in force",
357
+ "current_to": "2022-03-22",
358
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-17.html",
359
+ "id": "A-8.8-s17",
360
+ "text": "17 For greater certainty, a violation is not an offence and, accordingly, section 126 of the Criminal Code does not apply.",
361
+ "citation": "AAAMPA, s. 17"
362
+ },
363
+ {
364
+ "act_code": "A-8.8",
365
+ "act_short": "AAAMPA",
366
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
367
+ "section": "18",
368
+ "marginal_note": "Certain defences not available",
369
+ "part": "",
370
+ "division": "",
371
+ "heading": "Rules of Law About Violations",
372
+ "history": "",
373
+ "last_amended": "2002-12-31",
374
+ "in_force": "2002-12-31",
375
+ "status": "in force",
376
+ "current_to": "2022-03-22",
377
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-18.html",
378
+ "id": "A-8.8-s18",
379
+ "text": "18\n(1) A person named in a notice of violation does not have a defence by reason that the person\n(a) exercised due diligence to prevent the violation; or\n(b) reasonably and honestly believed in the existence of facts that, if true, would exonerate the person.\n(2) [Common law principles] Every rule and principle of the common law that renders any circumstance a justification or excuse in relation to a charge for an offence under an agri-food Act applies in respect of a violation to the extent that it is not inconsistent with this Act.",
380
+ "citation": "AAAMPA, s. 18"
381
+ },
382
+ {
383
+ "act_code": "A-8.8",
384
+ "act_short": "AAAMPA",
385
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
386
+ "section": "19",
387
+ "marginal_note": "Burden of proof",
388
+ "part": "",
389
+ "division": "",
390
+ "heading": "Rules of Law About Violations",
391
+ "history": "",
392
+ "last_amended": "2002-12-31",
393
+ "in_force": "2002-12-31",
394
+ "status": "in force",
395
+ "current_to": "2022-03-22",
396
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-19.html",
397
+ "id": "A-8.8-s19",
398
+ "text": "19 In every case where the facts of a violation are reviewed by the Minister or by the Tribunal, the Minister must establish, on a balance of probabilities, that the person named in the notice of violation committed the violation identified in the notice.",
399
+ "citation": "AAAMPA, s. 19"
400
+ },
401
+ {
402
+ "act_code": "A-8.8",
403
+ "act_short": "AAAMPA",
404
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
405
+ "section": "20",
406
+ "marginal_note": "Vicarious liability — licence holders, etc.",
407
+ "part": "",
408
+ "division": "",
409
+ "heading": "Rules of Law About Violations",
410
+ "history": "",
411
+ "last_amended": "2002-12-31",
412
+ "in_force": "2002-12-31",
413
+ "status": "in force",
414
+ "current_to": "2022-03-22",
415
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-20.html",
416
+ "id": "A-8.8-s20",
417
+ "text": "20\n(1) The holder of a licence, certificate, letter of accreditation, permit, notice or other document issued under an agri-food Act is liable for a violation that is committed in respect of any matter relating to any activity or requirement under that document, whether or not the person who actually committed the violation is identified or proceeded against in accordance with this Act.\n(2) [Vicarious liability — acts of employees and agents] A person is liable for a violation that is committed by any employee or agent of the person acting in the course of the employee’s employment or the scope of the agent’s authority, whether or not the employee or agent who actually committed the violation is identified or proceeded against in accordance with this Act.",
418
+ "citation": "AAAMPA, s. 20"
419
+ },
420
+ {
421
+ "act_code": "A-8.8",
422
+ "act_short": "AAAMPA",
423
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
424
+ "section": "21",
425
+ "marginal_note": "Continuing violation",
426
+ "part": "",
427
+ "division": "",
428
+ "heading": "Rules of Law About Violations",
429
+ "history": "",
430
+ "last_amended": "2002-12-31",
431
+ "in_force": "2002-12-31",
432
+ "status": "in force",
433
+ "current_to": "2022-03-22",
434
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-21.html",
435
+ "id": "A-8.8-s21",
436
+ "text": "21 A violation that is continued on more than one day constitutes a separate violation in respect of each day during which it is continued.",
437
+ "citation": "AAAMPA, s. 21"
438
+ },
439
+ {
440
+ "act_code": "A-8.8",
441
+ "act_short": "AAAMPA",
442
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
443
+ "section": "22",
444
+ "marginal_note": "Forfeiture",
445
+ "part": "",
446
+ "division": "",
447
+ "heading": "Rules of Law About Violations",
448
+ "history": "",
449
+ "last_amended": "2002-12-31",
450
+ "in_force": "2002-12-31",
451
+ "status": "in force",
452
+ "current_to": "2022-03-22",
453
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-22.html",
454
+ "id": "A-8.8-s22",
455
+ "text": "22 Where\n(a) a person is deemed by this Act to have committed a violation, or\n(b) the Minister, pursuant to a review under this Act, has decided that a person has committed a violation and no request to review the Minister’s decision has been made to the Tribunal in the prescribed time and manner,\nanything seized and detained under an agri-food Act in relation to the violation is, at the election of Her Majesty in right of Canada, immediately forfeited to Her Majesty in right of Canada and may be disposed of, at the expense of the person from whom it was seized, in accordance with the regulations made under the applicable agri-food Act unless the Minister directs otherwise.",
456
+ "citation": "AAAMPA, s. 22"
457
+ },
458
+ {
459
+ "act_code": "A-8.8",
460
+ "act_short": "AAAMPA",
461
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
462
+ "section": "23",
463
+ "marginal_note": "Notations removed",
464
+ "part": "",
465
+ "division": "",
466
+ "heading": "General Provisions",
467
+ "history": "",
468
+ "last_amended": "2002-12-31",
469
+ "in_force": "2002-12-31",
470
+ "status": "in force",
471
+ "current_to": "2022-03-22",
472
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-23.html",
473
+ "id": "A-8.8-s23",
474
+ "text": "23\n(1) Any notation of a violation shall, on application by the person who committed the violation, be removed from any records that may be kept by the Minister respecting that person after the expiration of five years from\n(a) where the notice of violation contained a warning, the date the notice was served, or\n(b) in any other case, the payment of any debt referred to in subsection 15(1),\nunless the removal from the record would not in the opinion of the Minister be in the public interest or another notation of a violation has been recorded by the Minister in respect of that person after that date and has not been removed in accordance with this subsection.\n(2) [Duty to notify] The Minister shall cause a notice of removal to be served on the person in respect of whom a notation is removed pursuant to subsection (1).",
475
+ "citation": "AAAMPA, s. 23"
476
+ },
477
+ {
478
+ "act_code": "A-8.8",
479
+ "act_short": "AAAMPA",
480
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
481
+ "section": "24",
482
+ "marginal_note": "Service of documents",
483
+ "part": "",
484
+ "division": "",
485
+ "heading": "General Provisions",
486
+ "history": "",
487
+ "last_amended": "2002-12-31",
488
+ "in_force": "2002-12-31",
489
+ "status": "in force",
490
+ "current_to": "2022-03-22",
491
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-24.html",
492
+ "id": "A-8.8-s24",
493
+ "text": "24 Every document required or authorized to be served under this Act shall be served in accordance with the regulations, either personally or in such other manner as may be authorized in the regulations.",
494
+ "citation": "AAAMPA, s. 24"
495
+ },
496
+ {
497
+ "act_code": "A-8.8",
498
+ "act_short": "AAAMPA",
499
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
500
+ "section": "25",
501
+ "marginal_note": "Evidence",
502
+ "part": "",
503
+ "division": "",
504
+ "heading": "General Provisions",
505
+ "history": "",
506
+ "last_amended": "2002-12-31",
507
+ "in_force": "2002-12-31",
508
+ "status": "in force",
509
+ "current_to": "2022-03-22",
510
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-25.html",
511
+ "id": "A-8.8-s25",
512
+ "text": "25 In any proceeding for a violation or for a prosecution for an offence, a notice of violation purporting to be issued pursuant to this Act is admissible in evidence without proof of the signature or official character of the person appearing to have signed the notice of violation.",
513
+ "citation": "AAAMPA, s. 25"
514
+ },
515
+ {
516
+ "act_code": "A-8.8",
517
+ "act_short": "AAAMPA",
518
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
519
+ "section": "26",
520
+ "marginal_note": "Limitation period",
521
+ "part": "",
522
+ "division": "",
523
+ "heading": "General Provisions",
524
+ "history": "1995, c. 40, s. 26; 2015, c. 2, s. 119",
525
+ "last_amended": "2015-02-27",
526
+ "in_force": "2015-02-27",
527
+ "status": "in force",
528
+ "current_to": "2022-03-22",
529
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-26.html",
530
+ "id": "A-8.8-s26",
531
+ "text": "26 No proceedings in respect of a violation may be commenced later than\n(a) six months after the day on which the subject matter of the proceedings arises, in the case of a minor violation; or\n(b) two years after the day on which the subject matter of the proceedings arises, in the case of a serious violation or a very serious violation.",
532
+ "citation": "AAAMPA, s. 26"
533
+ },
534
+ {
535
+ "act_code": "A-8.8",
536
+ "act_short": "AAAMPA",
537
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
538
+ "section": "27",
539
+ "marginal_note": "Review Tribunal continued",
540
+ "part": "",
541
+ "division": "",
542
+ "heading": "Composition",
543
+ "history": "1995, c. 40, s. 27; 2012, c. 24, s. 102",
544
+ "last_amended": "2019-01-15",
545
+ "in_force": "2019-01-15",
546
+ "status": "in force",
547
+ "current_to": "2022-03-22",
548
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-27.html",
549
+ "id": "A-8.8-s27",
550
+ "text": "27\n(1) The Review Tribunal, continued by subsection 4.1(1) of the Canada Agricultural Products Act, chapter 20 of the 4th Supplement to the Revised Statutes of Canada, 1985, is continued.\n(2) [Composition] The Tribunal consists of members to be appointed by the Governor in Council, one of whom is to be appointed as Chairperson.",
551
+ "citation": "AAAMPA, s. 27"
552
+ },
553
+ {
554
+ "act_code": "A-8.8",
555
+ "act_short": "AAAMPA",
556
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
557
+ "section": "28",
558
+ "marginal_note": "Qualifications",
559
+ "part": "",
560
+ "division": "",
561
+ "heading": "Composition",
562
+ "history": "1995, c. 40, s. 28; 2012, c. 24, s. 102",
563
+ "last_amended": "2019-01-15",
564
+ "in_force": "2019-01-15",
565
+ "status": "in force",
566
+ "current_to": "2022-03-22",
567
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-28.html",
568
+ "id": "A-8.8-s28",
569
+ "text": "28 A person is not eligible to be appointed as a member unless the person is knowledgeable about or has experience related to agriculture or agri-food and the Chairperson and at least one other member must, in addition, be a lawyer of at least ten years’ standing at the bar of any province or a notary of at least ten years’ standing at the Chambre des notaires du Québec.",
570
+ "citation": "AAAMPA, s. 28"
571
+ },
572
+ {
573
+ "act_code": "A-8.8",
574
+ "act_short": "AAAMPA",
575
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
576
+ "section": "29",
577
+ "marginal_note": "Membership",
578
+ "part": "",
579
+ "division": "",
580
+ "heading": "Composition",
581
+ "history": "1995, c. 40, s. 29; 2012, c. 24, s. 102",
582
+ "last_amended": "2019-01-15",
583
+ "in_force": "2019-01-15",
584
+ "status": "in force",
585
+ "current_to": "2022-03-22",
586
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-29.html",
587
+ "id": "A-8.8-s29",
588
+ "text": "29 The Chairperson is to be appointed as a full-time member and the other members are to be appointed as either full-time members or part-time members.",
589
+ "citation": "AAAMPA, s. 29"
590
+ },
591
+ {
592
+ "act_code": "A-8.8",
593
+ "act_short": "AAAMPA",
594
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
595
+ "section": "30",
596
+ "marginal_note": "Tenure",
597
+ "part": "",
598
+ "division": "",
599
+ "heading": "Composition",
600
+ "history": "1995, c. 40, s. 30; 2012, c. 24, s. 102",
601
+ "last_amended": "2019-01-15",
602
+ "in_force": "2019-01-15",
603
+ "status": "in force",
604
+ "current_to": "2022-03-22",
605
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-30.html",
606
+ "id": "A-8.8-s30",
607
+ "text": "30\n(1) Each member is to be appointed for a term of not more than five years and holds office during good behaviour, but may be removed by the Governor in Council for cause.\n(2) [Re-appointment] Each member may be re-appointed as a member in the same or another capacity.",
608
+ "citation": "AAAMPA, s. 30"
609
+ },
610
+ {
611
+ "act_code": "A-8.8",
612
+ "act_short": "AAAMPA",
613
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
614
+ "section": "31",
615
+ "marginal_note": "No other federal public administration",
616
+ "part": "",
617
+ "division": "",
618
+ "heading": "Composition",
619
+ "history": "1995, c. 40, s. 31; 2012, c. 24, s. 102",
620
+ "last_amended": "2019-01-15",
621
+ "in_force": "2019-01-15",
622
+ "status": "in force",
623
+ "current_to": "2022-03-22",
624
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-31.html",
625
+ "id": "A-8.8-s31",
626
+ "text": "31 A member must not hold any other office in the federal public administration.",
627
+ "citation": "AAAMPA, s. 31"
628
+ },
629
+ {
630
+ "act_code": "A-8.8",
631
+ "act_short": "AAAMPA",
632
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
633
+ "section": "32",
634
+ "marginal_note": "Conflict of interest",
635
+ "part": "",
636
+ "division": "",
637
+ "heading": "Composition",
638
+ "history": "1995, c. 40, s. 32; 2012, c. 24, s. 102",
639
+ "last_amended": "2019-01-15",
640
+ "in_force": "2019-01-15",
641
+ "status": "in force",
642
+ "current_to": "2022-03-22",
643
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-32.html",
644
+ "id": "A-8.8-s32",
645
+ "text": "32 A member must not accept or hold any office or employment that is inconsistent with the member’s duties or take part in any matter before the Tribunal in which the member has an interest.",
646
+ "citation": "AAAMPA, s. 32"
647
+ },
648
+ {
649
+ "act_code": "A-8.8",
650
+ "act_short": "AAAMPA",
651
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
652
+ "section": "33",
653
+ "marginal_note": "Duties of Chairperson",
654
+ "part": "",
655
+ "division": "",
656
+ "heading": "Chairperson",
657
+ "history": "1995, c. 40, s. 33; 2012, c. 24, s. 102; 2014, c. 20, s. 480",
658
+ "last_amended": "2019-01-15",
659
+ "in_force": "2019-01-15",
660
+ "status": "in force",
661
+ "current_to": "2022-03-22",
662
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-33.html",
663
+ "id": "A-8.8-s33",
664
+ "text": "33\n(1) The Chairperson apportions work among the Tribunal’s members.\n(2) [Absence or incapacity of Chairperson] If the Chairperson is absent or unable to act or the Chairperson’s position becomes vacant, the members must designate a member with the legal qualifications described in section 28 to act as Chairperson pending the appointment of a replacement, but no person may so act for a period exceeding 60 days without the approval of the Governor in Council.",
665
+ "citation": "AAAMPA, s. 33"
666
+ },
667
+ {
668
+ "act_code": "A-8.8",
669
+ "act_short": "AAAMPA",
670
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
671
+ "section": "34",
672
+ "marginal_note": "Remuneration",
673
+ "part": "",
674
+ "division": "",
675
+ "heading": "Remuneration and Expenses",
676
+ "history": "1995, c. 40, s. 34; 2012, c. 24, s. 102",
677
+ "last_amended": "2019-01-15",
678
+ "in_force": "2019-01-15",
679
+ "status": "in force",
680
+ "current_to": "2022-03-22",
681
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-34.html",
682
+ "id": "A-8.8-s34",
683
+ "text": "34\n(1) Each full-time member is to be paid the salary that is fixed by the Governor in Council and each part-time member is entitled to be paid the fees or other remuneration that is fixed by the Governor in Council.\n(2) [Travel and living expenses] Members are entitled to be paid, in accordance with Treasury Board directives, reasonable travel and living expenses incurred in the performance of their duties and functions while absent from their ordinary place of work, in the case of full-time members, or from their ordinary place of residence, in the case of part-time members.",
684
+ "citation": "AAAMPA, s. 34"
685
+ },
686
+ {
687
+ "act_code": "A-8.8",
688
+ "act_short": "AAAMPA",
689
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
690
+ "section": "35",
691
+ "marginal_note": "",
692
+ "part": "",
693
+ "division": "",
694
+ "heading": "Remuneration and Expenses",
695
+ "history": "",
696
+ "last_amended": "2019-01-15",
697
+ "in_force": "2019-01-15",
698
+ "status": "repealed",
699
+ "current_to": "2022-03-22",
700
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-35.html",
701
+ "id": "A-8.8-s35",
702
+ "text": "35 [Repealed, 2014, c. 20, s. 480]",
703
+ "citation": "AAAMPA, s. 35"
704
+ },
705
+ {
706
+ "act_code": "A-8.8",
707
+ "act_short": "AAAMPA",
708
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
709
+ "section": "36",
710
+ "marginal_note": "",
711
+ "part": "",
712
+ "division": "",
713
+ "heading": "Remuneration and Expenses",
714
+ "history": "",
715
+ "last_amended": "2019-01-15",
716
+ "in_force": "2019-01-15",
717
+ "status": "repealed",
718
+ "current_to": "2022-03-22",
719
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-36.html",
720
+ "id": "A-8.8-s36",
721
+ "text": "36 [Repealed, 2014, c. 20, s. 480]",
722
+ "citation": "AAAMPA, s. 36"
723
+ },
724
+ {
725
+ "act_code": "A-8.8",
726
+ "act_short": "AAAMPA",
727
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
728
+ "section": "37",
729
+ "marginal_note": "Head office",
730
+ "part": "",
731
+ "division": "",
732
+ "heading": "Head Office",
733
+ "history": "1995, c. 40, s. 37; 2012, c. 24, s. 102",
734
+ "last_amended": "2019-01-15",
735
+ "in_force": "2019-01-15",
736
+ "status": "in force",
737
+ "current_to": "2022-03-22",
738
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-37.html",
739
+ "id": "A-8.8-s37",
740
+ "text": "37\n(1) The head office of the Tribunal is to be in the National Capital Region as defined in section 2 of the National Capital Act.\n(2) [Sittings] The Tribunal is to sit at the places in Canada that may be specified by the Governor in Council.",
741
+ "citation": "AAAMPA, s. 37"
742
+ },
743
+ {
744
+ "act_code": "A-8.8",
745
+ "act_short": "AAAMPA",
746
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
747
+ "section": "38",
748
+ "marginal_note": "Tribunal",
749
+ "part": "",
750
+ "division": "",
751
+ "heading": "Jurisdiction",
752
+ "history": "1995, c. 40, s. 38; 2012, c. 24, s. 102",
753
+ "last_amended": "2019-01-15",
754
+ "in_force": "2019-01-15",
755
+ "status": "in force",
756
+ "current_to": "2022-03-22",
757
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-38.html",
758
+ "id": "A-8.8-s38",
759
+ "text": "38\n(1) The Tribunal has sole and exclusive jurisdiction to hear and determine all questions of fact or law in relation to any matter over which it is given jurisdiction under this Act or any other Act of Parliament.\n(2) [Review by Federal Court] An order of the Tribunal may only be reviewed under the Federal Courts Act.",
760
+ "citation": "AAAMPA, s. 38"
761
+ },
762
+ {
763
+ "act_code": "A-8.8",
764
+ "act_short": "AAAMPA",
765
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
766
+ "section": "39",
767
+ "marginal_note": "Chairperson",
768
+ "part": "",
769
+ "division": "",
770
+ "heading": "Jurisdiction",
771
+ "history": "1995, c. 40, s. 39; 2012, c. 24, s. 102",
772
+ "last_amended": "2019-01-15",
773
+ "in_force": "2019-01-15",
774
+ "status": "in force",
775
+ "current_to": "2022-03-22",
776
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-39.html",
777
+ "id": "A-8.8-s39",
778
+ "text": "39\n(1) The jurisdiction of the Tribunal in relation to the following matters is to be exercised by the Chairperson:\n(a) requests under subsection 8(1) or 12(2) for a review in respect of a notice of violation that contains a warning; and\n(b) requests under paragraph 9(2)(c) or 13(2)(b) for a review in respect of a notice of violation that sets out a penalty of less than $2,000.\n(2) [Other legally qualified members] The jurisdiction of the Tribunal in relation to a matter referred to in subsection (1) may be exercised, if the Chairperson so directs, by any member of the Tribunal with the legal qualifications described in section 28.",
779
+ "citation": "AAAMPA, s. 39"
780
+ },
781
+ {
782
+ "act_code": "A-8.8",
783
+ "act_short": "AAAMPA",
784
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
785
+ "section": "40",
786
+ "marginal_note": "Reviews",
787
+ "part": "",
788
+ "division": "",
789
+ "heading": "Jurisdiction",
790
+ "history": "1995, c. 40, s. 40; 2012, c. 24, s. 102",
791
+ "last_amended": "2019-01-15",
792
+ "in_force": "2019-01-15",
793
+ "status": "in force",
794
+ "current_to": "2022-03-22",
795
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-40.html",
796
+ "id": "A-8.8-s40",
797
+ "text": "40 Reviews by the Tribunal are to be heard by a single member.",
798
+ "citation": "AAAMPA, s. 40"
799
+ },
800
+ {
801
+ "act_code": "A-8.8",
802
+ "act_short": "AAAMPA",
803
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
804
+ "section": "41",
805
+ "marginal_note": "Court of record",
806
+ "part": "",
807
+ "division": "",
808
+ "heading": "Powers",
809
+ "history": "1995, c. 40, s. 41; 2012, c. 24, s. 102",
810
+ "last_amended": "2019-01-15",
811
+ "in_force": "2019-01-15",
812
+ "status": "in force",
813
+ "current_to": "2022-03-22",
814
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-41.html",
815
+ "id": "A-8.8-s41",
816
+ "text": "41\n(1) The Tribunal is a court of record with an official seal that must be judicially noticed.\n(2) [Examination of witnesses, etc.] In addition to the powers conferred by subsection (1), the Tribunal has, with respect to the appearance, swearing and examination of witnesses, the production and inspection of documents and other things, the enforcement of its orders and other matters necessary or proper for the due exercise of its jurisdiction, all the powers, rights and privileges that are vested in a superior court of record and, without limiting the generality of the foregoing, it may\n(a) issue a summons requiring a person\n(i) to appear at the time and place stated in the summons to testify to all matters within the person’s knowledge relative to any subject matter before the Tribunal, and\n(ii) to bring and produce any document, book or paper in the person’s possession or under the person’s control relative to that subject matter;\n(b) administer oaths and examine any person on oath; and\n(c) during a hearing, receive any evidence that it considers relevant and trustworthy.",
817
+ "citation": "AAAMPA, s. 41"
818
+ },
819
+ {
820
+ "act_code": "A-8.8",
821
+ "act_short": "AAAMPA",
822
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
823
+ "section": "42",
824
+ "marginal_note": "Rules",
825
+ "part": "",
826
+ "division": "",
827
+ "heading": "Rules",
828
+ "history": "1995, c. 40, s. 42; 2012, c. 24, s. 102",
829
+ "last_amended": "2019-01-15",
830
+ "in_force": "2019-01-15",
831
+ "status": "in force",
832
+ "current_to": "2022-03-22",
833
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-42.html",
834
+ "id": "A-8.8-s42",
835
+ "text": "42 The Tribunal may, with the approval of the Governor in Council, make rules governing\n(a) the practice and procedure in respect of hearings;\n(b) the time and manner in which applications and notices must be made or given; and\n(c) the work of the Tribunal under this or any other Act of Parliament.",
836
+ "citation": "AAAMPA, s. 42"
837
+ },
838
+ {
839
+ "act_code": "A-8.8",
840
+ "act_short": "AAAMPA",
841
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
842
+ "section": "43",
843
+ "marginal_note": "Consultations",
844
+ "part": "",
845
+ "division": "",
846
+ "heading": "General",
847
+ "history": "1995, c. 40, s. 43; 2012, c. 24, s. 102",
848
+ "last_amended": "2019-01-15",
849
+ "in_force": "2019-01-15",
850
+ "status": "in force",
851
+ "current_to": "2022-03-22",
852
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-43.html",
853
+ "id": "A-8.8-s43",
854
+ "text": "43 The members of the Tribunal may consult with other members of the Tribunal in respect of any matter before it.",
855
+ "citation": "AAAMPA, s. 43"
856
+ },
857
+ {
858
+ "act_code": "A-8.8",
859
+ "act_short": "AAAMPA",
860
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
861
+ "section": "44",
862
+ "marginal_note": "Rules of evidence",
863
+ "part": "",
864
+ "division": "",
865
+ "heading": "General",
866
+ "history": "1995, c. 40, s. 44; 2012, c. 24, s. 102",
867
+ "last_amended": "2019-01-15",
868
+ "in_force": "2019-01-15",
869
+ "status": "in force",
870
+ "current_to": "2022-03-22",
871
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-44.html",
872
+ "id": "A-8.8-s44",
873
+ "text": "44 The Tribunal is not bound by any legal or technical rules of evidence in conducting any matter that comes before it. It must deal with matters that come before it as informally and expeditiously as the circumstances and considerations of fairness and natural justice permit.",
874
+ "citation": "AAAMPA, s. 44"
875
+ },
876
+ {
877
+ "act_code": "A-8.8",
878
+ "act_short": "AAAMPA",
879
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
880
+ "section": "45",
881
+ "marginal_note": "Privileged evidence not receivable",
882
+ "part": "",
883
+ "division": "",
884
+ "heading": "General",
885
+ "history": "1995, c. 40, s. 45; 2012, c. 24, s. 102",
886
+ "last_amended": "2019-01-15",
887
+ "in_force": "2019-01-15",
888
+ "status": "in force",
889
+ "current_to": "2022-03-22",
890
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-45.html",
891
+ "id": "A-8.8-s45",
892
+ "text": "45 The Tribunal is not entitled to receive or accept as evidence anything that would be inadmissible in a court by reason of any privilege under the law of evidence.",
893
+ "citation": "AAAMPA, s. 45"
894
+ },
895
+ {
896
+ "act_code": "A-8.8",
897
+ "act_short": "AAAMPA",
898
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
899
+ "section": "46 to 89",
900
+ "marginal_note": "",
901
+ "part": "",
902
+ "division": "",
903
+ "heading": "General",
904
+ "history": "",
905
+ "last_amended": "2019-01-15",
906
+ "in_force": "2019-01-15",
907
+ "status": "in force",
908
+ "current_to": "2022-03-22",
909
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-46 to 89.html",
910
+ "id": "A-8.8-s46 to 89",
911
+ "text": "46 to 89 [Repealed, 2012, c. 24, s. 102]",
912
+ "citation": "AAAMPA, s. 46 to 89"
913
+ },
914
+ {
915
+ "act_code": "A-8.8",
916
+ "act_short": "AAAMPA",
917
+ "act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
918
+ "section": "90",
919
+ "marginal_note": "",
920
+ "part": "",
921
+ "division": "",
922
+ "heading": "General",
923
+ "history": "",
924
+ "last_amended": "2019-01-15",
925
+ "in_force": "2019-01-15",
926
+ "status": "repealed",
927
+ "current_to": "2022-03-22",
928
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-90.html",
929
+ "id": "A-8.8-s90",
930
+ "text": "90 [Repealed, 2012, c. 24, s. 102]",
931
+ "citation": "AAAMPA, s. 90"
932
+ }
933
+ ]
data/processed/C-1.4-fr.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/C-1.4.json ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "act_code": "C-1.4",
4
+ "act_short": "CBSA Act",
5
+ "act_name": "Canada Border Services Agency Act",
6
+ "section": "1",
7
+ "marginal_note": "Short title",
8
+ "part": "",
9
+ "division": "",
10
+ "heading": "Short Title",
11
+ "history": "",
12
+ "last_amended": "2005-12-12",
13
+ "in_force": "2005-12-12",
14
+ "status": "in force",
15
+ "current_to": "2024-11-11",
16
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-1.html",
17
+ "id": "C-1.4-s1",
18
+ "text": "1 This Act may be cited as the Canada Border Services Agency Act.",
19
+ "citation": "CBSA Act, s. 1"
20
+ },
21
+ {
22
+ "act_code": "C-1.4",
23
+ "act_short": "CBSA Act",
24
+ "act_name": "Canada Border Services Agency Act",
25
+ "section": "2",
26
+ "marginal_note": "Definitions",
27
+ "part": "",
28
+ "division": "",
29
+ "heading": "Interpretation",
30
+ "history": "2005, c. 38, ss. 2, 145; 2012, c. 24, s. 107; 2022, c. 10, s. 167",
31
+ "last_amended": "2022-09-01",
32
+ "in_force": "2005-12-12",
33
+ "status": "in force",
34
+ "current_to": "2024-11-11",
35
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-2.html",
36
+ "id": "C-1.4-s2",
37
+ "text": "2 The following definitions apply in this Act.\nAgency means the Canada Border Services Agency established under subsection 3(1). (Agence)\nMinister means the Minister of Public Safety and Emergency Preparedness. (ministre)\nPresident means the President of the Agency appointed under subsection 7(1). (président)\nprogram legislation means any other Act of Parliament or any instrument made under it, or any part of such an Act or instrument,\n(a) that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to administer and enforce, including the Excise Act, the Special Import Measures Act, the Customs Act, the Customs Tariff, the Immigration and Refugee Protection Act, the Excise Act, 2001 and the Select Luxury Items Tax Act;\n(b) that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to enforce, including the Agriculture and Agri-Food Administrative Monetary Penalties Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Plant Protection Act, the Safe Food for Canadians Act and the Seeds Act;\n(c) under which the Minister or another minister authorizes the Agency, the President or an employee of the Agency to administer a program or carry out an activity; or\n(d) under which duties or taxes collected and paid pursuant to the Customs Act are imposed. (législation frontalière)",
38
+ "citation": "CBSA Act, s. 2"
39
+ },
40
+ {
41
+ "act_code": "C-1.4",
42
+ "act_short": "CBSA Act",
43
+ "act_name": "Canada Border Services Agency Act",
44
+ "section": "3",
45
+ "marginal_note": "Agency established",
46
+ "part": "",
47
+ "division": "",
48
+ "heading": "Establishment and Mandate of the Agency",
49
+ "history": "",
50
+ "last_amended": "2005-12-12",
51
+ "in_force": "2005-12-12",
52
+ "status": "in force",
53
+ "current_to": "2024-11-11",
54
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-3.html",
55
+ "id": "C-1.4-s3",
56
+ "text": "3\n(1) The Canada Border Services Agency is established as a body corporate.\n(2) [Agent of Her Majesty] The Agency is for all purposes an agent of Her Majesty in right of Canada.",
57
+ "citation": "CBSA Act, s. 3"
58
+ },
59
+ {
60
+ "act_code": "C-1.4",
61
+ "act_short": "CBSA Act",
62
+ "act_name": "Canada Border Services Agency Act",
63
+ "section": "4",
64
+ "marginal_note": "Head office",
65
+ "part": "",
66
+ "division": "",
67
+ "heading": "Establishment and Mandate of the Agency",
68
+ "history": "",
69
+ "last_amended": "2005-12-12",
70
+ "in_force": "2005-12-12",
71
+ "status": "in force",
72
+ "current_to": "2024-11-11",
73
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-4.html",
74
+ "id": "C-1.4-s4",
75
+ "text": "4 The head office of the Agency is to be in the National Capital Region described in the schedule to the National Capital Act.",
76
+ "citation": "CBSA Act, s. 4"
77
+ },
78
+ {
79
+ "act_code": "C-1.4",
80
+ "act_short": "CBSA Act",
81
+ "act_name": "Canada Border Services Agency Act",
82
+ "section": "5",
83
+ "marginal_note": "Mandate of Agency",
84
+ "part": "",
85
+ "division": "",
86
+ "heading": "Establishment and Mandate of the Agency",
87
+ "history": "",
88
+ "last_amended": "2005-12-12",
89
+ "in_force": "2005-12-12",
90
+ "status": "in force",
91
+ "current_to": "2024-11-11",
92
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-5.html",
93
+ "id": "C-1.4-s5",
94
+ "text": "5\n(1) The Agency is responsible for providing integrated border services that support national security and public safety priorities and facilitate the free flow of persons and goods, including animals and plants, that meet all requirements under the program legislation, by\n(a) supporting the administration or enforcement, or both, as the case may be, of the program legislation;\n(b) implementing agreements between the Government of Canada or the Agency and a foreign state or a public body performing a function of government in a foreign state to carry out an activity, provide a service or administer a tax or program;\n(c) implementing agreements between the Government of Canada or the Agency and the government of a province or other public body performing a function of the Government in Canada to carry out an activity, provide a service or administer a tax or program;\n(d) implementing agreements or arrangements between the Agency and departments or agencies of the Government of Canada to carry out an activity, provide a service or administer a program; and\n(e) providing cooperation and support, including advice and information, to other departments and agencies of the Government of Canada to assist them in developing, evaluating and implementing policies and decisions in relation to program legislation for which they have responsibility.\n(2) [Support] The Agency may provide support, through the provision of services, to departments and agencies for which the Minister is responsible, in accordance with agreements or arrangements entered into with those departments and agencies.",
95
+ "citation": "CBSA Act, s. 5"
96
+ },
97
+ {
98
+ "act_code": "C-1.4",
99
+ "act_short": "CBSA Act",
100
+ "act_name": "Canada Border Services Agency Act",
101
+ "section": "6",
102
+ "marginal_note": "Minister responsible",
103
+ "part": "",
104
+ "division": "",
105
+ "heading": "Minister",
106
+ "history": "",
107
+ "last_amended": "2005-12-12",
108
+ "in_force": "2005-12-12",
109
+ "status": "in force",
110
+ "current_to": "2024-11-11",
111
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-6.html",
112
+ "id": "C-1.4-s6",
113
+ "text": "6\n(1) The Minister is responsible for the Agency.\n(2) [Delegation by Minister] The Minister may delegate to any person any power, duty or function conferred on the Minister under this Act or under the program legislation.\n(3) [Exception] Subsection (2) does not apply if an Act of Parliament other than this Act authorizes the Minister to delegate the power, duty or function to any person or authorizes any person to exercise or perform it.\n(4) [Limitation] Subsection (2) does not apply in respect of a power to make regulations.",
114
+ "citation": "CBSA Act, s. 6"
115
+ },
116
+ {
117
+ "act_code": "C-1.4",
118
+ "act_short": "CBSA Act",
119
+ "act_name": "Canada Border Services Agency Act",
120
+ "section": "7",
121
+ "marginal_note": "Appointment",
122
+ "part": "",
123
+ "division": "",
124
+ "heading": "President and Executive Vice-president",
125
+ "history": "",
126
+ "last_amended": "2005-12-12",
127
+ "in_force": "2005-12-12",
128
+ "status": "in force",
129
+ "current_to": "2024-11-11",
130
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-7.html",
131
+ "id": "C-1.4-s7",
132
+ "text": "7\n(1) The Governor in Council shall appoint a President of the Agency to hold office during pleasure for a term of not more than five years, which term may be renewed for one or more further terms.\n(2) [Executive Vice-president] The Governor in Council may appoint an Executive Vice-president of the Agency to hold office during pleasure for a term of not more than five years, which term may be renewed for one or more further terms.",
133
+ "citation": "CBSA Act, s. 7"
134
+ },
135
+ {
136
+ "act_code": "C-1.4",
137
+ "act_short": "CBSA Act",
138
+ "act_name": "Canada Border Services Agency Act",
139
+ "section": "8",
140
+ "marginal_note": "Role of President",
141
+ "part": "",
142
+ "division": "",
143
+ "heading": "President and Executive Vice-president",
144
+ "history": "",
145
+ "last_amended": "2005-12-12",
146
+ "in_force": "2005-12-12",
147
+ "status": "in force",
148
+ "current_to": "2024-11-11",
149
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-8.html",
150
+ "id": "C-1.4-s8",
151
+ "text": "8\n(1) The President, under the direction of the Minister, has the control and management of the Agency and all matters connected with it.\n(2) [Rank of deputy head] The President has the rank and all the powers of a deputy head of a department.\n(3) [Executive Vice-president’s powers] The Executive Vice-president shall exercise the powers and perform the duties and functions that the President may assign and shall act as President if that office is vacant or if the President is absent or incapacitated.",
152
+ "citation": "CBSA Act, s. 8"
153
+ },
154
+ {
155
+ "act_code": "C-1.4",
156
+ "act_short": "CBSA Act",
157
+ "act_name": "Canada Border Services Agency Act",
158
+ "section": "9",
159
+ "marginal_note": "Delegation by President",
160
+ "part": "",
161
+ "division": "",
162
+ "heading": "President and Executive Vice-president",
163
+ "history": "2005, c. 38, s. 9; 2012, c. 24, s. 108",
164
+ "last_amended": "2019-01-15",
165
+ "in_force": "2005-12-12",
166
+ "status": "in force",
167
+ "current_to": "2024-11-11",
168
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-9.html",
169
+ "id": "C-1.4-s9",
170
+ "text": "9\n(1) The President may delegate to any person any power, duty or function that the President is authorized to exercise or perform under this Act or any other enactment.\n(2) [Designation of officers] The President may designate any person, or person within a class of persons,\n(a) as an officer as defined in subsection 2(1) of the Customs Act to exercise any powers or perform any duties and functions of an officer under that Act that the President may specify; or\n(b) as an inspector or a veterinary inspector or other officer for the enforcement of any Act or instrument made under it, or any part of an Act or instrument, that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to enforce, including the Agriculture and Agri-Food Administrative Monetary Penalties Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Plant Protection Act, the Safe Food for Canadians Act and the Seeds Act.\n(3) [Designation power] The President may exercise any power that the Minister has to designate officers under subsection 6(1) of the Immigration and Refugee Protection Act.",
171
+ "citation": "CBSA Act, s. 9"
172
+ },
173
+ {
174
+ "act_code": "C-1.4",
175
+ "act_short": "CBSA Act",
176
+ "act_name": "Canada Border Services Agency Act",
177
+ "section": "10",
178
+ "marginal_note": "Remuneration",
179
+ "part": "",
180
+ "division": "",
181
+ "heading": "President and Executive Vice-president",
182
+ "history": "2005, c. 38, ss. 10, 144(E)",
183
+ "last_amended": "2005-12-12",
184
+ "in_force": "2005-12-12",
185
+ "status": "in force",
186
+ "current_to": "2024-11-11",
187
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-10.html",
188
+ "id": "C-1.4-s10",
189
+ "text": "10\n(1) The President and the Executive Vice-president shall be paid the remuneration that is fixed by the Governor in Council.\n(2) [Expenses] The President and the Executive Vice-president are entitled to be paid reasonable travel and living expenses incurred by them in the course of performing their duties while absent from their ordinary place of work.\n(3) [Deemed employment] The President and the Executive Vice-president are deemed to be employed in the public service for the purposes of the Public Service Superannuation Act and to be employed in the federal public administration for the purposes of the Government Employees Compensation Act and any regulations made under section 9 of the Aeronautics Act.",
190
+ "citation": "CBSA Act, s. 10"
191
+ },
192
+ {
193
+ "act_code": "C-1.4",
194
+ "act_short": "CBSA Act",
195
+ "act_name": "Canada Border Services Agency Act",
196
+ "section": "11",
197
+ "marginal_note": "Officers and employees",
198
+ "part": "",
199
+ "division": "",
200
+ "heading": "Human Resources",
201
+ "history": "",
202
+ "last_amended": "2005-12-12",
203
+ "in_force": "2005-12-12",
204
+ "status": "in force",
205
+ "current_to": "2024-11-11",
206
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-11.html",
207
+ "id": "C-1.4-s11",
208
+ "text": "11 Officers and employees necessary for the proper conduct of the work of the Agency shall be appointed in accordance with the Public Service Employment Act.",
209
+ "citation": "CBSA Act, s. 11"
210
+ },
211
+ {
212
+ "act_code": "C-1.4",
213
+ "act_short": "CBSA Act",
214
+ "act_name": "Canada Border Services Agency Act",
215
+ "section": "12",
216
+ "marginal_note": "Exercise of powers conferred on Minister",
217
+ "part": "",
218
+ "division": "",
219
+ "heading": "Powers of the Agency",
220
+ "history": "",
221
+ "last_amended": "2005-12-12",
222
+ "in_force": "2005-12-12",
223
+ "status": "in force",
224
+ "current_to": "2024-11-11",
225
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-12.html",
226
+ "id": "C-1.4-s12",
227
+ "text": "12\n(1) Subject to any direction given by the Minister, the Agency may exercise the powers, and shall perform the duties and functions, that relate to the program legislation and that are conferred on, or delegated, assigned or transferred to, the Minister under any Act or regulation.\n(2) [Officers and employees] An officer or employee of the Agency may exercise any power or perform any duty or function referred to in subsection (1) if the officer or employee is appointed to serve in the Agency in a capacity appropriate to the exercise of the power or the performance of the duty or function, and, in so doing, shall comply with any general or special direction given by the Minister.\n(3) [Exception] Subsection (1) does not include\n(a) any power, duty or function of the Minister under this Act; or\n(b) a power to make regulations.\n(4) [Non-application of Statutory Instruments Act] A direction given by the Minister under subsection (1) or (2) is not a statutory instrument for the purposes of the Statutory Instruments Act.",
228
+ "citation": "CBSA Act, s. 12"
229
+ },
230
+ {
231
+ "act_code": "C-1.4",
232
+ "act_short": "CBSA Act",
233
+ "act_name": "Canada Border Services Agency Act",
234
+ "section": "13",
235
+ "marginal_note": "Agreements",
236
+ "part": "",
237
+ "division": "",
238
+ "heading": "Powers of the Agency",
239
+ "history": "2005, c. 38, s. 13; 2006, c. 12, s. 46",
240
+ "last_amended": "2007-02-10",
241
+ "in_force": "2007-02-10",
242
+ "status": "in force",
243
+ "current_to": "2024-11-11",
244
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-13.html",
245
+ "id": "C-1.4-s13",
246
+ "text": "13\n(1) Subject to sections 38 and 38.1 of the Proceeds of Crime (Money Laundering) and Terrorist Financing Act, the Agency may, with the approval of the Governor in Council, on the recommendation of the Minister and the Minister of Foreign Affairs, enter into an agreement with a foreign state or an international organization, for the purposes of carrying out the mandate of the Agency.\n(2) [Arrangements and agreements] The Agency may, for the purposes of carrying out its mandate,\n(a) enter into an arrangement with a foreign state or an international organization; or\n(b) enter into an agreement or arrangement with the government of a province, a department or agency of the Government of Canada or any person or organization.",
247
+ "citation": "CBSA Act, s. 13"
248
+ },
249
+ {
250
+ "act_code": "C-1.4",
251
+ "act_short": "CBSA Act",
252
+ "act_name": "Canada Border Services Agency Act",
253
+ "section": "14",
254
+ "marginal_note": "Agreements to administer a tax",
255
+ "part": "",
256
+ "division": "",
257
+ "heading": "Powers of the Agency",
258
+ "history": "",
259
+ "last_amended": "2005-12-12",
260
+ "in_force": "2005-12-12",
261
+ "status": "in force",
262
+ "current_to": "2024-11-11",
263
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-14.html",
264
+ "id": "C-1.4-s14",
265
+ "text": "14\n(1) The Agency may enter into or amend an agreement with a provincial or territorial government to administer a tax or other fiscal measure if the agreement is in accordance with guidelines relating to agreements of that kind established jointly by the Minister and the Minister of Finance.\n(2) [Application of the Federal-Provincial Fiscal Arrangements Act] Parts III and III.1 of the Federal-Provincial Fiscal Arrangements Act do not apply to an agreement entered into or amended under subsection (1).",
266
+ "citation": "CBSA Act, s. 14"
267
+ },
268
+ {
269
+ "act_code": "C-1.4",
270
+ "act_short": "CBSA Act",
271
+ "act_name": "Canada Border Services Agency Act",
272
+ "section": "15",
273
+ "marginal_note": "Appropriation Acts",
274
+ "part": "",
275
+ "division": "",
276
+ "heading": "Expenditures",
277
+ "history": "",
278
+ "last_amended": "2005-12-12",
279
+ "in_force": "2005-12-12",
280
+ "status": "in force",
281
+ "current_to": "2024-11-11",
282
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-15.html",
283
+ "id": "C-1.4-s15",
284
+ "text": "15 An appropriation Act may provide that the balance of money appropriated by Parliament for the use of the Agency that remains unexpended at the end of the fiscal year, after the adjustments referred to in section 37 of the Financial Administration Act are made, lapses at the end of the following fiscal year.",
285
+ "citation": "CBSA Act, s. 15"
286
+ },
287
+ {
288
+ "act_code": "C-1.4",
289
+ "act_short": "CBSA Act",
290
+ "act_name": "Canada Border Services Agency Act",
291
+ "section": "15.1",
292
+ "marginal_note": "Annual report",
293
+ "part": "",
294
+ "division": "",
295
+ "heading": "Annual Report",
296
+ "history": "",
297
+ "last_amended": "2005-12-12",
298
+ "in_force": "2005-12-12",
299
+ "status": "in force",
300
+ "current_to": "2024-11-11",
301
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-15.1.html",
302
+ "id": "C-1.4-s15.1",
303
+ "text": "15.1\n(1) The Minister shall, as soon as possible after the end of each fiscal year but no later than the end of the calendar year in which that fiscal year ends, cause to be laid before each House of Parliament a report of the operations and performance of the Agency for that fiscal year.\n(2) [Reports required by Treasury Board] The obligation imposed by subsection (1) may be satisfied by the tabling of any reports of the operations and performance of the Agency required by the Treasury Board that contain the information required by that subsection.",
304
+ "citation": "CBSA Act, s. 15.1"
305
+ },
306
+ {
307
+ "act_code": "C-1.4",
308
+ "act_short": "CBSA Act",
309
+ "act_name": "Canada Border Services Agency Act",
310
+ "section": "16",
311
+ "marginal_note": "Definitions",
312
+ "part": "",
313
+ "division": "",
314
+ "heading": "Definitions",
315
+ "history": "2005, c. 38, ss. 16, 144(E)",
316
+ "last_amended": "2005-12-12",
317
+ "in_force": "2005-12-12",
318
+ "status": "in force",
319
+ "current_to": "2024-11-11",
320
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-16.html",
321
+ "id": "C-1.4-s16",
322
+ "text": "16 The following definitions apply in sections 17 to 19 and 21 to 28.\nformer agency means the portion of the federal public administration known as the Canada Border Services Agency. (ancienne agence)\nnew agency means the Canada Border Services Agency established under subsection 3(1). (nouvelle agence)\norder P.C. 2003-2064 means Order in Council P.C. 2003-2064 of December 12, 2003, registered as SI/2003-216. (décret C.P. 2003-2064)",
323
+ "citation": "CBSA Act, s. 16"
324
+ },
325
+ {
326
+ "act_code": "C-1.4",
327
+ "act_short": "CBSA Act",
328
+ "act_name": "Canada Border Services Agency Act",
329
+ "section": "17",
330
+ "marginal_note": "President and Executive Vice-president",
331
+ "part": "",
332
+ "division": "",
333
+ "heading": "Former Agency",
334
+ "history": "",
335
+ "last_amended": "2005-12-12",
336
+ "in_force": "2005-12-12",
337
+ "status": "in force",
338
+ "current_to": "2024-11-11",
339
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-17.html",
340
+ "id": "C-1.4-s17",
341
+ "text": "17\n(1) The persons occupying the positions of President and Executive Vice-president of the former agency on the day on which this section comes into force become the President and Executive Vice-president of the new agency on that day and are deemed to have been appointed under section 7.\n(2) [Positions] Nothing in this Act is to be construed as affecting the status of an employee who, immediately before the coming into force of this section, occupied a position in the former agency, except that the employee shall, on the coming into force of this section, occupy his or her position in the new agency under the direction of the President.\n(3) [Definition of employee] In subsection (2), employee has the same meaning as in subsection 2(1) of the Public Service Employment Act.",
342
+ "citation": "CBSA Act, s. 17"
343
+ },
344
+ {
345
+ "act_code": "C-1.4",
346
+ "act_short": "CBSA Act",
347
+ "act_name": "Canada Border Services Agency Act",
348
+ "section": "18",
349
+ "marginal_note": "Transfer of appropriations",
350
+ "part": "",
351
+ "division": "",
352
+ "heading": "Former Agency",
353
+ "history": "2005, c. 38, ss. 18, 144(E)",
354
+ "last_amended": "2005-12-12",
355
+ "in_force": "2005-12-12",
356
+ "status": "in force",
357
+ "current_to": "2024-11-11",
358
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-18.html",
359
+ "id": "C-1.4-s18-p1",
360
+ "text": "18\n(1) Any amount appropriated, for the fiscal year in which this section comes into force, by an appropriation Act based on the Estimates for that year for defraying the charges and expenses of the federal public administration for the former agency that, on the day on which this section comes into force, is unexpended is deemed, on that day, to be an amount appropriated for defraying the charges and expenses of the federal public administration for the new agency.\n(2) [Transfer of powers, duties and functions] Wherever under any Act, order, rule or regulation, or any contract, lease, licence or other document, any power, duty or function is vested in or exercisable by the President of the former agency or an employee of the former agency, the power, duty or function is vested in and shall be exercised by the President of the new agency or an employee of the new agency unless the Governor in Council by order designates a deputy minister or an officer of the federal public administration to exercise that power or perform that duty or function.\n(3) [Continuation of proceedings] Any action, suit or other legal or administrative proceeding to which the former agency or its President is a party that is pending on the coming into force of this section may be continued by or against the new agency or its President in a similar manner and to the same extent as it would have been continued by or against the former agency or its President.\n(4) [Deeming] Decisions made by the President of the former agency are deemed to be decisions made by the President of the new agency.",
361
+ "citation": "CBSA Act, s. 18 (part 1 of 2)"
362
+ },
363
+ {
364
+ "act_code": "C-1.4",
365
+ "act_short": "CBSA Act",
366
+ "act_name": "Canada Border Services Agency Act",
367
+ "section": "18",
368
+ "marginal_note": "Transfer of appropriations",
369
+ "part": "",
370
+ "division": "",
371
+ "heading": "Former Agency",
372
+ "history": "2005, c. 38, ss. 18, 144(E)",
373
+ "last_amended": "2005-12-12",
374
+ "in_force": "2005-12-12",
375
+ "status": "in force",
376
+ "current_to": "2024-11-11",
377
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-18.html",
378
+ "id": "C-1.4-s18-p2",
379
+ "text": "(5) [Validity of documents] All orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that are made or issued by the President of the former agency or any person under his or her authority continue in force as if they were made or issued by the President of the new agency or a person under his or her authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.\n(6) [Continuation of evidentiary presumption] Every affidavit sworn, or document purporting to be certified, by an employee of the former agency before the day on which this section comes into force has the same probative value as if it were sworn or certified by an employee of the new agency after that day.",
380
+ "citation": "CBSA Act, s. 18 (part 2 of 2)"
381
+ },
382
+ {
383
+ "act_code": "C-1.4",
384
+ "act_short": "CBSA Act",
385
+ "act_name": "Canada Border Services Agency Act",
386
+ "section": "19",
387
+ "marginal_note": "References",
388
+ "part": "",
389
+ "division": "",
390
+ "heading": "Former Agency",
391
+ "history": "",
392
+ "last_amended": "2005-12-12",
393
+ "in_force": "2005-12-12",
394
+ "status": "in force",
395
+ "current_to": "2024-11-11",
396
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-19.html",
397
+ "id": "C-1.4-s19",
398
+ "text": "19\n(1) A reference to the former agency in any of the following is deemed to be a reference to the new agency:\n(a) Schedule I to the Access to Information Act under the heading “Other Government Institutions”;\n(b) the schedule to the Privacy Act;\n(c) Part I of Schedule I to the Public Service Staff Relations Act;\n(d) any order of the Governor in Council made under paragraph (b) of the definition head in section 3 of the Access to Information Act;\n(e) any direction of the Governor in Council made under subsection 24(3) of the Auditor General Act;\n(f) any order of the Governor in Council made under paragraph 29(e) of the Canadian Security Intelligence Service Act;\n(g) any order of the Governor in Council made under paragraph (b) of the definition head in section 3 of the Privacy Act; and\n(h) any order of the Governor in Council made under the definition department in subsection 2(1) of the Public Service Employment Act.\n(2) [Deputy head] The designation of a person as deputy head of the former agency in any of the following is deemed to be a designation of the President of the new agency as deputy head of that agency:\n(a) any order of the Governor in Council made under paragraph 29(e) of the Canadian Security Intelligence Service Act; and\n(b) any order of the Governor in Council made under the definition deputy head in subsection 2(1) of the Public Service Employment Act.",
399
+ "citation": "CBSA Act, s. 19"
400
+ },
401
+ {
402
+ "act_code": "C-1.4",
403
+ "act_short": "CBSA Act",
404
+ "act_name": "Canada Border Services Agency Act",
405
+ "section": "20",
406
+ "marginal_note": "Positions",
407
+ "part": "",
408
+ "division": "",
409
+ "heading": "Canada Customs and Revenue Agency",
410
+ "history": "",
411
+ "last_amended": "2005-12-12",
412
+ "in_force": "2005-12-12",
413
+ "status": "in force",
414
+ "current_to": "2024-11-11",
415
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-20.html",
416
+ "id": "C-1.4-s20",
417
+ "text": "20 Nothing in this Act shall be construed as affecting the status of an employee who, immediately before the coming into force of this section, occupied a position in the Canada Customs and Revenue Agency, except that the employee shall occupy that position in the Canada Revenue Agency.",
418
+ "citation": "CBSA Act, s. 20"
419
+ },
420
+ {
421
+ "act_code": "C-1.4",
422
+ "act_short": "CBSA Act",
423
+ "act_name": "Canada Border Services Agency Act",
424
+ "section": "21",
425
+ "marginal_note": "Continuation of rights and property",
426
+ "part": "",
427
+ "division": "",
428
+ "heading": "Canada Customs and Revenue Agency",
429
+ "history": "",
430
+ "last_amended": "2005-12-12",
431
+ "in_force": "2005-12-12",
432
+ "status": "in force",
433
+ "current_to": "2024-11-11",
434
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-21.html",
435
+ "id": "C-1.4-s21",
436
+ "text": "21\n(1) Subject to subsection (2), all rights and property of the Canada Customs and Revenue Agency continue as the rights and property of the Canada Revenue Agency.\n(2) [Transfer to new agency] All rights and property of the Canada Customs and Revenue Agency that are in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the new agency.",
437
+ "citation": "CBSA Act, s. 21"
438
+ },
439
+ {
440
+ "act_code": "C-1.4",
441
+ "act_short": "CBSA Act",
442
+ "act_name": "Canada Border Services Agency Act",
443
+ "section": "22",
444
+ "marginal_note": "Continuation of obligations and liabilities",
445
+ "part": "",
446
+ "division": "",
447
+ "heading": "Canada Customs and Revenue Agency",
448
+ "history": "",
449
+ "last_amended": "2005-12-12",
450
+ "in_force": "2005-12-12",
451
+ "status": "in force",
452
+ "current_to": "2024-11-11",
453
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-22.html",
454
+ "id": "C-1.4-s22",
455
+ "text": "22\n(1) Subject to subsection (2), all obligations and liabilities of the Canada Customs and Revenue Agency continue as obligations and liabilities of the Canada Revenue Agency.\n(2) [Transfer to new agency] All obligations and liabilities of the Canada Customs and Revenue Agency that were incurred in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the new agency.",
456
+ "citation": "CBSA Act, s. 22"
457
+ },
458
+ {
459
+ "act_code": "C-1.4",
460
+ "act_short": "CBSA Act",
461
+ "act_name": "Canada Border Services Agency Act",
462
+ "section": "23",
463
+ "marginal_note": "Real property, immovables and licences",
464
+ "part": "",
465
+ "division": "",
466
+ "heading": "Canada Customs and Revenue Agency",
467
+ "history": "",
468
+ "last_amended": "2005-12-12",
469
+ "in_force": "2005-12-12",
470
+ "status": "in force",
471
+ "current_to": "2024-11-11",
472
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-23.html",
473
+ "id": "C-1.4-s23",
474
+ "text": "23\n(1) The administration of any real property or immovable, and the administrative responsibility for any licence in respect of any real property or immovable, that was under the administration or administrative responsibility of the Canada Customs and Revenue Agency immediately before the coming into force of this section and that was used for or in support of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the Minister.\n(2) [List] As soon as practicable after the coming into force of this section, the Minister of National Revenue shall publish in the Canada Gazette a list of the real property and immovables the administration of which was transferred under subsection (1) in such a way that each is sufficiently identified.\n(3) [Title] Where the title of any real property or immovable was held in the name of the Canada Customs and Revenue Agency immediately before the coming into force of this section and that real property or immovable was used for or in support of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064, the title to that real property or immovable is deemed to be held in the name of Her Majesty in right of Canada.\n(4) [Other real property, immovables and licences — Canada Revenue Agency] The administration of any real property or immovable, and the administrative responsibility for any licence in respect of any real property or immovable, that is not referred to in subsection (1) and that was under the administration or administrative responsibility of the Canada Customs and Revenue Agency immediately before the coming into force of this section continues under the administration or administrative responsibility, as the case may be, of the Canada Revenue Agency.",
475
+ "citation": "CBSA Act, s. 23"
476
+ },
477
+ {
478
+ "act_code": "C-1.4",
479
+ "act_short": "CBSA Act",
480
+ "act_name": "Canada Border Services Agency Act",
481
+ "section": "24",
482
+ "marginal_note": "Continuation of legal proceedings: Canada Revenue Agency",
483
+ "part": "",
484
+ "division": "",
485
+ "heading": "Canada Customs and Revenue Agency",
486
+ "history": "",
487
+ "last_amended": "2005-12-12",
488
+ "in_force": "2005-12-12",
489
+ "status": "in force",
490
+ "current_to": "2024-11-11",
491
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-24.html",
492
+ "id": "C-1.4-s24",
493
+ "text": "24\n(1) Subject to subsection (2), any action, suit or other legal or administrative proceeding to which the Canada Customs and Revenue Agency is a party that is pending on the coming into force of this section may be continued by or against the Canada Revenue Agency in the same manner and to the same extent as it could have been continued by or against the Canada Customs and Revenue Agency.\n(2) [Continuation of legal proceedings: new agency] Any action, suit or other legal or administrative proceeding to which the Canada Customs and Revenue Agency is a party that is pending on the coming into force of this section may be continued by or against the new agency in the same manner and to the same extent as it could have been continued by or against the Canada Customs and Revenue Agency in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064.",
494
+ "citation": "CBSA Act, s. 24"
495
+ },
496
+ {
497
+ "act_code": "C-1.4",
498
+ "act_short": "CBSA Act",
499
+ "act_name": "Canada Border Services Agency Act",
500
+ "section": "25",
501
+ "marginal_note": "Validity of documents: Canada Revenue Agency",
502
+ "part": "",
503
+ "division": "",
504
+ "heading": "Canada Customs and Revenue Agency",
505
+ "history": "",
506
+ "last_amended": "2005-12-12",
507
+ "in_force": "2005-12-12",
508
+ "status": "in force",
509
+ "current_to": "2024-11-11",
510
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-25.html",
511
+ "id": "C-1.4-s25",
512
+ "text": "25\n(1) Subject to subsection (2), all orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that were made or issued by the Minister of National Revenue or by the Commissioner of Customs and Revenue or any person under their authority continue in force as if they were made or issued by the Minister of National Revenue or the Commissioner of Revenue or any person under their authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.\n(2) [Validity of documents] All orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that were made or issued by the Minister of National Revenue or by the Commissioner of Customs and Revenue or any person under their authority that are in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 continue in force as if they were made or issued by the Minister, the President of the new agency or a person under their authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.",
513
+ "citation": "CBSA Act, s. 25"
514
+ },
515
+ {
516
+ "act_code": "C-1.4",
517
+ "act_short": "CBSA Act",
518
+ "act_name": "Canada Border Services Agency Act",
519
+ "section": "26",
520
+ "marginal_note": "Continuation of evidentiary presumption: Canada Revenue Agency",
521
+ "part": "",
522
+ "division": "",
523
+ "heading": "Canada Customs and Revenue Agency",
524
+ "history": "",
525
+ "last_amended": "2005-12-12",
526
+ "in_force": "2005-12-12",
527
+ "status": "in force",
528
+ "current_to": "2024-11-11",
529
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-26.html",
530
+ "id": "C-1.4-s26",
531
+ "text": "26\n(1) Subject to subsection (2), every affidavit sworn, or document purporting to be certified, by an employee of the Canada Customs and Revenue Agency before the day on which this section comes into force has the same probative value as if it were sworn or certified by an employee of the Canada Revenue Agency after that day.\n(2) [Continuation of evidentiary presumption: Canada Border Services Agency] Every affidavit sworn, or document purporting to be certified, by an employee of the Canada Customs and Revenue Agency before the day on which this section comes into force that was sworn or was purported to be certified in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 has the same probative value as if it were sworn or certified by an employee of the new agency after that day.",
532
+ "citation": "CBSA Act, s. 26"
533
+ },
534
+ {
535
+ "act_code": "C-1.4",
536
+ "act_short": "CBSA Act",
537
+ "act_name": "Canada Border Services Agency Act",
538
+ "section": "27",
539
+ "marginal_note": "References",
540
+ "part": "",
541
+ "division": "",
542
+ "heading": "Canada Customs and Revenue Agency",
543
+ "history": "",
544
+ "last_amended": "2005-12-12",
545
+ "in_force": "2005-12-12",
546
+ "status": "in force",
547
+ "current_to": "2024-11-11",
548
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-27.html",
549
+ "id": "C-1.4-s27",
550
+ "text": "27\n(1) Subject to subsection (2), every reference to the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue, the Deputy Commissioner of Customs and Revenue or any person under their authority in a document issued in the name of the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue or the Deputy Commissioner of Customs and Revenue is to be read, unless the context otherwise requires, as a reference to the Canada Revenue Agency, the Commissioner of Revenue, the Deputy Commissioner of Revenue or a person under their authority, as the case may be.\n(2) [References] Every reference to the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue, the Deputy Commissioner of Customs and Revenue or any person under their authority in a document issued in the name of the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue or the Deputy Commissioner of Customs and Revenue is to be read in respect of those documents that relate to those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064, unless the context otherwise requires, as a reference to the new agency, the President of the new agency, the Executive Vice-president of the new agency or a person under their authority, as the case may be.",
551
+ "citation": "CBSA Act, s. 27"
552
+ },
553
+ {
554
+ "act_code": "C-1.4",
555
+ "act_short": "CBSA Act",
556
+ "act_name": "Canada Border Services Agency Act",
557
+ "section": "28",
558
+ "marginal_note": "References in documents and other provisions: Canada Revenue Agency",
559
+ "part": "",
560
+ "division": "",
561
+ "heading": "Canada Customs and Revenue Agency",
562
+ "history": "",
563
+ "last_amended": "2005-12-12",
564
+ "in_force": "2005-12-12",
565
+ "status": "in force",
566
+ "current_to": "2024-11-11",
567
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-28.html",
568
+ "id": "C-1.4-s28",
569
+ "text": "28\n(1) Subject to subsection (2), any expression referring to the Deputy Minister of National Revenue, the Department of National Revenue, the Commissioner of Customs and Revenue or the Canada Customs and Revenue Agency in any document, any instrument made under an Act of Parliament or any provision of an Act of Parliament not amended by this Act is to be read, unless the context otherwise requires, as a reference to the Commissioner of Revenue or the Canada Revenue Agency, as the case may be.\n(2) [References in documents and other provisions: Canada Border Services Agency] Any expression referring to the Deputy Minister of National Revenue, the Department of National Revenue, the Commissioner of Customs and Revenue or the Canada Customs and Revenue Agency in any document, any instrument made under an Act of Parliament or any provision of an Act of Parliament not amended by this Act and the document, instrument or provision is in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 is to be read, unless the context otherwise requires, as a reference to the President of the new agency or the new agency, as the case may be.",
570
+ "citation": "CBSA Act, s. 28"
571
+ },
572
+ {
573
+ "act_code": "C-1.4",
574
+ "act_short": "CBSA Act",
575
+ "act_name": "Canada Border Services Agency Act",
576
+ "section": "29",
577
+ "marginal_note": "Royal Canadian Mint Act",
578
+ "part": "",
579
+ "division": "",
580
+ "heading": "Royal Canadian Mint Act",
581
+ "history": "",
582
+ "last_amended": "2005-12-12",
583
+ "in_force": "2005-12-12",
584
+ "status": "in force",
585
+ "current_to": "2024-11-11",
586
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-29.html",
587
+ "id": "C-1.4-s29",
588
+ "text": "29 The Minister of National Revenue is the Minister for the purposes of the Royal Canadian Mint Act until another member of the Queen’s Privy Council for Canada is designated under section 2.1 of that Act, as enacted by section 130 of this Act.",
589
+ "citation": "CBSA Act, s. 29"
590
+ },
591
+ {
592
+ "act_code": "C-1.4",
593
+ "act_short": "CBSA Act",
594
+ "act_name": "Canada Border Services Agency Act",
595
+ "section": "*147",
596
+ "marginal_note": "Order in council",
597
+ "part": "",
598
+ "division": "",
599
+ "heading": "Coming into Force",
600
+ "history": "",
601
+ "last_amended": "2005-12-12",
602
+ "in_force": "2005-12-12",
603
+ "status": "in force",
604
+ "current_to": "2024-11-11",
605
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-*147.html",
606
+ "id": "C-1.4-s*147",
607
+ "text": "*147 This Act, except for sections 144 to 146, comes into force on a day to be fixed by order of the Governor in Council.\n* [Note: Sections 144 to 146 in force on assent November 3, 2005; Act, except sections 144 to 146, in force December 12, 2005, see SI/2005-119.]",
608
+ "citation": "CBSA Act, s. *147"
609
+ },
610
+ {
611
+ "id": "C-1.4-nif-1",
612
+ "act_code": "C-1.4",
613
+ "act_short": "CBSA Act",
614
+ "act_name": "Canada Border Services Agency Act",
615
+ "section": "",
616
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 108",
617
+ "part": "AMENDMENTS NOT IN FORCE",
618
+ "division": "",
619
+ "heading": "Amendments not in force",
620
+ "text": "108 Section 2 of the Canada Border Services Agency Act is amended by adding the following in alphabetical order:\nCommission means the Public Complaints and Review Commission established by subsection 3(1) of the Public Complaints and Review Commission Act. (Commission)",
621
+ "history": "2024, c. 25, s. 108",
622
+ "last_amended": "",
623
+ "in_force": "",
624
+ "status": "not-in-force",
625
+ "current_to": "2024-11-11",
626
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 108)",
627
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
628
+ },
629
+ {
630
+ "id": "C-1.4-nif-2",
631
+ "act_code": "C-1.4",
632
+ "act_short": "CBSA Act",
633
+ "act_name": "Canada Border Services Agency Act",
634
+ "section": "",
635
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 109",
636
+ "part": "AMENDMENTS NOT IN FORCE",
637
+ "division": "",
638
+ "heading": "Amendments not in force",
639
+ "text": "109 Subsection 9(1) of the Act is replaced by the following:\n9 [Delegation by President]\n(1) Subject to subsection (1.1), the President may delegate to any person any power, duty or function that the President is authorized to exercise or perform under this Act or any other enactment.\n(1.1) [Exception] The President shall not delegate the power to make rules under section 39 of the Public Complaints and Review Commission Act and the power to enter into a memorandum of understanding under subsection 17(7) of that Act.",
640
+ "history": "2024, c. 25, s. 109",
641
+ "last_amended": "",
642
+ "in_force": "",
643
+ "status": "not-in-force",
644
+ "current_to": "2024-11-11",
645
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 109)",
646
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
647
+ },
648
+ {
649
+ "id": "C-1.4-nif-3",
650
+ "act_code": "C-1.4",
651
+ "act_short": "CBSA Act",
652
+ "act_name": "Canada Border Services Agency Act",
653
+ "section": "",
654
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 110",
655
+ "part": "AMENDMENTS NOT IN FORCE",
656
+ "division": "",
657
+ "heading": "Amendments not in force",
658
+ "text": "110 Section 13 of the Act is amended by adding the following after subsection (2):\n(3) [Restriction] The Agency may enter into an agreement or arrangement with the government of a province respecting the detention of persons on behalf of the Agency only if the Minister is of the opinion that there is in the province an independent individual or body that is empowered to receive and deal with complaints about the treatment and conditions of detention of detained persons.\n(4) [Exception] If the Minister is of the opinion that there is an urgent need to provide for the detention of persons on a temporary basis, the Agency may, with the Minister’s approval, enter into an agreement or arrangement referred to in subsection (3) with the government of a province even if the Minister is of the opinion that there is no independent individual or body referred to in that subsection in the province.\n(5) [Duty to provide information] If the Agency is notified that a person who is or has been detained on behalf of the Agency under an agreement or arrangement referred to in subsection (3) has made a complaint respecting their treatment during detention — or their conditions of detention — to any competent authority in the province where they are or were detained, the Agency shall, as soon as feasible, provide the Commission with all of the information that the Agency has received or receives in relation to the complaint.\n(6) [Duty to provide copy] If the Agency enters into an agreement or arrangement respecting the monitoring of the treatment and of the conditions of detention of persons detained by or on behalf of the Agency or the inspection of any facility where persons are detained by or on behalf of the Agency, the Agency shall, as soon as feasible, provide the Commission with a copy of every document containing findings or recommendations that it receives under that agreement or arrangement.",
659
+ "history": "2024, c. 25, s. 110",
660
+ "last_amended": "",
661
+ "in_force": "",
662
+ "status": "not-in-force",
663
+ "current_to": "2024-11-11",
664
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 110)",
665
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
666
+ },
667
+ {
668
+ "id": "C-1.4-nif-4-p1",
669
+ "act_code": "C-1.4",
670
+ "act_short": "CBSA Act",
671
+ "act_name": "Canada Border Services Agency Act",
672
+ "section": "",
673
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
674
+ "part": "AMENDMENTS NOT IN FORCE",
675
+ "division": "",
676
+ "heading": "Amendments not in force",
677
+ "text": "111 The Act is amended by adding the following after section 14:\n14.1 [Definitions]\n(1) The following definitions apply in this section and sections 14.2 to 14.9.\nemployee, in relation to the Agency, includes any person who assists the Agency in the exercise of any of its powers or the performance of any of its duties and functions under this Act. (employé)\nserious incident means an incident in which the actions of an officer or employee of the Agency in the exercise of its powers or the performance of its duties and functions under this Act\n(a) may have resulted in serious injury to or the death of any person; or\n(b) may have constituted an offence under federal or provincial law that the Minister or the President decides would be in the public interest to be investigated by the Agency. (incident grave)\nserious injury means a prescribed physical or psychological injury. (blessure grave)\n(2) [Regulations] The Governor in Council may make regulations prescribing physical or psychological injuries for the purposes of the definition serious injury in subsection (1).\n14.2 [Notification] As soon as feasible after becoming aware that a serious incident is alleged to have occurred, the President shall notify the police force that has jurisdiction at the location where it is alleged to have occurred and the Commission of the alleged serious incident.\n14.3 [Investigation]\n(1) Subject to subsection (2), as soon as feasible after the President becomes aware that a serious incident is alleged to have occurred, the Agency shall investigate the alleged serious incident.",
678
+ "history": "2024, c. 25, s. 111",
679
+ "last_amended": "",
680
+ "in_force": "",
681
+ "status": "not-in-force",
682
+ "current_to": "2024-11-11",
683
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 1 of 4)",
684
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
685
+ },
686
+ {
687
+ "id": "C-1.4-nif-4-p2",
688
+ "act_code": "C-1.4",
689
+ "act_short": "CBSA Act",
690
+ "act_name": "Canada Border Services Agency Act",
691
+ "section": "",
692
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
693
+ "part": "AMENDMENTS NOT IN FORCE",
694
+ "division": "",
695
+ "heading": "Amendments not in force",
696
+ "text": "(2) [Limit on investigation] If the alleged serious incident involves the actions of one or more persons, the Agency shall not, in the investigation, investigate the actions of any person who assists the Agency in the exercise of any of its powers or the performance of any of its duties and functions under this Act by reason only of an agreement or arrangement referred to in subsection 13(3).\n14.4 [Copy of records] As soon as feasible after completing the investigation, the President shall provide the Commission with a copy of any records in its possession relating to the investigation. The President shall also provide the Commission with a copy of any record in respect of every other lawfully authorized investigation conducted into the alleged serious incident that the Agency receives after the completion of its own investigation.\n14.5 [Observer]\n(1) The Agency shall permit an observer appointed by the Commission under subsection (2) to assess the impartiality of the investigation.\n(2) [Appointment of observer] The Commission may appoint an observer to assess the impartiality of the investigation.\n(3) [Observers are compellable] Despite subsection 11(3) of the Public Complaints and Review Commission Act, but subject to section 14.8(1), every observer is a compellable witness in every criminal, civil or administrative action or proceeding, or inquiry, in respect of any matter coming to the knowledge of the observer as a result of exercising a power or performing a duty or function under this Part.",
697
+ "history": "2024, c. 25, s. 111",
698
+ "last_amended": "",
699
+ "in_force": "",
700
+ "status": "not-in-force",
701
+ "current_to": "2024-11-11",
702
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 2 of 4)",
703
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
704
+ },
705
+ {
706
+ "id": "C-1.4-nif-4-p3",
707
+ "act_code": "C-1.4",
708
+ "act_short": "CBSA Act",
709
+ "act_name": "Canada Border Services Agency Act",
710
+ "section": "",
711
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
712
+ "part": "AMENDMENTS NOT IN FORCE",
713
+ "division": "",
714
+ "heading": "Amendments not in force",
715
+ "text": "(4) [No observer appointed] If no observer is appointed to an investigation of a serious incident under subsection (2), the President shall provide the Chairperson of the Commission with a report that sets out all measures that have been or will be taken by the Agency to ensure the impartiality of the investigation.\n14.6 [Recommendations] If the observer has concerns with the impartiality of the investigation, the observer may inform the Agency of their concerns and may make any recommendations to the Agency that they consider appropriate to address those concerns.\n14.7 [Report]\n(1) The observer shall, in accordance with the regulations, provide a report respecting the impartiality of the investigation of a serious incident to the Chairperson of the Commission and the President.\n(2) [Response] If the observer’s report identifies concerns with respect to the impartiality of an investigation, the President shall provide to the Chairperson of the Commission a written response to the observer’s report that includes a description of what actions have or will be taken by the Agency to address those concerns.\n(3) [Report on response] If the Chairperson of the Commission is not satisfied with the response of the President, the Chairperson shall issue a report to that effect to the Minister.\n14.8 [Information subject to privilege]\n(1) Nothing in this Act authorizes a person to disclose to an observer privileged information, as defined in subsection 17(1) of the Public Complaints and Review Commission Act, and an observer shall not use or disclose that information if it is disclosed.\n(2) [Offence to disclose certain information] Every person who contravenes subsection (1) is\n(a) guilty of an indictable offence and liable to imprisonment for a term of not more than five years; or",
716
+ "history": "2024, c. 25, s. 111",
717
+ "last_amended": "",
718
+ "in_force": "",
719
+ "status": "not-in-force",
720
+ "current_to": "2024-11-11",
721
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 3 of 4)",
722
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
723
+ },
724
+ {
725
+ "id": "C-1.4-nif-4-p4",
726
+ "act_code": "C-1.4",
727
+ "act_short": "CBSA Act",
728
+ "act_name": "Canada Border Services Agency Act",
729
+ "section": "",
730
+ "marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
731
+ "part": "AMENDMENTS NOT IN FORCE",
732
+ "division": "",
733
+ "heading": "Amendments not in force",
734
+ "text": "(b) guilty of an offence punishable on summary conviction and liable to a fine of not more than $5,000 or to imprisonment for a term of not more than six months, or to both.\n(3) [Limitation period] Summary conviction proceedings in respect of an offence under subsection (2) may be instituted at any time within but not later than two years after the time when the subject matter of the proceedings arose.\n14.9 [Regulations] The Governor in Council may make regulations\n(a) respecting the criteria and procedures for the appointment of an observer;\n(b) respecting the scope of an observer’s role;\n(c) respecting an observer’s reporting obligations;\n(d) respecting the access to, and use of, the notes, reports or other material prepared by an observer in relation to an investigation;\n(e) prescribing the period within which the President is to provide a response under subsection 14.7(2); and\n(f) generally for carrying out the purposes of sections 14.5 to 14.8.",
735
+ "history": "2024, c. 25, s. 111",
736
+ "last_amended": "",
737
+ "in_force": "",
738
+ "status": "not-in-force",
739
+ "current_to": "2024-11-11",
740
+ "citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 4 of 4)",
741
+ "source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
742
+ }
743
+ ]
data/processed/C-24.5-fr.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/C-24.5.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/C-29-fr.json ADDED
The diff for this file is too large to render. See raw diff