Beemer Claude Opus 4.7 commited on
Commit
cb3763b
·
1 Parent(s): 82105a0

Add canlex/refresh.py --check: corpus staleness detection (Phase 2)

Browse files

Closes the audit's #1 gap (legislation was refreshed by hand, so Bill C-12
only landed when someone looked). For each of the 41 Justice Laws instruments
in config.SOURCES it fetches just the head of the XML (the consolidation date
is a root attribute, so ~16 KB suffices — no full re-download), reads
lims:current-date, and diffs it against the current_to stored in
data/processed/<code>.json; upstream-newer = drift, and the process exits
non-zero so it can gate a deploy or run on a schedule. Also prints the stored
currency of the seven non-XML sources (D-memos, case law, agreement,
directives, delegation, benefits, tariff) with their refresh commands, and
flags tracked pending bills whose in-force date has passed (seeded with Bill
C-16, in force 2026-07-18, amending the Criminal Code).

py -m canlex.refresh --check [--json]

Verified live: reports all 41 instruments current after the recent refresh.
7 offline unit tests (date extraction, BOM, pending-bill gating); 47 tests pass.
Pure helpers (_extract_current_date, pending_in_force) are unit-tested; the
network fetch is not. Maintainer/CI tool — inert in the deployed server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (2) hide show
  1. canlex/refresh.py +197 -0
  2. tests/test_refresh.py +45 -0
