| # CanLex maintainer runbook |
|
|
| Everything a future maintainer needs that the code alone does not say. |
|
|
| ## The refresh choreography |
|
|
| Order matters β chunks feed embeddings feed the eval: |
|
|
| ``` |
| py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules) |
| py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs) |
| py -m canlex.pdi # IRCC PDI trees (TR / refugee / standard / |
| # identity / PR-card; capped BFS, drops logged) |
| py -m canlex.ciu # CIU/PSAC union documents + site VSSAs |
| py -m canlex.dmemo [--fr] # CBSA D-Memoranda (EN; --fr = MΓ©morandums D) |
| py -m canlex.enf # ENF operational manuals (canada.ca PDFs) |
| py -m canlex.amps # AMPS Master Penalty Document |
| py -m canlex.charter # Charter / Constitution HTML |
| py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts |
| py -m canlex.delegation # CBSA/IRCC delegation instruments |
| py -m canlex.directive [--fr] # NJC directives (EN; --fr = Γ©ditions franΓ§aises) |
| py -m canlex.agreement # FB collective agreement |
| py -m canlex.benefits # benefit-plan booklets |
| py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition) |
| py -m canlex.commentary # curated datasets -> commentary chunks |
| py -m canlex.embed # re-embed EVERYTHING (run after ANY of the above) |
| py -m unittest discover -s tests # all offline |
| py -m canlex.eval # gate: no metric below the last committed run |
| ``` |
|
|
| `canlex.embed` is mandatory after any chunk change: the index zero-fills |
| vectors for chunks missing from embeddings.npz and quietly degrades them to |
| BM25-only (a stderr warning is the only signal). |
|
|
| ### The corpus-write guard |
|
|
| Every ingester above rebuilds its whole processed file from a fresh scrape, so |
| an upstream page that changes shape turns into data loss at the write. All of |
| them now go through `_common.write_corpus`, which **refuses to write a corpus |
| below 80% of the stored one** and makes the process exit non-zero: |
|
|
| ``` |
| REFUSING to write: 0 chunks would replace 1870 already in dmemos.json. ... |
| ``` |
|
|
| That is not a transient error β it means the scrape broke. Diagnose it, don't |
| retry. Once the drop is understood and intended (a source genuinely shrank), |
| re-run the same module with `--allow-shrink`. Because the runbook chains these |
| as `py -m canlex.X && py -m canlex.embed`, a refusal now correctly stops the |
| embed instead of propagating a gutted corpus. |
|
|
| Most ingesters also **preserve on failure**: an item that errors keeps its |
| last-good chunks (`_common.preserve_dropped`), restricted to items that |
| actually failed, so anything removed on purpose stays gone. Deliberate |
| exception: `enf.py` treats an HTTP 404 as IRCC retiring a chapter β its |
| documented policy β so a 404 drops the chapter while any other error preserves |
| it. Writes are atomic (tmp + `os.replace`): a killed run cannot leave truncated |
| JSON that would read as an empty corpus and disable the guard next time. |
|
|
| ## Deploys |
|
|
| - **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`. |
| The Space rebuilds automatically. `embeddings.npz` ships in-repo via |
| git-LFS β a fresh clone needs `git lfs install`. |
| - **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy |
| `webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` + |
| `webapp/README.md` over, commit, push. Do NOT use `hf upload` β HF gates |
| it behind PRO for Docker/Gradio Spaces; git pushes work on the free tier. |
|
|
| ## Secrets and variables (set in each Space's settings) |
|
|
| | Where | Name | Purpose | |
| |---|---|---| |
| | MCP Space | `CANLII_API_KEY` | optional, enables canlex_case | |
| | Webapp | `GEMINI_API_KEY` | required | |
| | Webapp | `CANLEX_WEB_AUTH` | required (`user:pass` per line); the app refuses to start without it | |
| | Webapp | `CANLEX_GEMINI_MODEL` | optional; the deployed Space sets `gemini-2.5-pro` (code default is flash) | |
| |
| Locally, the CanLII key lives in `canlii_key.txt` (gitignored β never |
| commit it). |
|
|
| ## The staleness watch |
|
|
| `py -m canlex.refresh` (alias `--check`) diffs upstream: Justice Laws |
| consolidation dates, CBSA/IRCC delegation instruments, NJC directive dates, |
| the newest per-memo `LAST_UPDATED` in the D-memo datasource, curated-dataset |
| age (>180 days β AGING) and curated-vs-chunk regen drift. Non-zero exit = |
| something drifted. |
| A scheduled task runs it Mondays 03:01 local β if this machine is off, the |
| check silently doesn't happen that week. Note: network fetch errors on the |
| legislation rows DO fail the check (exit 1) so a broken watch can't pass |
| silently. |
|
|
| `_PENDING_BILLS` in canlex/refresh.py names bills heading into force that |
| affect corpus Acts β edit that list when a new one appears (source-code edit, |
| deliberate). A bill stays listed after its in-force date until Justice Laws |
| consolidates it: C-16 came into force 2026-07-18 but the 2026-06-14 |
| consolidation predates it, so the coercive-control provisions are NOT in the |
| corpus yet and the notice correctly keeps printing. |
|
|
| ## The CBSA D-memo index (rebuilt 2026-07-10) |
|
|
| CBSA replaced the static index with a WET DataTable: the page ships **no memo |
| links at all**, the rows come from `dmemoranda-datasource-eng.json` beside it. |
| `dmemo.memo_urls` reads that datasource and falls back to scraping the page |
| only if it disappears. Two consequences worth knowing: |
|
|
| - Cancelled memos (`STATUS: "Cancelled"`, 34 of 398) are skipped β retired |
| guidance must not retrieve as current. D10-14-48 left the corpus this way. |
| - Eight memos are listed Active but their pages 404 (D8-2-2, D8-9-3, D10-2-3, |
| D10-14-3, D10-14-18, D10-14-65, D10-15-16, D10-15-25) β CBSA's own broken |
| links. `dmemo.preserve_failed` re-attaches the last-good chunks of any memo |
| that fails to fetch, so a 404 (or a dropped connection) can no longer delete |
| still-in-force guidance. The two D8s were never ingestable and are absent. |
|
|
| This is the incident the write guard above exists for: the first run after the |
| rebuild scraped zero memos and wrote an empty dmemos.json straight over 1,870 |
| good chunks, recovered from git. Every ingester now carries the guard. |
|
|
| ## Curated-dataset review protocol |
|
|
| `data/curated/us_dispositions.json` and `us_equivalency.json` are the only |
| hand-maintained legal content. To change them: edit β bump `"reviewed"` β |
| `py -m canlex.commentary` β `py -m canlex.embed` β eval β deploy. The refresh |
| check flags any drift between the curated `reviewed` date and the rendered |
| chunks. Provenance and the verbatim-verification log live inside the JSON |
| files themselves; keep them current. |
|
|
| ## Backups |
|
|
| The HF Space remote is also production. A full-history bundle lives at |
| `C:\projects\CanLex-backup.bundle` (recreate: `git bundle create |
| ../CanLex-backup.bundle --all`). For a real offsite mirror, create a private |
| GitHub repo and: |
|
|
| ``` |
| git remote add mirror https://github.com/<you>/CanLex.git |
| git push mirror --all |
| ``` |
|
|
| (GitHub needs git-lfs enabled for embeddings.npz, or add it to a |
| `.lfsconfig`-excluded push.) |
|
|
| ## Known gaps / parked |
|
|
| - ENF coverage (researched and URL-verified 2026-07-22): ENF 20 Detention |
| and the current ENF 3 edition live under non-standard filenames β handled |
| by `_URL_OVERRIDES` in canlex/enf.py (the old enf03-eng.pdf still serves a |
| STALE 2015-era copy; never remove the override). ENF 7 was never publicly |
| released; ENF 13/16/25 never existed; ENF 9 (Judicial Reviews) and ENF 11 |
| (Verifying Departure) are retired with no successor (Wayback copies exist |
| but retired guidance is deliberately not ingested). ENF 27 (PR Card) was |
| replaced by the PDI section at |
| operational-bulletins-manuals/permanent-residence/card.html β ingested by |
| `canlex.pdi` (the `pdi-prcard` set). ENF 2 (Evaluating Inadmissibility) |
| and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as |
| ATIP-released copies hosted by a private law firm β NOT ingested pending |
| the maintainer's call on unofficial sources; the Tran PDI page now comes |
| in with the whole `standard-requirements` tree and partially covers |
| ENF 2's ground. |
| - PDI scope: `canlex.pdi` crawls six category trees breadth-first |
| (`_MAX_DEPTH` 2, `_MAX_PAGES` 160 per tree). Deliberately out of scope: |
| citizenship, fees, office service delivery, and the year-by-year |
| operational-bulletin archives (mostly expired instructions). If a tree |
| ever hits the page cap the run PRINTS how many in-scope pages it dropped |
| β raise `_MAX_PAGES` rather than letting the silence read as coverage. |
| - CanLII capture workflow (used for the Hill/Brannson/Li trilogy, |
| 2026-07-22): open the URL a missing-file stub prints in a real browser, |
| copy the page text to the named `data/raw/caselaw/*.txt`, re-run |
| `py -m canlex.caselaw && py -m canlex.embed`. |
| - Dag (FCA) parses to 2 chunks because it genuinely is a 9-paragraph bench |
| judgment β correct, not a bug. |
| - A weekly citator sweep over the ~110 corpus decisions (negative-treatment |
| detection) was considered and deliberately skipped: ~6 minutes of |
| rate-limited CanLII API per run for marginal signal. Run `canlex_case` on |
| a decision before relying on it instead. |
| - French support is now phase 3: the full parallel French legislation corpus |
| is ingested (`--fr`; lang='fr' chunks, '-fr-' id infix, 'art.'/'annexe' |
| citations). Retrieval is language-routed: English queries never see French |
| chunks; French queries (detected by accents or French function words) |
| search both languages. get_section/xref/list_acts stay English-canonical. |
| Phase 2 (a multilingual embedder) was tried and REJECTED 2026-07-22 β |
| regressed every slice and broke confidence calibration; don't retry |
| without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are |
| published bilingually) is the remaining bilingual frontier. |
| - ENF 2 and ENF 14/OP 19 are both ingested from their ATIP releases |
| (authorized 2026-07-23, bannered UNOFFICIAL on every chunk). ENF 14's |
| image-scan PDF was recovered by OCR (pymupdf rasterization + Windows OCR; |
| cached at data/raw/enf/enf14-ocr.txt β enf.py auto-falls-back to |
| `*-ocr.txt` when a PDF extracts under 500 chars). |
| - Typo robustness, two layers: unknown STEMS get the trigram bridge in |
| `index._fuzzy_term` (BM25 recall only), and misspelled WORDS are repaired |
| by `index._correct_query` (trigram candidates, Damerau-Levenshtein <= 2, |
| shared 2-char prefix, frequency floor 5 so OCR debris can't win) -- the |
| repaired text feeds the semantic embedder and the reranker. Ultra-rare |
| stems (df <= 2) get the correction APPENDED, never substituted, so rare |
| legitimate terms keep their recall. The `typo` eval slice tracks it. |
| - Bilingual guidance: the D-memos and NJC directives now carry their French |
| editions (`--fr` modes; dmemos-fr.json / directives-fr.json) alongside |
| the French legislation, all lang-routed. Remaining English-only: case |
| law, ENF/AMPS/PDI, benefits booklets, and the curated commentary. |
| |
| ## Two retrieval invariants worth not re-breaking (2026-07-29) |
| |
| Ingesting the French guidance and the wider PDI trees exposed both of these. |
| Each was measured question-by-question against the previous corpus, not |
| guessed at β the harness is a per-question rank dump over two corpus |
| variants, which is how a 5-question shuffle gets attributed instead of |
| argued about. |
| |
| - **BM25 collection statistics are language-scoped.** `_build_bm25` keeps |
| `idf`/`avgdl` (whole corpus) AND `idf_en`/`avgdl_en` (English scope); |
| every English query scores with the English pair. An English query can |
| never retrieve the French twins, but a single corpus-wide idf let those |
| unreachable chunks set the rarity of every shared token (numbers, act |
| codes, folded cognates) and drag the average length: adding the French |
| D-memos and directives alone moved legacy Hit@1 0.767 β 0.750 without a |
| single French chunk being retrievable. French queries still use the |
| whole-corpus pair β they search both languages. |
| - **The diversity cap keys memoranda on the parent DOCUMENT.** `_source_key` |
| used `section` for every memorandum, which is the document identity only |
| for the flat families (`_FLAT_MEMO_CODES`: one act_code across ~360 |
| D-memos / ~1,000 AMPS contraventions). ENF chapters, PDI pages and IRB |
| guidelines carry a per-document act_code and use `section` to subdivide |
| it, so each ENF section and each PDI page was its own "source" and |
| neither RECALL_CAP nor SOURCE_CAP ever bound: the ~150-page PDI refugee |
| tree filled the candidate pool and pushed IRPA s. 112 out of the top 20 |
| of "what is a pre-removal risk assessment" entirely. If a new corpus |
| family lands, check which field carries its document identity before |
| trusting the caps. |
|
|