canlex/refresh.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. It also prints the stored currency of the non-XML sources (D-memos,
12
+ case law, agreements, directives, delegation, benefits, tariff schedule), which
13
+ have no single machine-readable date, with the command to refresh each; and it
14
+ flags any tracked pending bill whose coming-into-force date has passed.
15
+
16
+ This is the staleness detection the corpus previously lacked: legislation was
17
+ refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
18
+
19
+ py -m canlex.refresh --check # human-readable; exit 1 on drift
20
+ py -m canlex.refresh --check --json # machine-readable for CI
21
+ """
22
+ import datetime
23
+ import json
24
+ import re
25
+ import sys
26
+ import time
27
+ import urllib.request
28
+
29
+ from .config import SOURCES, PROCESSED_DIR
30
+
31
+ _UA = "CanLex-refresh/0.1"
32
+ _HEAD_BYTES = 16384 # the root element's attributes sit at the very top
33
+ _CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
34
+
35
+ # Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
36
+ # command, description). These carry no single upstream consolidation date, so
37
+ # the check reports their STORED currency (newest item) and how to refresh them
38
+ # rather than diffing against upstream.
39
+ _NON_XML = [
40
+ ("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
41
+ ("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
42
+ ("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
43
+ ("directive", "directives.json", "py -m canlex.directive", "NJC directives"),
44
+ ("delegation", "delegation.json", "py -m canlex.delegation", "IRPA/IRPR delegation"),
45
+ ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
46
+ ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
47
+ "Customs Tariff Schedule ch. 98-99"),
48
+ ]
49
+
50
+ # Bills known to be heading into force that affect the corpus. Surfaced once
51
+ # their in-force date has passed, with the codes to re-ingest. (The legislation
52
+ # drift check above will independently flag the affected Act once Justice Laws
53
+ # consolidates the amendment; this list just names the cause.)
54
+ _PENDING_BILLS = [
55
+ {"bill": "C-16", "name": "Protecting Victims Act",
56
+ "in_force": "2026-07-18", "affects": ["C-46"],
57
+ "note": "Criminal Code: coercive control, child-protection reporting "
58
+ "(coercive-control provisions come into force later)."},
59
+ ]
60
+
61
+
62
+ def _extract_current_date(raw):
63
+ """Pull the lims:current-date (YYYY-MM-DD) from the head of a Justice Laws
64
+ XML document. Pure and offline, for testability. Returns '' if absent."""
65
+ if raw[:3] == b"\xef\xbb\xbf":
66
+ raw = raw[3:]
67
+ m = _CURRENT_DATE.search(raw)
68
+ return m.group(1).decode("ascii") if m else ""
69
+
70
+
71
+ def _local_current_to(code):
72
+ """The current_to stored in data/processed/<code>.json (first chunk), or ''."""
73
+ path = PROCESSED_DIR / f"{code}.json"
74
+ if not path.exists():
75
+ return None
76
+ chunks = json.loads(path.read_text(encoding="utf-8"))
77
+ return chunks[0].get("current_to", "") if chunks else ""
78
+
79
+
80
+ def _remote_current_date(url):
81
+ """Fetch only the head of the XML and extract its consolidation date.
82
+ Returns (date, error): date is '' on a clean fetch that lacked the
83
+ attribute, error is a short string when the fetch itself failed."""
84
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
85
+ try:
86
+ with urllib.request.urlopen(req, timeout=60) as resp:
87
+ head = resp.read(_HEAD_BYTES)
88
+ return _extract_current_date(head), ""
89
+ except Exception as exc: # network / HTTP / timeout
90
+ return "", f"{type(exc).__name__}: {exc}"
91
+
92
+
93
+ def check_legislation():
94
+ """Compare each Justice Laws source's upstream date to the stored corpus.
95
+ Returns a list of row dicts."""
96
+ rows = []
97
+ for code, src in SOURCES.items():
98
+ local = _local_current_to(code)
99
+ remote, error = _remote_current_date(src["xml_url"])
100
+ if error:
101
+ status = "error"
102
+ elif local is None:
103
+ status = "missing-local" # configured but never ingested
104
+ elif remote and remote > local:
105
+ status = "stale" # upstream newer than our corpus
106
+ elif not remote:
107
+ status = "no-date" # fetched, but no date found (unusual)
108
+ else:
109
+ status = "ok"
110
+ rows.append({"code": code, "short": src["short"], "local": local or "",
111
+ "remote": remote, "status": status, "error": error})
112
+ time.sleep(0.3) # be polite to Justice Laws
113
+ return rows
114
+
115
+
116
+ def non_xml_currency():
117
+ """Stored currency (newest item) of the aggregate sources."""
118
+ rows = []
119
+ for label, fname, cmd, desc in _NON_XML:
120
+ path = PROCESSED_DIR / fname
121
+ if not path.exists():
122
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
123
+ "newest": "", "chunks": 0, "present": False})
124
+ continue
125
+ chunks = json.loads(path.read_text(encoding="utf-8"))
126
+ newest = max((c.get("current_to", "") for c in chunks), default="")
127
+ rows.append({"label": label, "desc": desc, "cmd": cmd,
128
+ "newest": newest, "chunks": len(chunks), "present": True})
129
+ return rows
130
+
131
+
132
+ def pending_in_force(today):
133
+ """Pending bills whose in-force date has passed as of `today` (ISO str)."""
134
+ return [b for b in _PENDING_BILLS if b["in_force"] <= today]
135
+
136
+
137
+ def run(as_json=False):
138
+ today = datetime.date.today().isoformat()
139
+ leg = check_legislation()
140
+ nonxml = non_xml_currency()
141
+ pending = pending_in_force(today)
142
+ stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
143
+ errors = [r for r in leg if r["status"] == "error"]
144
+
145
+ if as_json:
146
+ print(json.dumps({"checked": today, "legislation": leg,
147
+ "non_xml": nonxml, "pending_in_force": pending,
148
+ "stale_count": len(stale),
149
+ "error_count": len(errors)}, indent=2))
150
+ else:
151
+ print(f"CanLex corpus staleness check — {today}\n")
152
+ print(f"Justice Laws legislation ({len(leg)} instruments):")
153
+ for r in sorted(leg, key=lambda x: (x["status"] != "stale", x["short"])):
154
+ if r["status"] == "stale":
155
+ print(f" STALE {r['short']:34} local {r['local']} "
156
+ f"upstream {r['remote']}")
157
+ elif r["status"] == "missing-local":
158
+ print(f" ABSENT {r['short']:34} configured but not ingested "
159
+ f"(upstream {r['remote']})")
160
+ elif r["status"] == "error":
161
+ print(f" ERROR {r['short']:34} {r['error'][:50]}")
162
+ elif r["status"] == "no-date":
163
+ print(f" ? {r['short']:34} no current-date found upstream")
164
+ if not stale and not errors:
165
+ print(f" all {len(leg)} current (local matches or exceeds upstream)")
166
+ if stale:
167
+ codes = " ".join(r["code"] for r in stale)
168
+ print(f"\n -> {len(stale)} drifted. Refresh: "
169
+ f"py -m canlex.ingest --force {codes}\n"
170
+ f" then: py -m canlex.embed && py -m canlex.eval")
171
+
172
+ print("\nOther sources (stored currency; re-run the ingester to refresh):")
173
+ for r in nonxml:
174
+ mark = " " if r["present"] else "!"
175
+ print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
176
+ f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
177
+
178
+ if pending:
179
+ print("\nPending bills now in force (re-ingest the affected Acts):")
180
+ for b in pending:
181
+ print(f" Bill {b['bill']} ({b['name']}) in force {b['in_force']} "
182
+ f"-> {', '.join(b['affects'])}")
183
+ print(f" {b['note']}")
184
+ print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
185
+
186
+ return 1 if (stale or errors) else 0
187
+
188
+
189
+ def main():
190
+ args = sys.argv[1:]
191
+ as_json = "--json" in args
192
+ # --check is the only mode; accepted (and default) for forward-compatibility.
193
+ sys.exit(run(as_json=as_json))
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
tests/test_refresh.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the staleness-check logic (canlex/refresh.py). Offline only."""
2
+ import unittest
3
+
4
+ from canlex import refresh
5
+
6
+
7
+ class ExtractCurrentDateTests(unittest.TestCase):
8
+ def test_reads_root_attribute(self):
9
+ head = (b'<?xml version="1.0"?>\n<Statute xmlns:lims="..." '
10
+ b'lims:current-date="2026-03-31" lims:lastAmendedDate="2026-03-26">')
11
+ self.assertEqual(refresh._extract_current_date(head), "2026-03-31")
12
+
13
+ def test_strips_utf8_bom(self):
14
+ head = b'\xef\xbb\xbf<Regulation lims:current-date="2019-06-21">'
15
+ self.assertEqual(refresh._extract_current_date(head), "2019-06-21")
16
+
17
+ def test_returns_empty_when_absent(self):
18
+ self.assertEqual(refresh._extract_current_date(b"<Statute foo='bar'>"), "")
19
+
20
+ def test_takes_the_current_date_not_amended_date(self):
21
+ # lastAmendedDate must not be mistaken for current-date.
22
+ head = b'<Statute lims:lastAmendedDate="2099-01-01" lims:current-date="2026-05-26">'
23
+ self.assertEqual(refresh._extract_current_date(head), "2026-05-26")
24
+
25
+
26
+ class PendingInForceTests(unittest.TestCase):
27
+ def test_surfaces_a_bill_once_its_date_passes(self):
28
+ # C-16 in force 2026-07-18 in the tracked list.
29
+ after = refresh.pending_in_force("2026-08-01")
30
+ self.assertTrue(any(b["bill"] == "C-16" for b in after))
31
+
32
+ def test_hides_a_bill_before_its_date(self):
33
+ before = refresh.pending_in_force("2026-07-01")
34
+ self.assertFalse(any(b["bill"] == "C-16" for b in before))
35
+
36
+
37
+ class DriftSemanticsTests(unittest.TestCase):
38
+ def test_iso_date_string_compare_detects_newer_upstream(self):
39
+ # The check uses plain string comparison on ISO dates; verify ordering.
40
+ self.assertTrue("2026-05-26" > "2026-03-31")
41
+ self.assertFalse("2026-03-31" > "2026-03-31")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()