diff --git "a/canlex/server.py" "b/canlex/server.py" new file mode 100644--- /dev/null +++ "b/canlex/server.py" @@ -0,0 +1,12376 @@ +#!/usr/bin/env python3 +"""MCP server exposing CanLex Canadian legislation retrieval as tools. + +Runs over stdio. Launched by an MCP client (Claude Code / Claude Desktop); +it requires no API keys -- retrieval is fully local. +""" +import contextlib +import contextvars +import copy +import datetime +import functools +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Optional + +# Allow this file to be launched directly by an MCP client, cwd-independent. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from mcp.server.fastmcp import FastMCP + +from canlex.index import LegislationIndex + +mcp = FastMCP("canlex_mcp", host="0.0.0.0", + port=int(os.environ.get("PORT", "8000"))) + +_READONLY = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} + +# Prepended to every result so the model grounds, cites, and hedges correctly. +GROUNDING_NOTE = ( + "ANSWERING INSTRUCTIONS: Base the answer only on the material below. Cite " + "specific provisions and quote key operative words (e.g. 'IRPA s. 34(1)(c)'). " + "When a result lists related provisions, regulations or D-memoranda, fetch " + "any that bear on the question -- the definitions section, an exception, a " + "cross-referenced rule, the regulation that adds detail -- with " + "canlex_get_section or canlex_search_legislation before answering. " + "Distinguish the kinds of source: enacted law (Acts and regulations) is binding; " + "administrative guidance is persuasive only, not binding, and a court may " + "disagree with it -- attribute each item to the publisher its label names " + "(CBSA D-Memoranda and CBSA policy; IRCC's enforcement manuals (ENF), program " + "delivery instructions (PDI) and applicant guides; IRB Chairperson's " + "Guidelines; IRB Legal Services papers, which the IRB has archived), never to " + "CBSA by default, and repeat any archived, withdrawn or unofficial-ATIP-copy " + "status the label gives; collective agreements and the National " + "Joint Council directives they incorporate are binding employment-terms " + "instruments for a bargaining unit; court decisions interpret and apply the " + "law and are binding precedent depending on the court and jurisdiction, " + "while an IRB tribunal decision is persuasive only -- " + "name the deciding court and the date, and do not assume a decision is still " + "good law if it may have been overtaken (where this deployment has a CanLII " + "citator configured, canlex_case lists a decision's later treatment -- give " + "it the neutral citation; where it has none, say the decision's currency " + "was not checked). " + "Where a result carries a SUBSEQUENT HISTORY note, the decision it names was " + "reversed on appeal: do not rely on the passage for that decision's holding. " + "Always state the date the source is current to, and that the answer " + "reflects the law only as of that date -- for a time-sensitive matter, tell " + "the reader to verify no amendment has come into force since. If the material " + "below does not fully resolve the question -- including where it turns on case " + "law or facts not present here -- say so explicitly. This is legal information, " + "not legal advice." +) + +# canlex_get_section(grounding='brief') prints this instead of GROUNDING_NOTE (audit +# follow-up 2026-09-21, missing 13: the full note is 35 % of a section answer); the +# default stays the full note, which the canlex://grounding-note resource also serves +GROUNDING_BRIEF = ( + "ANSWERING INSTRUCTIONS (brief): the full instructions -- the canlex://grounding-note " + "resource, and the head of every canlex_search_legislation answer -- still apply. " + "Cite the provision and quote its operative words, state the date it is current " + "to, and say that this is legal information, not legal advice." +) + +HEDGE_THRESHOLD = 0.72 # max semantic similarity below which results are weak + +WEAK_MATCH_NOTE = ( + "RETRIEVAL CAUTION: the material below is only a weak match for this query " + "— CanLex may not contain a provision or decision that directly answers it. " + "Read it critically; if it does not actually address the question, say so " + "plainly rather than stretching it to fit, and consider canlex_list_acts to " + "check what the corpus covers." +) + +_INDEX: Optional[LegislationIndex] = None + + +def _index() -> LegislationIndex: + """Load and cache the legislation index on first use.""" + global _INDEX + if _INDEX is None: + _INDEX = LegislationIndex() + return _INDEX + + +_GUIDANCE_AGE_YEARS = 5 # warn when guidance is older than this +_YEAR = re.compile(r"\b(?:19|20)\d{2}\b") + + +def _log_query(query, top_k, act, doc_type, results): + """Hand a search's outcome -- n_results, the top confidence and the top + three citations -- to the call log's record of this tool call + (canlex/calllog.py, 2026-09-16; the query, top_k, act and doc_type are + already in that record's arguments). Real user queries -- especially + weak-match or no-result ones -- are the only unbiased source of corpus + gaps (the eval set is maintainer-authored); canlex/loganalyze.py mines + them. Until 2026-09-16 this appended the same fields to + CANLEX_LOG_DIR/queries.jsonl, which loganalyze still reads. A no-op + unless the call log is recording (opt-in, off by default). Best-effort: a + logging failure never affects the response.""" + try: + from canlex import calllog + if not calllog.recording(): + return + top = results[0] if results else None + calllog.note( + n_results=len(results), + confidence=(round(top["confidence"], 4) + if top and top.get("confidence") is not None else None), + top_citations=[r.get("citation", "") for r in results[:3]]) + except Exception: + pass + + +def _stated(value: str) -> str: + """A currency value for display, or an honest placeholder when the source + states none -- so a reader sees 'not stated by the source' rather than a + bare 'n/a' that reads as an oversight.""" + return value or "not stated by the source" + + +def _guidance_age_note(current_to: str) -> str: + """A warning when administrative guidance is several years old, since its + figures and procedures may have changed (e.g. duty-free limits in a 2013 + D-memo). Only fires when a year can be read from the currency value.""" + years = _YEAR.findall(current_to or "") + if not years: + return "" + newest = max(int(y) for y in years) + age = datetime.date.today().year - newest + if age >= _GUIDANCE_AGE_YEARS: + return (f"_⚠ This guidance is about {age} years old (dated {newest}); " + "its figures or procedures may have changed -- verify against " + "the current published version._") + return "" + + +# Who publishes each guidance source, keyed by act_code prefix (roadmap +# T1.10). Every doc_type 'memorandum' chunk used to render as 'CBSA +# administrative guidance' -- IRCC's enforcement manuals, program delivery +# instructions and applicant guide, the IRB's archived Legal Services papers +# and a Public Safety ministerial direction included -- so an officer quoting +# a result attributed an IRCC guide or an archived IRB paper to CBSA. This is +# render-time only: labels are not embedded, so nothing is re-ingested or +# re-embedded. The longest matching prefix wins; an unmapped code gets the +# neutral label (tests/test_source_labels.py fails on any code in +# data/processed that falls through, so a new source gets a deliberate label). +# Each entry: (prefix, publisher, force, status). +_GUIDANCE_FORCE = "administrative guidance: persuasive, not binding law" +_PUBLISHERS = ( + ("D-Memo", "CBSA administrative guidance", "persuasive, not binding law", ""), + ("AMPS", "CBSA administrative guidance", "persuasive, not binding law", ""), + ("CBSA-", "CBSA administrative guidance", "persuasive, not binding law", ""), + ("ENF-", "IRCC enforcement manual (applied by CBSA)", _GUIDANCE_FORCE, ""), + ("PDI-", "IRCC program delivery instructions", _GUIDANCE_FORCE, ""), + ("IMM-5312", "IRCC applicant guide (Guide 5312)", _GUIDANCE_FORCE, ""), + ("IMM-", "IRCC application form page", _GUIDANCE_FORCE, ""), + ("IRCC-PUB", "IRCC public web page (applicant-facing)", _GUIDANCE_FORCE, ""), + ("IRB-LS-ROA", "IRB Legal Services paper", + "legal research paper: persuasive, not binding law", + "Archived by the IRB (its page is marked archived 2018-07-06) and not " + "updated since"), + ("IRB-LS-SPO", "IRB Legal Services paper", + "legal research paper: persuasive, not binding law", + "Archived by the IRB (listed as archived in its legal-concepts index) and " + "not updated since"), + ("IRB-LS", "IRB Legal Services paper", + "legal research paper: persuasive, not binding law", ""), + ("IRB-G", "IRB Chairperson's Guideline", + "guidance for IRB proceedings: persuasive, not binding law", ""), + ("PS-MD", "Public Safety ministerial direction to the CBSA", + "not legislation; confirm it is still in effect", ""), +) +_NEUTRAL_PUBLISHER = ("", "Administrative guidance", "persuasive, not binding law", "") +# Tran v Canada (Public Safety and Emergency Preparedness), 2017 SCC 50, was +# decided on this date (pinned against caselaw.json by +# tests/test_source_labels.py); guidance dated before it cannot reflect it. +_TRAN_DECIDED = "2017-10-19" + + +def _publisher(c): + """The _PUBLISHERS entry for a guidance chunk (the neutral one when no + prefix matches).""" + code = c.get("act_code") or "" + best = _NEUTRAL_PUBLISHER + for entry in _PUBLISHERS: + if code.startswith(entry[0]) and len(entry[0]) > len(best[0]): + best = entry + return best + + +def _guidance_label(c): + """The one-line italic provenance label for a doc_type 'memorandum' + chunk: publisher, legal force, and any status the source carries + (an unofficial ATIP copy of a withdrawn ENF chapter; an archived IRB + paper that predates Tran).""" + prefix, publisher, force, status = _publisher(c) + notes = [status] if status else [] + if prefix == "ENF-" and "unofficial atip copy" in (c.get("act_name") or "").lower(): + notes.append("Unofficial ATIP copy of a chapter withdrawn from public " + "distribution (see the History line)") + if prefix.startswith("IRB-LS") and (c.get("current_to") or "9") < _TRAN_DECIDED: + notes.append("it predates Tran, 2017 SCC 50" if notes + else "It predates Tran, 2017 SCC 50") + tail = ("; ".join(notes) + ".") if notes else "" + return f"_{publisher} — {force}.{' ' + tail if tail else ''}_" + + +def _is_jurisprudential_guide(c): + """True for an IRB decision the corpus records as a Chairperson's + jurisprudential guide (its act_name / citation says so) -- only those + carry the jurisprudential-guide label; any other IRB decision (IAD, ID, + RPD, RAD) is a tribunal decision, persuasive only.""" + return "jurisprudential guide" in ( + f"{c.get('act_name', '')} {c.get('citation', '')}".lower()) + + +# Guidance that cites a decision later reversed on appeal (roadmap T1.11; +# moves into data/curated/authorities.json with T3.2). ENF 1 (2013) states, +# citing the Federal Court trial decision in Saini, that 'An officer cannot +# ignore a valid pardon given in another country with a similar justice +# system'; the PDI relief-mechanisms page attributes the recognition test to +# the same trial report. The Federal Court of Appeal reversed it. The note is +# appended where the chunk is shown; the chunk's own text is never altered. +# Every single-quoted phrase in a note is pinned against the appellate +# decision's text by tests/test_source_labels.py. +SUPERSEDED_CITES = ( + {"id": "saini-2000-fctd", + "pattern": re.compile( + r"Saini\b.{0,100}?\(2000\)" + r"|\[2000\]\s*3\s*F\.?\s*C\.?(?:\s| )*253" + r"|184\s*D\.\s*L\.\s*R\.\s*\(4\s*th\)\s*568", re.S), + "note": ("⚠ SUBSEQUENT HISTORY: this passage cites the Federal Court trial " + "decision in Saini ([2000] 3 F.C. 253; 184 D.L.R. (4th) 568), which " + "the Federal Court of Appeal reversed: Canada (MCI) v Saini, 2001 " + "FCA 311. The FCA states (para 24) that 'three elements must be " + "established before a foreign discharge or pardon may be " + "recognized'; asked whether, absent evidence of the motivating " + "considerations that led to a foreign pardon, a Canadian court is " + "bound by it, the FCA 'answers no to the first certified " + "question'; and 'The appeal will be allowed' (paras 51-52). The " + "current official statement is ENF 3 s. 10.9 (enf-3-10-9). The " + "passage above is reproduced unaltered."), + # Where to read the appellate decision: only claimed to be in the corpus + # when the loaded index holds it (a release built from a corpus without + # it must not send the officer to a search that finds nothing). + "appellate_citation": "2001 FCA 311", + "read_present": ("Read the FCA decision with canlex_search_legislation " + "(doc_type 'caselaw', query 'Saini 2001 FCA 311')."), + "read_absent": ("The FCA decision is not in this deployment's corpus; " + "ENF 3 s. 10.9 states its rule."), + "read_unknown": ("Search for the FCA decision with " + "canlex_search_legislation (doc_type 'caselaw', query " + "'Saini 2001 FCA 311'); if it is not in the corpus, ENF 3 " + "s. 10.9 states its rule.")}, +) + + +def _corpus_has_decision(citation): + """True / False when the loaded index does / does not hold a decision + whose citation contains `citation`; None when no index is loaded (this + never loads one -- the curated screening tools run without it).""" + chunks = getattr(_INDEX, "chunks", None) + if chunks is None: + return None + return any(citation in (c.get("citation") or "") for c in chunks + if c.get("doc_type") == "caselaw") + + +def _index_chunks(): + """The loaded index's chunks, or None when no index is loaded (the + curated screening tools run without one; the authority registry then + reads data/processed).""" + return getattr(_INDEX, "chunks", None) + + +def _superseded_specs(): + """The subsequent-history entries: the authority registry's (roadmap + T3.2, only when its rows are rendered -- authorities.renderable), else + SUPERSEDED_CITES.""" + from canlex.authorities import superseded_entries + return superseded_entries() or SUPERSEDED_CITES + + +def _superseded_notes(text): + """The SUBSEQUENT HISTORY notes for every reversed decision the text + cites, once each, each ending with where to read the appellate decision + (worded by whether the loaded corpus holds it).""" + out = [] + for s in _superseded_specs(): + if not s["pattern"].search(text or ""): + continue + held = _corpus_has_decision(s["appellate_citation"]) + pointer = (s["read_present"] if held else s["read_unknown"] if held is None + else s["read_absent"]) + out.append(f"{s['note']} {pointer}") + return out + + +def _with_superseded(text): + """Text plus, before any trailing CANLEX_RESULT line, the SUBSEQUENT + HISTORY note for each reversed decision it cites (screening tools whose + curated entries or methodology quote guidance).""" + notes = _superseded_notes(text) + if not notes: + return text + head, sep, tail = text.rpartition("\n\nCANLEX_RESULT: ") + if not sep: + return text + "\n\n" + "\n\n".join(notes) + return head + "\n\n" + "\n\n".join(notes) + sep + tail + + +# Guidance that still cites a repealed provision (roadmap T2.1): ENF 1 s. 3 +# (2013), ENF 2 (ATIP) and ENF 3 s. 10.8 (2022) describe transborder +# criminality as 'A36(2)(d)'. The note is appended where such a guidance +# chunk is shown; the chunk's own text is never altered. The repeal and the +# new paragraph are pinned by tests/test_transborder.py. +AMENDED_REFERENCES = ( + {"id": "irpa-36-2-d", + "pattern": re.compile(r"\bA36\(2\)\(d\)|paragraph 36\(2\)\(d\)", re.I), + "note": ("⚠ AMENDED LAW: IRPA s. 36(2)(d) was repealed (2023, c. 32, s. 53). " + "Transborder criminality is now s. 36(2.1) -- 'committing, on " + "entering Canada, a prescribed offence under an Act of Parliament' -- " + "with the prescribed offences in IRPR s. 19 as amended (SOR/2024-128, " + "which adds the Cannabis Act and lists offences by section and goods), " + "the delegate's orders in IRPR s. 228(1)(a.01) and the Immigration " + "Division's in s. 229(1)(d.1). Screen it with canlex_transborder. The " + "passage above is reproduced unaltered.")}, +) + + +def _amended_notes(text): + """The AMENDED LAW note for each repealed provision a guidance text cites.""" + return [a["note"] for a in AMENDED_REFERENCES if a["pattern"].search(text or "")] + + +# The '(part k of n)' / '(part k)' marker the ingesters append to a split +# section's citation or title. Stripped wherever a whole section is served +# or a pin-cite is built, so the client never emits 'IRPA, s. 36 (part 1 of +# 2)(1)(c)' as a citation. +_PART_NOTE = re.compile( + r"\s*\((?:part|partie)\s+\d+(?:\s+(?:of|de)\s+\d+)?\)", re.IGNORECASE) + + +def _clean_citation(citation: str) -> str: + return _PART_NOTE.sub("", citation) + + +def _assemble(parts): + """One chunk carrying the whole section. Long provisions are stored as + '-p1', '-p2', ... pieces (IRPA s. 36 is two, IRPR s. 18 three, the CDSA's + Schedule I ten) and get_section used to serve only the first piece -- so + IRPA s. 36(3), the hybrid-deeming and rehabilitation rules the screening + tools point the client at, was unreachable by any tool.""" + if len(parts) == 1: + return parts[0] + whole = dict(parts[0]) + whole["citation"] = _clean_citation(parts[0]["citation"]) + whole["marginal_note"] = _PART_NOTE.sub("", parts[0]["marginal_note"]) + whole["text"] = "\n\n".join(p["text"] for p in parts) + whole["assembled_parts"] = len(parts) + whole.pop("highlight", None) + return whole + + +def _not_found(index, act: str, section: str) -> str: + """A get_section/citing miss: name the closest instruments, not the whole + inventory (~325 names, 195 of them decisions get_section cannot serve -- + the old dump cost ~1.1k tokens per miss and taught the client that + 'Baker' is an Act).""" + import difflib + known = {} + for c in index.chunks: + if c.get("doc_type", "legislation") == "legislation": + known.setdefault(c["act_short"], c.get("act_name", "")) + act_lc = act.strip().lower() + loaded = any(act_lc in (short.lower(), name.lower()) + for short, name in known.items()) or any( + act_lc == c["act_code"].lower() for c in index.chunks) + if loaded: + return (f"Error: '{act}' is loaded but has no section '{section}'. " + "Check the number (a subsection form like '36(1)(a)' is " + "fine -- the whole section is returned), or call " + f"canlex_toc(act='{act}') to list its sections.") + hint = _closest_acts(index, act, known) + return (f"Error: no loaded instrument is named '{act}'.{hint} " + "canlex_list_acts gives the full inventory; " + "canlex_search_legislation finds a provision by topic.") + + +def _closest_acts(index, act: str, known=None) -> str: + """' Did you mean: IRPA (Immigration and Refugee Protection Act); ...? ' -- + the legislation short names closest to `act`, or '' when none is close. + Whole-word overlap first, character similarity second: 'Immigration Act' + must find IRPA and the Immigration Division Rules, not whichever short + names happen to share letters with it. Shared by _not_found and the + search tool's act-filter miss (the code audit of 2026-09-21).""" + import difflib + if known is None: + known = {} + for c in index.chunks: + if c.get("doc_type", "legislation") == "legislation": + known.setdefault(c["act_short"], c.get("act_name", "")) + act_lc = act.strip().lower() + stop = {"act", "the", "and", "of", "on", "regulations", "regs", "rules"} + q_words = {w for w in re.findall(r"[a-z0-9]+", act_lc) if w not in stop} + scored = [] + for short, name in known.items(): + words = {w for w in re.findall(r"[a-z0-9]+", f"{short} {name}".lower()) + if w not in stop} + overlap = (len(q_words & words) / len(q_words)) if q_words else 0.0 + ratio = max(difflib.SequenceMatcher(None, act_lc, short.lower()).ratio(), + difflib.SequenceMatcher(None, act_lc, name.lower()).ratio()) + score = overlap + ratio + if score >= 0.8: + scored.append((score, short)) + shorts = [short for _, short in sorted(scored, reverse=True)[:3]] + return (" Did you mean: " + "; ".join( + f"{sh} ({known[sh]})" if known[sh] else sh for sh in shorts) + "?" + if shorts else "") + + +def _format_section(c: dict, related=None) -> str: + """Render one chunk (legislation, D-Memo, or agreement) as cited Markdown.""" + doc_type = c.get("doc_type", "legislation") + header = f"### {c['citation']} — {c['marginal_note']}".rstrip(" —") + location = " > ".join(p for p in (c["part"], c["division"]) if p) + lines = [header] + if location: + lines.append(f"*{location}*") + if c.get("assembled_parts", 0) > 1: + lines.append(f"_Complete text, assembled from " + f"{c['assembled_parts']} stored parts._") + if c.get("applicability"): + lines.append(f"_**⚠ APPLICABILITY:** {c['applicability']}_") + if doc_type == "memorandum": + lines.append(_guidance_label(c)) + lines.append(f"(modified {_stated(c['current_to'])})") + age = _guidance_age_note(c["current_to"]) + if age: + lines.append(age) + elif doc_type == "agreement": + lines.append("_Treasury Board collective agreement — a binding contract for " + "this bargaining unit._") + lines.append(f"(in force to {_stated(c['current_to'])})") + elif doc_type == "directive": + lines.append("_National Joint Council directive — forms part of collective " + "agreements; binding for the matters it covers._") + lines.append(f"(effective {_stated(c['current_to'])})") + age = _guidance_age_note(c["current_to"]) + if age: + lines.append(age) + elif doc_type == "caselaw": + if ("Immigration and Refugee Board" in c["part"] + and _is_jurisprudential_guide(c)): + lines.append("_Immigration and Refugee Board jurisprudential guide " + "— IRB members apply its reasoning to similar cases or " + "explain why not; persuasive, and subject to revocation " + "or to review by the Federal Court._") + elif "Immigration and Refugee Board" in c["part"]: + # An IAD, ID, RPD or RAD decision that is not a designated + # jurisprudential guide (roadmap T1.10): no guide label. + lines.append("_IRB tribunal decision, persuasive only — not " + "binding precedent, and subject to judicial review " + "by the Federal Court._") + elif "Board" in c["part"]: + lines.append("_Labour-board decision — a federal administrative " + "tribunal's ruling; persuasive within the board's own " + "jurisprudence, and subject to judicial review by the " + "Federal Court of Appeal._") + else: + lines.append("_Court decision — binding precedent depending on the " + "court and jurisdiction; confirm it has not been " + "overturned on appeal or overtaken by later authority._") + lines.append(f"(decided {c['current_to'] or 'n/a'})") + if c["heading"]: + lines.append(f"Subject: {c['heading']}") + elif doc_type == "delegation": + lines.append("_Instrument of delegation and designation — it records " + "which officials the Minister has delegated powers to, or " + "designated for functions, under IRPA and the IRPR. " + "Administrative; confirm it is still the current version._") + lines.append(f"(dated {c['current_to'] or 'n/a'})") + elif doc_type == "benefits": + lines.append("_Benefit-plan member booklet — a plain-language summary " + "published by the plan administrator (Canada Life). The " + "governing terms are in the plan's directive or rules, " + "which prevail in any discrepancy; quote the booklet for " + "what it says but flag that the plan rules control._") + lines.append(f"(booklet edition: {_stated(c['current_to'])})") + age = _guidance_age_note(c["current_to"]) + if age: + lines.append(age) + elif doc_type == "union": + lines.append("_Union document (CIU/PSAC) — internal governance " + "rules, member guides or locally negotiated scheduling " + "instruments published by the bargaining agent. They " + "bind the union and its members under the union's own " + "rules; they are NOT employer policy and NOT part of " + "the collective agreement itself (a VSSA operates " + "under the FB agreement's variable-shift provisions). " + "The union site may lag renegotiations — verify " + "currency with the Branch._") + lines.append(f"(document currency: {_stated(c['current_to'])})") + age = _guidance_age_note(c["current_to"]) + if age: + lines.append(age) + elif doc_type == "policy": + lines.append("_Employer policy instrument (Treasury Board / CBSA) — " + "binding on employees as a condition of employment (the " + "CBSA Code of Conduct requires an annual attestation) " + "and the yardstick discipline decisions apply; NOT " + "legislation and NOT part of the collective agreement. " + "Discipline imposed under it is grievable (FB agreement " + "art. 17-18; FPSLRA s. 208)._") + lines.append(f"(page modified {_stated(c['current_to'])}" + f"{'; ' + c['history'] if c['history'] else ''})") + age = _guidance_age_note(c["current_to"]) + if age: + lines.append(age) + elif doc_type == "commentary": + lines.append("_**CURATED ANALYSIS — CanLex commentary, not a source " + "of law.** A curated synthesis of the cited authorities; " + "where it says no authority exists, the analysis is " + "reasoned interpretation only. Always verify against the " + "cited cases and guidance before relying on it._") + lines.append(f"(entry reviewed {_stated(c['current_to'])})") + elif c.get("status") == "not-in-force": + # Enacted but not yet in force: pending law from the Act's AMENDMENTS + # NOT IN FORCE schedule. Make it impossible to mistake for current law. + lines.append("_**⚠ NOT YET IN FORCE — this is an enacted amendment " + "that has not come into force.** It is NOT current law; " + "the provision it amends (if any) continues to read as " + "consolidated. Cite it only as pending law, naming the " + f"amending Act: {c['history'] or 'see text'}._ " + f"(consolidation current to {c['current_to'] or 'n/a'})") + elif c.get("status") == "repealed": + # A repealed provision is no longer law; say so unmistakably so the + # model never presents the (near-empty) stub as if it were in force. + lines.append("_**⚠ REPEALED — this provision is no longer in force.** " + "The repeal citation is in the text below; do not rely on " + "it as current law, and tell the reader it has been " + f"repealed._ (text current to {c['current_to'] or 'n/a'})") + else: + meta = [f"in force; text current to {c['current_to'] or 'n/a'}"] + if c.get("in_force"): + meta.append(f"this provision in force since {c['in_force']}") + if c["last_amended"]: + meta.append(f"last amended {c['last_amended']}") + lines.append(f"**Currency:** {'; '.join(meta)}. Does not reflect any " + f"amendment that came into force after the 'current to' date.") + hl = c.get("highlight") + if hl: + label, snippet = hl + lines.append(f"**Most on point for this query:** " + f"{_clean_citation(c['citation'])}{label} — {snippet}") + lines.append("") + lines.append(c["text"]) + lines.append("") + if doc_type in ("memorandum", "commentary"): + # A reversed decision the guidance still cites (roadmap T1.11). + for note in _superseded_notes(c["text"]): + lines.append(f"_{note}_") + lines.append("") + # A repealed paragraph the guidance still describes (roadmap T2.1). + for note in _amended_notes(c["text"]): + lines.append(f"_{note}_") + lines.append("") + if related: + provisions = related.get("provisions") + if provisions: + refs = "; ".join(f"s. {s} ({n})" if n else f"s. {s}" + for s, n in provisions) + lines.append(f"Related provisions in this Act: {refs}") + regs = related.get("regulations") + if regs: + lines.append("Regulations made under this Act: " + + "; ".join(f"{n} ({s})" for s, n in regs)) + enabling = related.get("enabling_act") + if enabling: + lines.append(f"Made under: {enabling[1]} ({enabling[0]})") + memos = related.get("memoranda") + if memos: + lines.append("CBSA D-memoranda citing this section (guidance, not " + "binding): " + ", ".join(memos)) + if c["history"]: + if doc_type == "caselaw": + lines.append(f"Also reported: {c['history']}") + elif doc_type == "legislation": + lines.append(f"Amendment history: {c['history']}") + else: + lines.append(f"History: {c['history']}") + lines.append(f"Source: {c['source_url']}") + return "\n".join(lines) + + +# The ten doc_type values, as a Literal so every client schema carries them +# as an enum rather than free text (regex 'pattern' constraints are dropped by +# several function-calling translators, notably Gemini's). +DocType = Literal["legislation", "memorandum", "agreement", "directive", + "caselaw", "delegation", "benefits", "commentary", "union", + "policy"] + +# Longest alternative first, and 's'/'ss' only at a word boundary or before +# a dotted number -- so 'section 20.1' loses the whole word (not its first +# letter) and a real section value like 'Schedule I' is left alone. +_SECTION_PREFIX = re.compile( + # (the code audit of 2026-09-21: 'sec. 36', 'art. 36' / 'article 36' -- the French + # form the FR twins teach --, 'subsection 36(1)' and 'par. 36(1)(c)' are prefixes + # too; a schedule word never is, so 'Schedule I' stays whole) + r"^(?:sections?\b|subsections?\b|paragraphs?\b|sec(?:t)?\.?(?=\s*\d)|" + r"art(?:icles?)?\.?(?=\s*\d)|par(?:a)?\.?(?=\s*\d)|ss?\.(?=\s*\d)|ss?\b|§+)\s*", + re.IGNORECASE) + + +def _normalize_section(section: str) -> str: + """Accept the forms other tool outputs teach a client to write -- 's. 34', + 'section 34', '34(1)(c)' -- and reduce them to the bare section number the + index keys on. Subsection parentheticals are truncated: the section is + served whole (every stored part, see _assemble), so '36(3)(a)' resolves + to the text that contains subsection (3).""" + s = _SECTION_PREFIX.sub("", section.strip()) + s = s.split("(", 1)[0].strip().rstrip(".") + # The Justice Laws XML labels s. 320.20 '320.20' but the ingester stores + # the numeric form '320.2'; accept the statutory label. + if re.fullmatch(r"\d+\.\d*[1-9]0+", s) or re.fullmatch(r"\d+\.0+", s): + s = s.rstrip("0").rstrip(".") + return s or section.strip() + + +_SCHEDULE_FORM = re.compile(r"^\s*sch(?:edule)?\.?\s*(.*?)\s*$", re.IGNORECASE) +_ROMAN = {"i": 1, "ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7, "viii": 8, + "ix": 9, "x": 10, "xi": 11, "xii": 12, "xiii": 13, "xiv": 14, "xv": 15, + "xvi": 16, "xvii": 17, "xviii": 18, "xix": 19, "xx": 20} +_ARABIC = {str(v): k for k, v in _ROMAN.items()} + + +def _schedule_label(index, act: str, section: str): + """The stored 'Schedule ...' label a schedule reference names, in any case + and with the number in either numeral system -- 'Sch. 1', 'schedule i', + 'SCHEDULE 1' and 'Schedule I' all find IRPR's 'Schedule 1' and the CDSA's + 'Schedule I' -- or None (the code audit of 2026-09-21: the stored label was + compared exactly, so 'Schedule I' failed for IRPR and 'schedule 2' + everywhere).""" + m = _SCHEDULE_FORM.match(section or "") + if not m: + return None + tail = " ".join(m.group(1).lower().split()) + wanted = {tail} + if tail in _ROMAN: + wanted.add(str(_ROMAN[tail])) + elif tail in _ARABIC: + wanted.add(_ARABIC[tail]) + act_lc = act.strip().lower() + labels = sorted({c["section"] for c in index.chunks + if str(c.get("section") or "").lower().startswith("schedule") + and act_lc in (c["act_short"].lower(), c["act_code"].lower(), + c.get("act_name", "").lower())}) + for label in labels: + if " ".join(label.lower().removeprefix("schedule").split()) in wanted: + return label + return None + + +@mcp.tool(name="canlex_search_legislation", + annotations={"title": "Search Canadian Legislation", **_READONLY}) +def canlex_search_legislation( + query: Annotated[str, Field( + description="Natural-language legal question or keywords, e.g. " + "'detention review timelines' or 'inadmissibility for serious " + "criminality'.", min_length=2, max_length=500)], + top_k: Annotated[int, Field( + description="Number of sections to return (1-20). Use more for broad " + "questions.", ge=1, le=20)] = 6, + act: Annotated[Optional[str], Field( + description="Optional filter to a single Act, by short name, code or " + "full name (e.g. 'IRPA', 'I-2.5', 'Customs Act'). Omit to search " + "every loaded instrument.")] = None, + doc_type: Annotated[Optional[DocType], Field( + description="Optional filter by source type: 'legislation' (Acts and " + "regulations, incl. their Schedules), 'memorandum' (administrative " + "guidance, each result labelled with its publisher: CBSA D-Memoranda " + "and CBSA policy, IRCC's enforcement manuals (ENF), program delivery " + "instructions (PDI) and applicant guides, IRB Chairperson's " + "Guidelines, archived IRB Legal Services papers), 'agreement' " + "(collective agreements), " + "'directive' (NJC directives), 'caselaw' (court and tribunal " + "decisions), 'delegation' (IRPA/IRPR delegation instruments), " + "'benefits' (benefit-plan member booklets), 'commentary' " + "(CanLex's curated analysis datasets, e.g. foreign -- mostly US -- " + "dispositions vs the IRPA 'conviction' concept), 'union' (CIU/PSAC by-laws, " + "regulations, branch rules, member guides and site VSSAs), or " + "'policy' (employer conduct instruments: the CBSA Code of Conduct " + "and the Values and Ethics Code for the Public Sector). Omit to " + "search all.")] = None, + court: Annotated[Optional[str], Field( + description="Optional: restrict CASE-LAW results to a deciding " + "court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court " + "of Appeal', 'Immigration and Refugee Board', 'FPSLREB'. Other " + "source types are unaffected.", max_length=60)] = None, + decided_after: Annotated[Optional[int], Field( + description="Optional: restrict CASE-LAW results to decisions from " + "this year onward, e.g. 2015. Other source types are unaffected.", + ge=1950, le=2100)] = None, +) -> str: + """Search Canadian federal law, administrative guidance (CBSA, IRCC and + IRB), agreements, NJC directives, and leading court decisions. + + The CanLex corpus has these kinds of source: federal Acts and regulations + with their Schedules (immigration, customs, criminal, firearms, drugs, + food/health, labour, privacy and more); administrative guidance, each + result labelled with its real publisher -- CBSA D-Memoranda and CBSA + policy, IRCC's enforcement manuals (ENF, applied by CBSA), program + delivery instructions and applicant guides, the IRB Chairperson's + Guidelines and the IRB's archived Legal Services papers; Treasury Board + collective agreements (currently the FB / Border Services group); National + Joint Council directives (travel, relocation, isolated posts and more); + leading decisions of the courts and federal tribunals (Supreme Court, + Federal Court of Appeal and Federal Court, the IRB, the FPSLREB and CIRB + labour boards, and other courts and tribunals; use court= to restrict); + instruments of delegation and designation under IRPA + and the IRPR; member booklets of the public-service benefit plans; + CanLex's curated commentary (foreign -- mostly US -- disposition and + equivalency analysis); + and union documents (the CIU by-laws/regulations/branch rules, the PSAC + Constitution, member guides, and every published site VSSA). + Use this for ANY question about that material. It ranks results by + relevance and returns their full text so the answer can cite the actual + wording; an explicit section reference (e.g. "section 34") is always + surfaced. Each result is marked with its source type. + + Returns: + str: Markdown with answering instructions followed by the matching + sections -- citation, Part/Division, currency dates, full text, + amendment history and source URL per result. Returns a "No ... + matched" message if nothing is found. + + Examples: + - "What are the security grounds for inadmissibility?" -> query about s. 34. + - "detention review 48 hours" -> finds the detention-review provisions. + - Don't use to look up a known section number verbatim -- use canlex_get_section. + """ + index = _index() + results = index.search(query, top_k=top_k, act=act, doc_type=doc_type, + court=court, decided_after=decided_after) + _log_query(query, top_k, act, doc_type, results) + if not results: + if act: + act_lc = act.strip().lower() + if not any(act_lc in (c["act_short"].lower(), c["act_code"].lower(), + c.get("act_name", "").lower()) + for c in index.chunks): + # (the code audit of 2026-09-21: the inventory listed every act_short in + # the corpus, ~190 decision names included, as 'short names accepted'; + # the closest legislation names are offered instead, as _not_found does) + hint = _closest_acts(index, act) + return (f"No results: the act filter '{act}' matches no loaded " + f"instrument.{hint} Retry without the filter, " + + ("with one of those names, " if hint else "") + + "or call canlex_list_acts for the inventory.") + scope = f" in '{act}'" if act else "" + return (f"No results matched '{query}'{scope}. " + f"Try broader or different keywords, or call canlex_list_acts to see " + f"what is currently loaded.") + blocks = [] + weak = results[0].get("confidence") + if weak is not None and weak < HEDGE_THRESHOLD: + blocks += [WEAK_MATCH_NOTE, ""] + blocks += [GROUNDING_NOTE, "", + f'{len(results)} relevant section(s) for: "{query}"'] + for c in results: + blocks.append("") + blocks.append("---") + blocks.append("") + blocks.append(_format_section(c, index.related(c))) + return "\n".join(blocks) + + +@mcp.tool(name="canlex_get_section", + annotations={"title": "Get a Legislation Section", **_READONLY}) +def canlex_get_section( + act: Annotated[str, Field( + description="Act short name or code, e.g. 'IRPA' or 'I-2.5'.", + min_length=1, max_length=60)], + section: Annotated[str, Field( + description="Section number, e.g. '34', '20.1', 's. 34' or " + "'34(1)(c)' -- prefixes and subsection parentheticals are accepted " + "and normalized.", min_length=1, max_length=40)], + grounding: Annotated[Literal["full", "brief"], Field( + description="'full' (default): the answering instructions in full above the " + "section. 'brief': a three-line reminder instead, for a client that already " + "holds the full instructions (the canlex://grounding-note resource).")] = "full", +) -> str: + """Retrieve one specific section of legislation verbatim, by Act and section number. + + Use this when the exact citation is already known (e.g. the user asks about + "IRPA section 34" or a provision cross-references "subsection 25.1(1)"). + + Returns: + str: Markdown with answering instructions followed by the section's citation, + Part/Division, 'current to' and 'last amended' dates, full provision text, + amendment history, and source URL. Returns an actionable error message (listing + the loaded Acts) if the Act or section is not found. grounding 'brief' + shortens the answering instructions to a reminder that the full ones apply. + + Examples: + - "Show me IRPA s. 34" -> act='IRPA', section='34'. + - "What does subsection 20.1 say" -> act='IRPA', section='20.1'. + """ + index = _index() + parts = index.get_section_parts(act, section) + if not parts and section != _normalize_section(section): + parts = index.get_section_parts(act, _normalize_section(section)) + if not parts: + label = _schedule_label(index, act, section) + if label: + parts = index.get_section_parts(act, label) + if not parts: + return _not_found(index, act, section) + note = GROUNDING_BRIEF if grounding == "brief" else GROUNDING_NOTE + return (note + "\n\n" + + _format_section(_assemble(parts), index.related(parts[0]))) + + +_CURATED_CACHE = {} + + +def _curated_json(name): + """A curated dataset (data/curated/) as a dict, or None when the file + is absent, unreadable, not JSON or not an object -- the tools then answer + 'not present in this deployment' instead of raising to the client (the + code audit of 2026-09-21: a truncated file took every screening tool down + with a raw JSONDecodeError). Cached by the file's mtime, as _held_manifest + is, so a call does not re-parse the file.""" + from canlex.config import DATA_DIR + path = DATA_DIR / "curated" / name + try: + stamp = path.stat().st_mtime_ns + except OSError: + _CURATED_CACHE.pop(str(path), None) + return None + hit = _CURATED_CACHE.get(str(path)) + if hit is not None and hit[0] == stamp: + return hit[1] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + print(f"CanLex MCP: {path.name} could not be read ({type(exc).__name__}): the " + "dataset is treated as absent", file=sys.stderr) + data = None + if data is not None and not isinstance(data, dict): + print(f"CanLex MCP: {path.name} is not a JSON object: the dataset is treated as " + "absent", file=sys.stderr) + data = None + _CURATED_CACHE[str(path)] = (stamp, data) + return data + + +def _load_dispositions(): + """The curated US-dispositions dataset (data/curated/us_dispositions.json).""" + return _curated_json("us_dispositions.json") + + +_STATE_NAMES = { + "al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas", + "ca": "california", "co": "colorado", "ct": "connecticut", "de": "delaware", + "fl": "florida", "ga": "georgia", "hi": "hawaii", "id": "idaho", + "il": "illinois", "in": "indiana", "ia": "iowa", "ks": "kansas", + "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland", + "ma": "massachusetts", "mi": "michigan", "mn": "minnesota", + "ms": "mississippi", "mo": "missouri", "mt": "montana", "ne": "nebraska", + "nv": "nevada", "nh": "new hampshire", "nj": "new jersey", + "nm": "new mexico", "ny": "new york", "nc": "north carolina", + "nd": "north dakota", "oh": "ohio", "ok": "oklahoma", "or": "oregon", + "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina", + "sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah", + "vt": "vermont", "va": "virginia", "wa": "washington", + "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming", + "dc": "district of columbia", +} + +# The rest of what a US record's state field can name (roadmap T2.9): the +# territories, the District of Columbia's other spellings, and a federal +# (not state) prosecution. None of these has curated state rows. +_US_TERRITORIES = {"pr": "puerto rico", "gu": "guam", "vi": "u.s. virgin islands", + "as": "american samoa", "mp": "northern mariana islands"} +_US_OTHER = frozenset({ + "washington dc", "washington d.c.", "washington, d.c.", "washington, dc", + "d.c.", "virgin islands", "us virgin islands", "united states virgin islands", + "usvi", "northern marianas", "cnmi", "federal", "us federal", "u.s. federal", + "united states federal", "federal (us)", "federal court"}) +_US_COUNTRY = frozenset({"us", "u.s.", "usa", "u.s.a.", "united states", + "united states of america", "the united states", + "america"}) +_CANADA_COUNTRY = frozenset({"canada", "can", "can.", "ca"}) +_PROVINCES = {"on": "ontario", "qc": "quebec", "bc": "british columbia", + "ab": "alberta", "mb": "manitoba", "sk": "saskatchewan", + "ns": "nova scotia", "nb": "new brunswick", + "nl": "newfoundland and labrador", "pe": "prince edward island", + "nt": "northwest territories", "yt": "yukon", "nu": "nunavut"} +# Other spellings of a state or province a record's state field carries +# (disposition roadmap T1.4): the GPO / Bluebook dotted abbreviations, keyed +# WITH their dots (spaces after a dot removed), so 'Ind.' is Indiana while a +# bare 'IND' stays unrecognised; the District of Columbia's spellings, keyed +# with dots and commas removed. A dotted two-letter code ('N.J.', 'B.C.') is +# read as its code. Nothing here makes an unlisted dotted value US: 'N.S.W.' +# and 'Qld.' stay non-US (no 'ends in a dot' rule). +_STATE_ABBREVIATIONS = { + "ala.": "al", "alas.": "ak", "ariz.": "az", "ark.": "ar", "cal.": "ca", "calif.": "ca", + "colo.": "co", "conn.": "ct", "del.": "de", "fla.": "fl", "haw.": "hi", "ill.": "il", + "ind.": "in", "kan.": "ks", "kans.": "ks", "mass.": "ma", "mich.": "mi", "minn.": "mn", + "miss.": "ms", "mont.": "mt", "neb.": "ne", "nebr.": "ne", "nev.": "nv", + "n.mex.": "nm", "n.dak.": "nd", "okla.": "ok", "ore.": "or", "oreg.": "or", + "penn.": "pa", "penna.": "pa", "s.dak.": "sd", "tenn.": "tn", "tex.": "tx", + "wash.": "wa", "w.va.": "wv", "wis.": "wi", "wisc.": "wi", "wyo.": "wy"} +_DC_SPELLINGS = frozenset({"dc", "washington dc", "district of columbia", + "washington district of columbia"}) +_PROVINCE_ABBREVIATIONS = { + "ont.": "on", "que.": "qc", "alta.": "ab", "man.": "mb", "sask.": "sk", + "nfld.": "nl", "p.e.i.": "pe", "pei": "pe", "n.w.t.": "nt", "nwt": "nt", + "y.t.": "yt", "nvt.": "nu", "nun.": "nu"} +_US_WRAPPER = re.compile(r"(?:\s*,\s*|\s+|\s*\(\s*)(?:u\.?\s?s\.?\s?a\.?|u\.?\s?s\.?|" + r"united states(?: of america)?)\s*\)?\s*$") + + +def _place_code(value, codes, abbreviations): + """The code (a key of `codes`) a state or province spelling names -- the + code, the full name, a dotted code ('N.J.'), a listed abbreviation + ('Tex.', 'N. Mex.') -- or None.""" + v = " ".join(str(value or "").lower().split()) + if not v: + return None + if v in codes: + return v + names = {n: c for c, n in codes.items()} + if v in names: + return names[v] + dotted = re.sub(r"\.\s+", ".", v) + if dotted in abbreviations: + return abbreviations[dotted] + bare = re.sub(r"[.\s]", "", v) + if "." in v and len(bare) == 2 and bare in codes: + return bare + return None + + +def _us_state_code(value): + """The _STATE_NAMES code a US state spelling names, 'dc' for the + District of Columbia's spellings, after an 'X, USA' / 'X, U.S.' wrapper is + removed; None otherwise.""" + v = " ".join(str(value or "").lower().split()) + inner = _US_WRAPPER.sub("", v) if _US_WRAPPER.sub("", v) else v + code = _place_code(inner, _STATE_NAMES, _STATE_ABBREVIATIONS) + if code: + return code + if " ".join(inner.replace(".", "").replace(",", " ").split()) in _DC_SPELLINGS: + return "dc" + return None + + +def _province_code(value): + return _place_code(value, _PROVINCES, _PROVINCE_ABBREVIATIONS) + + +# H2 (2026-09-22, G2-REL-G): the other shapes a record writes Canada in -- the country +# codes ('CAN', 'CA', 'Can.', in _CANADA_COUNTRY), a province wrapped in the country +# ('Ontario, Canada', 'ON, Canada', 'Ontario (Canada)', 'Ontario Canada') and the country +# carrying its province ('Canada (Ontario)', 'Canada - Ontario'). Each was read as a +# non-US FOREIGN record before: the Canadian paragraphs did not reach it, and the relief +# reader did not see such an entry as a later conviction in Canada (CRA s. 7.2(a)). +# The WRAPPER takes the country's name only, never the codes: 'Ontario, CA' is the +# California city, not the province ('CA' as the country value is Canada -- a state value +# 'CA' is read as California before this is reached). +_CANADA_WRAPPER = re.compile(r"(?:\s*[,(\-]\s*|\s+)(?:canada|can\.)\s*\)?\s*$") +_CANADA_WITH_PROVINCE = re.compile(r"^(?:canada|can\.)\s*(?:[,(\-]\s*|\s+)(?P[^()]+?)\s*\)?$") + + +def _canada_place(value): + """True when a state or country value names Canada: 'Canada' or a country + code (_CANADA_COUNTRY), a province with the country after it ('Ontario, + Canada'), or the country with its province after it ('Canada (Ontario)'). + A wrapper counts only around a province the tool reads, so 'Santa Ana, CA' + and 'Canada Bay' are not Canada.""" + v = " ".join(str(value or "").lower().split()) + if not v: + return False + if v in _CANADA_COUNTRY: + return True + inner = _CANADA_WRAPPER.sub("", v) + if inner != v and inner and _province_code(inner) is not None: + return True + m = _CANADA_WITH_PROVINCE.match(v) + return bool(m and _province_code(m.group("prov")) is not None) + + +def _state_display(code): + """A state code's name as the curated rows spell it ('District of Columbia').""" + return " ".join(w if w == "of" else w.capitalize() for w in _STATE_NAMES[code].split()) + +NON_US_LABEL = "non-US record: curated coverage is thin" +_CANADA_NOTE = ( + "CANADA GIVEN AS THE PLACE OF THE RECORD: this tool reads a record from " + "OUTSIDE Canada (IRPA s. 36(1)(b)/(c) and 36(2)(b)/(c)). A person convicted " + "in Canada falls under s. 36(1)(a) or 36(2)(a) instead -- " + "canlex_screen_record and canlex_screen_history screen a Canadian entry on " + "those paragraphs (country 'Canada' or the province), and " + "canlex_rehabilitation takes basis 'canada-conviction'. What follows reads " + "the record as foreign and does not answer a Canadian conviction.") + + +def _us_state_value(st_lc): + """True when a lower-cased state value names a US state, the District of + Columbia, a territory or a federal prosecution.""" + return (st_lc in _STATE_NAMES or st_lc in _STATE_NAMES.values() + or st_lc in _US_TERRITORIES or st_lc in _US_TERRITORIES.values() + or st_lc in _US_OTHER) + + +# A record that names no state or country but cites the New York Criminal Procedure +# Law (CPL 160.50 / 160.55 / 440.10) is read as a New York record (the review of +# batch 1, round 2: the CPL seeds on the New York row apply), and says so. +# (the review of batch 1, round 4: the bare section beside 'sealed' or 'vacated' -- 'SEALED +# 160.50', '160.50 SEALED' -- is the rap-sheet form; the section numbers are New York's) +_CPL_CITE = re.compile(r"\bcpl\b[^;|]{0,20}?\b(?:160\.50|160\.55|440\.10)\b|" + r"\b(?:160\.50|160\.55|440\.10)\b[^;|]{0,12}?\bcpl\b|" + r"\b(?:seal\w*|vacat\w*)\b[^;|]{0,20}?\b(?:160\.50|160\.55|440\.10)\b|" + r"\b(?:160\.50|160\.55|440\.10)\b[^;|]{0,20}?\b(?:seal\w*|vacat\w*)\b", re.I) +STATE_INFERRED_CPL = ("State inferred: New York -- no state or country was given, and the record's " + "words cite the New York Criminal Procedure Law ('{cite}'). Give the state if the " + "record is from elsewhere.") + + +# The 32 federal entities of Mexico (place keys, _place_key), for a record whose state +# field names one and whose country is not given (the review of batch 1, round 3). +_MEXICAN_STATES = frozenset({ + "aguascalientes", "baja california", "baja california sur", "campeche", "chiapas", + "chihuahua", "ciudad de mexico", "cdmx", "mexico city", "coahuila", "coahuila de zaragoza", + "colima", "durango", "estado de mexico", "state of mexico", "guanajuato", "guerrero", "hidalgo", + "jalisco", "michoacan", "michoacan de ocampo", "morelos", "nayarit", "nuevo leon", "oaxaca", + "puebla", "queretaro", "queretaro de arteaga", "quintana roo", "san luis potosi", "sinaloa", + "sonora", "tabasco", "tamaulipas", "tlaxcala", "veracruz", "veracruz de ignacio de la llave", + "yucatan", "zacatecas"}) +COUNTRY_INFERRED_MEXICO = ("Country inferred: Mexico -- no country was given, and '{state}' is a " + "Mexican state. Give the country if the record is from elsewhere.") + + +def _jurisdiction(state=None, country=None, disposition=None, data=None): + """Where a record comes from, for the screening tools (roadmap T2.9). + + The tool names say 'us' and the curated tables were compiled for US + records, but the tools take any foreign record. A country other than the + US, or a state value that is not a US state or territory ('England', + 'Jalisco', 'Punjab'), makes the record non-US: the US state rows are + skipped (never matched against a foreign region), the answer says so, + and it carries NON_US_LABEL. Canada (as the country, or a province as + the state) is not a foreign record at all, and says so. + + Returns {"kind": "us" | "non-us" | "canada" | "unspecified", "state": the + value to match US state rows with (None when rows must not apply), + "given_state", "country", "notes": [lines to print]}. disposition: the + record's words -- with no state or country, a CPL cite makes it a New York + record (STATE_INFERRED_CPL) on any dataset (round 5; `data` is kept for the + callers that pass it).""" + st = " ".join(str(state or "").split()) + co = " ".join(str(country or "").split()) + st_lc, co_lc = st.lower(), co.lower() + # disposition roadmap T1.4: a dotted abbreviation ('N.J.', 'Tex.'), an + # 'X, USA' wrapper or a District of Columbia spelling names that state's + # rows -- the state value is then the name the rows carry. A code or a + # full name is kept as given. + code = _us_state_code(st_lc) if st_lc else None + in_us = bool(st_lc) and (_us_state_value(st_lc) or code is not None) + if code and st_lc not in _STATE_NAMES and st_lc not in _STATE_NAMES.values(): + st_rows = _state_display(code) + else: + st_rows = st + # H2 (2026-09-22, G2-REL-G): a province, or a value naming Canada around one + # ('Ontario, Canada', 'ON, Canada'), is a Canadian state value + in_canada = bool(st_lc) and not in_us and (_province_code(st_lc) is not None + or _canada_place(st_lc)) + # the review of batch 1: a US territory or the District of Columbia given as + # the COUNTRY ('Puerto Rico', 'Guam') is a US record, not a foreign one + co_us_place = bool(co_lc) and co_lc not in _US_COUNTRY and ( + co_lc in _US_TERRITORIES.values() or co_lc in _US_OTHER + or _us_state_code(co_lc) == "dc") + if co_us_place and not st_lc: + st, st_lc = co, co_lc + in_us, st_rows = True, (_state_display("dc") if _us_state_code(co_lc) == "dc" else co) + # the review of batch 1, round 3: a Mexican state given as the state with no country + # ('Jalisco', the tool's own example) is a record from Mexico -- the Mexico place + # gates meet it + inferred_mexico = (not co_lc and bool(st_lc) and not in_us + and re.sub(r"^estado de ", "", _place_key(st)) in _MEXICAN_STATES) + if inferred_mexico: + co, co_lc = "Mexico", "mexico" + if co_lc: + # H2 (2026-09-22, G2-REL-G): 'CAN', 'CA', 'Can.' and 'Canada (Ontario)' are Canada + kind = ("canada" if _canada_place(co_lc) + else "us" if (co_lc in _US_COUNTRY or co_us_place) else "non-us") + elif st_lc: + kind = "us" if in_us else "canada" if in_canada else "non-us" + else: + kind = "unspecified" + notes = [] + cite = _CPL_CITE.search(disposition or "") if kind == "unspecified" else None + if cite: + # (the review of batch 1, round 5: a code rule on any dataset -- the CPL sections are + # New York's; on the approved data the New York row then answers and its held routed + # row prints the HELD note, instead of a conditional draft on both footings) + kind, in_us, st_rows = "us", True, "New York" + notes.append(STATE_INFERRED_CPL.format(cite=cite.group(0))) + if kind == "non-us": + where = co or st + if inferred_mexico: + notes.append(COUNTRY_INFERRED_MEXICO.format(state=st)) + notes.append(f"NOTE -- {NON_US_LABEL}. The disposition entries, offence " + "pairings and state rows CanLex curates were compiled for US " + f"records; for a record from {where} they are a starting " + "point only. Apply the governing framework to the foreign " + "law itself: the foreign disposition's own legal effect, and " + "the foreign statute's elements (Hill).") + if st and in_us: + notes.append(f"State '{st}' is ignored: the country given is {co}.") + elif st: + notes.append(f"'{st}' is not a US state or territory: the state rows " + "are US-only, so none is applied.") + elif kind == "us" and st and not in_us: + notes.append(f"'{st}' is not a US state or territory: no state row is " + "applied; the entry-level verdict applies.") + elif kind == "canada": + notes.append(_CANADA_NOTE) + return {"kind": kind, "state": st_rows if (kind == "us" and in_us) else None, + "given_state": st or None, "country": co or None, "notes": notes} + + +def _jurisdiction_summary(kinds): + """One CANLEX_RESULT value for several entries' jurisdictions.""" + named = sorted(set(kinds) - {"unspecified"}) + if not named: + return "unspecified" + return named[0] if len(named) == 1 else "mixed" + + +_DISP_STOP = frozenset("a an the of to in for on with and or by at from plea " + "program court order case charge charges record " + "disposition".split()) +_DISP_GENERIC = frozenset("state court program order case judgment conviction " + "plea sentence record".split()) +# Words too weak to carry a multi-word name alone through the lone-word +# fallback: 'pending' names a pending charge, a pending appeal and pending +# sentencing alike (as 6-character stems, the form the fallback compares); +# 'penal' is in 'penal order', 'penal code' and the Mexican 'no ejercicio de +# la accion penal' alike (disposition roadmap T1.9: 'penal order' reached the +# Mexican sobreseimiento entry through it, even with no country). A name whose +# only content words are weak matches as a phrase only: 'penal order' ('order' +# is a stop word) must not reach every record that says 'penal'. +# 'charge' (the stem of 'charged') is weak too (the review of batch 1): a record +# saying only 'CHARGED' reached the conviction entry through 'guilty as charged'. +# 'no' is weak too (the review of batch 1, round 2): a German record's 'NO OBJECTION +# LODGED' reached pending-charge's 'no disposition' ('disposition' is a stop word). +# 'filed' and 'releas' are weak too (the review of batch 1, round 4): 'CHARGE FILED' ('charge' +# is a stop word) had reached 'appeal filed', and a bare 'RELEASED' the held 'released +# without charge'. +# 'agreem' (agreement) is weak too (the review of batch 1, round 5): a bare 'PLEA AGREEMENT' +# ('plea' is a stop word) had reached the deferred-prosecution-agreement entry. +_DISP_WEAK = frozenset({"pendin", "penal", "charge", "no", "filed", "releas", "agreem"}) +# Acronym names that spell a common word of another language, never matched for a +# NON-US record (the review of batch 1, round 2): the German 'als' ('as') reached the +# ALS (administrative licence suspension) name of dui-administrative. +_FOREIGN_WORD_ACRONYMS = frozenset({"als"}) +# Cancellation words (stems): a record saying the conviction was quashed, +# annulled, reversed, overturned or spent is not reached by a name that +# merely shares 'conviction' or 'appeal' with it. +_DISP_CANCEL = frozenset({"quashe", "quash", "annull", "annul", "spent", + "revers", "overtu"}) +# A decided appeal (roadmap T2.7): with the cancellation words, 'acquitted on +# appeal' decides the appeal too, so a name about a pending appeal steps aside. +_DISP_APPEAL_DECIDED = frozenset({"acquit"}) +# Names whose only token is one of these stems match as a PHRASE only (track +# A step A9): 'charge refused' must not reach 'pardon refused' through the +# shared word -- a refused pardon leaves the conviction standing. +_DISP_PHRASE_ONLY = frozenset({"refuse"}) +# A US-framed relief entry read for a NON-US record (roadmap T2.8): the entry +# built for relief granted elsewhere answers instead, where the curated data +# has it (held content; the approved baseline has neither side of the map). +_FOREIGN_RELIEF = {"state-pardon": "foreign-pardon-other", + "us-presidential-pardon": "foreign-pardon-other", + "conditional-discharge": "discharge-no-conviction-recorded", + "expungement": "foreign-rehabilitation-law"} +# A _FOREIGN_RELIEF entry replaced only when the record's words carry these +# (disposition roadmap T1.9): a non-US record that says 'expunged' is read under +# the foreign rehabilitation-law entry, while 'sealed' or 'vacated' keep the +# expungement entry's reading (a vacatur is not a rehabilitation law). +_FOREIGN_RELIEF_WORDS = {"expungement": re.compile(r"\bexpun\w*")} +# Place-gated entries and names (disposition roadmap T1.8(3), T1.9; the curated +# fields are optional and HELD): an entry's 'countries' and its 'gated_names' +# ([{names, places}]) answer only a record from a listed place -- a US state (by +# its full name, for a US record's state) or a country, or the region a non-US +# record's state field names ('Scotland', 'New South Wales'). A record that +# names no place meets them by a full phrase only, and a gated name then yields +# a tie to an ungated one; a record from any other place, or from Canada, never +# meets them. The gate a match met breaks ties, like the state-row bonus. +_GATE_BONUS = {"admit": 0.01, "open": 0.0, "phrase": -0.01} +# The no-charge names (held on dismissed-nolle): a record that also reports a +# conviction in another segment, or whose no-charge phrase names its own object +# ('NO FURTHER ACTION ON ASSAULT', 'NO CHARGES FILED ON CO-DEFENDANT', 'NO FURTHER ACTION +# REQUIRED BY PROBATION'), is not answered by them -- the record's other words decide, +# and a no-charge outcome on another count raises split-counts (the review of batch 1, +# round 3). Folded as _disp_fold folds them. +_NO_CHARGE_NAMES = frozenset({"no further action", "nfa", "no proceedings", "no charges filed", + "no charge filed", "charges not filed", "no charges laid", + "released without charge", "declined to file", "da declined to file", + "non indictment", "non indictment disposition", "no file", "no papered", + # round 5: the declination named by the office (held aliases) + "declined by da", "da declined"}) +_NO_CHARGE_OBJECT = re.compile( + r"^\s*(?:(?:on|of|against|as\s+to|re|regarding|in\s+respect\s+of|for)\s+(?!(?:the\s+|this\s+|that\s+)?" + r"(?:defendant|accused|suspect|subject|him|her|them|this|that|now|lack|insufficient|want|" + r"(?:(?:arrest|case|matter|investigation|referral|report|complaint|allegations?|incident|" + r"offen[cs]e|charges?)\b(?!\s*#?\s*\d)))\b)[a-z0-9]|" + r"(?:required|taken)\s+(?:by|on|against)\b)") +# a procedural object ('NO FURTHER ACTION REQUIRED BY PROBATION', 'NO FURTHER ACTION TAKEN +# ON THE COMPLAINT'): never a charge's outcome, whatever else the record says +_NO_CHARGE_OBJECT_PROCEDURAL = re.compile(r"^\s*(?:required|taken)\s+(?:by|on|against)\b") +# 'declined to file' with a class of charge as its object ('DA DECLINED TO FILE FELONY') +_NO_CHARGE_FILE_OBJECT = re.compile( + r"^\s+(?:the\s+|a\s+|an\s+)?(?:felon(?:y|ies)|misdemeanou?rs?|enhancements?|counts?|priors?|" + r"habitual|aggravat\w*|additional)\b") +# The deferred-sentence names meet a record only as adjacent words -- never across a +# field break ('DISPOSITION: DEFERRED; SENTENCE: 2 YRS PROBATION' is a deferral with a +# sentence field); a label colon ('SENTENCE: DEFERRED') still joins them (round 3). +_SEGMENT_BOUND_NAMES = frozenset({"deferred sentence", "sentence deferred", "deferral of sentence", + "deferred sentencing", "sentencing deferred"}) +# Entries built on one US mechanism that never answer a NON-US record (the +# critic, 2026-09-16): a 'conviction set aside' from the United Kingdom is not a +# California PC 1203.4 dismissal. +_US_ONLY_ENTRIES = frozenset({"set-aside-1203-4"}) +# Relief can remove a conviction, never create one (disposition roadmap T1.7): +# the verdicts a non-relief entry's row-aware reading must have for it to rank +# ahead of a relief entry the same words reach. +_RELIEF_YIELDS_TO = frozenset({"no", "likely-no"}) +# The acquittal words the relief ordering reads like the acquittal entry, on any dataset +# (the review of batch 1, round 4): a sealed acquittal is an acquittal wherever the +# words stand ('SEALED; NOT GUILTY', 'ACQUITTED; SEALED; NOT GUILTY VERDICT'). +_ACQUITTAL_WORDS = re.compile( + r"\b(?:acquitt\w*|not guilty(?: verdict)?|verdict of not guilty|judg(?:e)?ment of acquittal)\b") +# The relief entries (the held curated 'role': 'relief'), named in code so a +# composed release without that field ranks them the same way (the review of batch +# 1, round 2): a state row adds to their score only to break a tie. +_RELIEF_ENTRIES = frozenset({"expungement", "set-aside-1203-4", "state-pardon", + "us-presidential-pardon", "commutation", + "certificate-of-rehabilitation", "foreign-pardon-other"}) + + +def _foreign_relief(found, entries, disposition=""): + """The matched entries with each US-framed relief entry replaced by its + non-US counterpart (_FOREIGN_RELIEF) where the data carries it -- for an + entry _FOREIGN_RELIEF_WORDS lists, only when the record's words say so.""" + by_id = {e["id"]: e for e in entries} + folded = _disp_fold(disposition or "") + out = [] + for e in found: + target = _FOREIGN_RELIEF.get(e["id"]) + words = _FOREIGN_RELIEF_WORDS.get(e["id"]) + if target and words is not None and not words.search(folded): + target = None + e = by_id.get(target, e) + if all(x["id"] != e["id"] for x in out): + out.append(e) + return out + + +def _place_key(value): + """A place spelling folded for the place gates: lower-cased, accent-folded, + punctuation dropped ('Korea, Republic of' -> 'korea republic of'); '&' reads 'and' + and a run of single letters joins ('U.K.' -> 'uk', 'England & Wales' -> 'england and + wales': the review of batch 1, round 3).""" + from canlex.screening import _accent_fold + k = " ".join(re.sub(r"[^a-z0-9 ]+", " ", _accent_fold(str(value or "")).replace("&", " and ")).split()) + return re.sub(r"\b(?:[a-z] )+[a-z]\b", lambda m: m.group(0).replace(" ", ""), k) + + +def _record_places(j): + """The place keys a non-US record names (its country and its state field), + for the place gates; () for any other record (a US record's gate reads its + state).""" + if (j or {}).get("kind") != "non-us": + return () + out = [] + for raw in (j.get("country"), j.get("given_state")): + for k in _place_spellings(raw): + if k and k not in out: + out.append(k) + return tuple(out) + + +def _place_spellings(raw): + """The place keys one place field yields (the review of batch 1, round 4): the whole + value, then each part split on ',', '/' and brackets ('Scotland, UK', 'England/Wales', + 'Germany (Bavaria)'), each without a leading 'the' ('The United Kingdom') and with an + abbreviated 'S.' read as 'south' ('S. Korea').""" + raw = str(raw or "") + if not raw.strip(): + return () + parts = [raw] + [p for p in re.split(r"\s*[,/()\[\]]\s*", raw) if p.strip()] + # (round 5: the head of an 'X of Y' value -- 'United Kingdom of Great Britain and Northern + # Ireland' -- when it has two or more words) + head = re.split(r"\s+of\s+", raw, maxsplit=1)[0] + if head != raw and len(head.split()) >= 2: + parts.append(head) + whole = _place_key(raw) + out = [] + for p in parts: + k = _place_key(p) + k = re.sub(r"^the\s+", "", k) + k = re.sub(r"^(?:s|so)\s+(korea|africa|sudan)$", r"south \1", k) + k = re.sub(r"^n\s+(korea|macedonia)$", r"north \1", k) + k = re.sub(r"^n\s+ireland$", "northern ireland", k) + if k == "korea" and re.search(r"\b(?:north|dprk)\b", whole): + continue # round 5: 'Korea, North' never meets the South Korea gates + if k and k not in out: + out.append(k) + return tuple(out) + + +def _place_gate(listed, state, jurisdiction, places): + """How a place gate ('countries' or a 'gated_names' item's 'places') meets a + record: 'admit' (the record is from a listed place), 'phrase' (it names no + place: a full phrase only), or None (another place, or Canada).""" + if jurisdiction == "canada": + return None + keys = {_place_key(p) for p in listed or ()} + if state: + code = _us_state_code(str(state).strip().lower()) + name = _STATE_NAMES[code] if code else str(state) + return "admit" if (_place_key(name) in keys or _place_key(state) in keys) else None + if places: + # round 3: 'Republic of South Korea' meets a gate listing 'South Korea' + return "admit" if any(p in keys or re.sub(r"^(?:the )?republic of ", "", p) in keys + for p in places) else None + if jurisdiction == "us": + # the review of batch 1, round 2: a US record with no state named meets a + # US state's gate by its phrase, never another country's + return "phrase" if any(_us_state_code(k) for k in keys) else None + return "phrase" + + +def _combine_gates(*gates): + """The gate a name meets under its entry's gate and its own.""" + if None in gates: + return None + for g in ("phrase", "admit"): + if g in gates: + return g + return "open" + + +_GATED_OBJECT = re.compile( + r"\b(?:allegations?|violations?|factors?|elements?|priors?|enhancements?|aggravat\w*|" + r"counts?\s*#?\s*\d+|charges?(?:\s*#?\s*\d+)?)\s+(?:(?:was|were|is|are)\s+)?$") + + +_GATED_OBJECT_ADMITTED = re.compile( + r"\b(?:allegations?|violations?|factors?|elements?|priors?|enhancements?|aggravat\w*|" + r"specifications?)\s+(?:(?:was|were|is|are)\s+)?$") + + +def _gated_object_before(disposition, name, admitted=False): + """True when a place-gated name the record meets with no place named follows an + object noun in its segment ('ALLEGATION NOT PROVEN', 'ELEMENTS NOT PROVEN', + 'CHARGE NOT PROVEN'): the words describe that object, not a verdict of the + gated place (the review of batch 1, round 2). admitted: the record is from a + gated place -- then a charge or count is the verdict's object and only the other + object nouns count ('ALLEGATION NOT PROVEN' from Scotland: round 3).""" + from canlex.screening import _accent_fold, _segments + t = _accent_fold(disposition or "") + words = name.split() + obj = _GATED_OBJECT_ADMITTED if admitted else _GATED_OBJECT + for m in re.finditer(r"\b" + r"[^a-z0-9]+".join(re.escape(w) for w in words) + r"\b", t): + a = next((x for x, y in _segments(t) if x <= m.start() < y), 0) + if obj.search(t[a:m.start()]): + return True + return False + + +def _is_relief_entry(e): + """True for an entry that grants relief after a judgment: the curated role + 'relief' (held) or, on a dataset without that field, the ids _RELIEF_ENTRIES + lists (so a composed release ranks the same way).""" + return e.get("role") == "relief" or e.get("id") in _RELIEF_ENTRIES + + +# The entries an acquittal the record's words also reach ranks ahead of when the record +# carries no conviction words (the review of batch 1, round 4): a jury acquittal with +# the State's appeal noted is not a conviction under appeal; a court-martial acquittal +# is an acquittal; a juvenile 'adjudicated not delinquent' is the juvenile court's +# acquittal. dismissed-nolle yields with or without conviction words (round 2). +_ACQUITTAL_AHEAD_OF = frozenset({"conviction-under-appeal", "court-martial", "juvenile-adjudication"}) + + +def _acquittal_first(scored, disposition=None): + """The scored matches with the acquittal entry ahead of dismissed-nolle when the + record's words reach both (the review of batch 1, round 2: 'NOT GUILTY - CASE + DISMISSED' is an acquittal, carrying its readings; dismissed-nolle stays 'Also + possible'), and -- with no conviction words in the record (round 4) -- ahead of + conviction-under-appeal, court-martial and juvenile-adjudication ('NOT GUILTY BY + JURY; STATE APPEAL PENDING', 'GENERAL COURT-MARTIAL: ACQUITTED OF ALL CHARGES', + 'ADJUDICATED NOT DELINQUENT'). A place-gated acquittal name the record's place admits + ('NOT PROVEN' from Scotland) counts like a plain one; one met only as a phrase, with + no place named, does not. A not-guilty plea is already removed + (screening.record_order).""" + from canlex.screening import has_conviction_words + ids = [t[2]["id"] for t in scored] + if "acquittal" not in ids: + return scored + j = ids.index("acquittal") + if scored[j][6] == "phrase" or scored[j][0] < 1.0: + # (round 5: an acquittal reached only by shared tokens -- 'COULD NOT BE ... ADJUDICATED + # DELINQUENT' -- never outranks an entry the words name outright) + return scored + convicted = has_conviction_words(disposition) if disposition else False + ahead = {"dismissed-nolle"} | (set() if convicted else _ACQUITTAL_AHEAD_OF) + if "set-aside-1203-4" in ids and disposition: + # round 5: a set-aside with no rehabilitative ground, statute cite or appellate word, + # then an acquittal in a later segment ('CONVICTION SET ASIDE; NOT GUILTY') is the + # acquittal; the set-aside stays 'Also possible' + from canlex.screening import _CUE, _NEGATED, record_order + words = _NEGATED.sub(" ", record_order(disposition)[0]) + seg_sa, seg_acq = _name_segment(disposition, "set aside"), _name_segment(disposition, scored[j][4]) + if (seg_sa is not None and seg_acq is not None and seg_acq > seg_sa + and not _CUE["set-aside-rehabilitative"].search(words) + and not _CUE["set-aside-appellate"].search(words)): + ahead = ahead | {"set-aside-1203-4"} + first = min((ids.index(x) for x in ahead if x in ids), default=None) + if first is not None and first < j: + acq = scored[j] + scored = scored[:first] + [acq] + [t for k, t in enumerate(scored[first:], first) if k != j] + return scored + + +def _deferred_sentence_first(scored, state): + """The scored matches with the deferred-sentence entry ahead of deferred-adjudication + when the record names a deferred sentence (a segment-bound name) and only the + deferred-sentence entry has a row for the record's state -- 'DISMISSED UPON COMPLETION + OF DEFERRED SENTENCE' WA is RCW 9.95.240, not a deferred adjudication (the review of + batch 1, round 3).""" + ids = [t[2]["id"] for t in scored] + if "deferred-sentence" not in ids or "deferred-adjudication" not in ids or not state: + return scored + i, j = ids.index("deferred-adjudication"), ids.index("deferred-sentence") + ds, da = scored[j], scored[i] + if (i < j and ds[4] in _SEGMENT_BOUND_NAMES and _state_row(ds[2], state) + and not _state_row(da[2], state)): + scored = scored[:i] + [ds] + [t for k, t in enumerate(scored[i:], i) if k != j] + return scored + + +def _name_segment(disposition, name): + """The index of the first segment of the record's words (screening.record_order) + that holds a matched name's words, or None.""" + from canlex.screening import _segments, record_order + words = _disp_fold(name or "").split() + if not words: + return None + t = record_order(disposition)[0] + m = re.search(r"\b" + r"[^a-z0-9]+".join(re.escape(w) for w in words) + r"\b", t) + if not m: + return None + return next((i for i, (a, b) in enumerate(_segments(t)) if a <= m.start() < b), None) + + +def _name_pos(disposition, name): + """The offset in the record's words (screening.record_order) where a matched name's + words first stand, or None.""" + from canlex.screening import record_order + words = _disp_fold(name or "").split() + if not words: + return None + m = re.search(r"\b" + r"[^a-z0-9]+".join(re.escape(w) for w in words) + r"\b", + record_order(disposition)[0]) + return m.start() if m else None + + +def _no_charge_yields(disposition, name): + """True when a no-charge name (_NO_CHARGE_NAMES) must not answer the record: + conviction words stand in another segment ('CHARGE 1: CONVICTED; CHARGE 2: NO FURTHER + ACTION'); its phrase's object is procedural, another person's or a class of charge + ('NO FURTHER ACTION REQUIRED BY PROBATION', 'NO CHARGES FILED ON CO-DEFENDANT; + DEFENDANT CONVICTED', 'DA DECLINED TO FILE FELONY'); or its object is a charge or an + offence and the record's other words report a disposition ('NO FURTHER ACTION ON + THEFT; CHARGED WITH ASSAULT; CONVICTED'). A no-charge phrase whose object is the charge + itself, with nothing else reported ('NO FURTHER ACTION ON CHARGE 2', 'NO FURTHER + ACTION ON ASSAULT'), answers (the review of batch 1, round 3).""" + from canlex.screening import (_CONVICTION_CUES, _CUE, _NEGATED, _NEGATED_CONVICTION, + _NON_CONVICTION_CUES, _fold, _segments, record_order) + t = record_order(disposition)[0] + words = name.split() + hits = list(re.finditer(r"\b" + r"[^a-z0-9]+".join(re.escape(w) for w in words) + r"\b", t)) + segs = _segments(t) + outside, charge_object = t, False + for m in hits: + a, b = next(((x, y) for x, y in segs if x <= m.start() < y), (m.start(), m.end())) + outside = outside[:a] + " " * (b - a) + outside[b:] + rest = t[m.end():b] + if name.endswith("file") and _NO_CHARGE_FILE_OBJECT.match(rest): + return True # a class of charge declined: another may have been filed + if _NO_CHARGE_OBJECT.match(rest): + if (_NO_CHARGE_OBJECT_PROCEDURAL.match(rest) or _CUE["other-person"].search(rest) + or _CUE["other-person-family"].search(rest)): + return True # never a charge's outcome + charge_object = True + # (no phrase hit: the name reached the words by shared tokens -- any conviction words) + # a conviction on another matter or another person's is no conviction of this record's; + # nor one noted as earlier in time -- 'PRIOR: CONVICTED 2010', 'PREVIOUSLY CONVICTED OF + # THEFT' -- with the segment after a 'PRIOR' label (the review of batch 1, round 4) + from canlex.screening import _PRIOR_LABEL + earlier = set() + for k, (a, b) in enumerate(segs): + seg = re.sub(r"[^a-z0-9 ]+", " ", t[a:b]) + if _CUE["earlier-in-time"].search(seg) or _PRIOR_LABEL.match(seg): + earlier.add(k) + if _PRIOR_LABEL.match(seg) or re.search(r"\b(?:prior|previous)\s*$", seg.strip()): + earlier.add(k + 1) + for k, (a, b) in enumerate(segs): + if (k in earlier or _CUE["other-matter-explicit"].search(t[a:b]) + or _CUE["other-person"].search(t[a:b])): + outside = outside[:a] + " " * (b - a) + outside[b:] + if _CONVICTION_CUES.search(_NEGATED_CONVICTION.sub(" ", _NEGATED.sub(" ", _fold(outside)))): + return True + # the phrase names a charge or an offence: it answers unless other words report a + # disposition for the record to read instead + return charge_object and bool(_NON_CONVICTION_CUES.search(_fold(outside)) + or _CUE["outcome-word"].search(outside)) + + +def _segment_bound_ok(disposition, name): + """True when a segment-bound name's words stand together in the record's words -- + adjacent, or joined by a label colon ('SENTENCE: DEFERRED') -- never across ';', ',', + '|' or a spaced dash (_SEGMENT_BOUND_NAMES; round 3).""" + from canlex.screening import record_order + words = _disp_fold(name or "").split() + return bool(re.search(r"\b" + r"(?:\s+|\s*:\s*)".join(re.escape(w) for w in words) + r"\b", + record_order(disposition)[0])) + + +def _relief_yields(first, second, disposition, state): + """The two best matches of _match_dispositions, reordered between a relief + entry and a non-relief one (disposition roadmap T1.7; the review of batch 1, + round 2). Relief can remove a conviction, never create one: the non-relief + entry ranks first if its ROW-AWARE verdict is no or likely-no, the words that + reach it are not part of the relief phrase, they stand in an EARLIER segment + than the relief's words, and the record carries no conviction words -- 'ACD - + SEALED' is an ACD, 'ACQUITTED; LATER EXPUNGED' an acquittal. A dismissal after the + relief's words is the relief's effect ('SET ASIDE AND DISMISSED', 'RECORD SEALED; + CASE DISMISSED'), and so is a dismissal beside conviction words or under a PC + 1203.4 set-aside ('DISMISSED PER PC 1203.4'): the relief entry ranks first. A + dismissal or acquittal BEFORE the relief's words, with no conviction words, is the + charge's own outcome, then sealed or expunged ('DISMISSED AND SEALED', 'NOT GUILTY + AND SEALED': the review of batch 1, round 3) -- it ranks first, whatever the + segments. The other entry stays second ('Also possible'); a + promoted entry carries the relief entry's policy_caveats. A relief entry is + the curated role 'relief' (held) or an id in _RELIEF_ENTRIES, so a composed + release ranks the same way. Each item is (score, bonus, entry, fallback_only, + matched name, state bonus, gate).""" + from canlex.screening import (_NEGATED, has_conviction_words, record_order, + relief_dismissal_joined) + r1, r2 = _is_relief_entry(first[2]), _is_relief_entry(second[2]) + if r1 == r2: + return [first, second] + relief, other = (first, second) if r1 else (second, first) + row = _state_row(other[2], state) + verdict = (row or {}).get("is_conviction") or other[2].get("is_conviction") + if verdict not in _RELIEF_YIELDS_TO: + return [first, second] + seg_o, seg_r = _name_segment(disposition, other[4]), _name_segment(disposition, relief[4]) + if seg_o is None or seg_r is None: + return [first, second] + convicted = has_conviction_words(disposition) + inside = f" {other[4] or ''} " in f" {relief[4] or ''} " + # the review of batch 1, round 4: acquittal words anywhere in the record read like the + # acquittal entry, whatever the order ('SEALED; NOT GUILTY' on a dataset whose acquittal + # names still sit on dismissed-nolle); a vacatur then a dismissal is the charge's own + # outcome ('VACATED AND DISMISSED' is no sealed conviction), and so is a dismissal the + # words join the relief to ('EXPUNGED AFTER DISMISSAL') + acquitted = bool(_ACQUITTAL_WORDS.search(_NEGATED.sub(" ", record_order(disposition)[0]))) + vacatur = bool(re.search(r"vacat", relief[4] or "")) + joined = relief_dismissal_joined(disposition) + if vacatur and convicted: + # 'CONVICTION VACATED AND INDICTMENT DISMISSED': the vacated conviction's own word is + # no conviction word beside the dismissal + convicted = has_conviction_words(re.sub( + r"(?i)\b(?:convictions?\s+(?:was\s+|were\s+)?vacated|vacated\s+(?:the\s+)?convictions?|" + r"vacat\w*\s+(?:the\s+)?(?:judg(?:e)?ment|plea|verdict))\b", " ", disposition or "")) + + def promoted(): + e_o, e_r = other[2], relief[2] + have = {c.get("id") for c in e_o.get("policy_caveats") or [] if isinstance(c, dict)} + carried = [c for c in e_r.get("policy_caveats") or [] + if isinstance(c, dict) and c.get("id") not in have] + if carried: + e_o = dict(e_o, policy_caveats=list(e_o.get("policy_caveats") or []) + carried) + return [(other[0], other[1], e_o) + tuple(other[3:]), relief] + if seg_o < seg_r and not convicted and not inside: + return promoted() + pos_o, pos_r = _name_pos(disposition, other[4]), _name_pos(disposition, relief[4]) + first_here = (not convicted and not inside and pos_o is not None and pos_r is not None + and pos_o < pos_r and relief[2].get("id") != "set-aside-1203-4") + if other[2].get("id") in ("dismissed-nolle", "acquittal") and acquitted and not convicted: + return promoted() + if other[2].get("id") == "dismissed-nolle": + # the dismissal first, then the relief: the charge's outcome (round 3); a dismissal + # after a vacatur, or joined to the relief as the outcome it followed (round 4) + if first_here or joined or (vacatur and not convicted): + return promoted() + return [relief, other] + if other[2].get("id") == "acquittal" and first_here: + return promoted() + return [first, second] + + +# The synthetic entry for a plain conviction, used when the curated dataset +# has no 'conviction' entry: the record's own words ('convicted', 'guilty', +# 'sentenced') ARE the disposition, and treating them as unknown -- or as +# the 'not guilty' entry through a shared token -- was the audit's first +# wrong answer. +_CONVICTION_ENTRY = { + "id": "conviction", + "names": ["conviction", "convicted", "guilty", "found guilty", + "pled guilty", "pleaded guilty", "plea of guilty", "guilty plea", + "judgment of conviction", "adjudicated guilty", "sentenced", + "jury verdict of guilty", "fine", "fined", "jail", "prison", + "probation (as a sentence)"], + "is_conviction": "yes", "status": "settled", + "bottom_line": "A judgment of conviction that stands is a conviction for " + "IRPA s. 36(1)(b)/(2)(b): proceed to equivalency.", + "analysis": "The record says the person was convicted -- a plea of guilty " + "or a finding of guilt followed by a judgment (a sentence of " + "probation, a fine, jail or prison). Nothing in the wording " + "names a deferral, withholding, dismissal, expungement, pardon " + "or diversion, so the conviction framework's threshold " + "question is answered: there is a conviction, and Burgon / " + "Saini recognition of a foreign non-conviction regime does " + "not arise. If the record also shows later relief (expunged, " + "set aside, vacated, pardoned), screen that disposition " + "instead.", + "state_variations": [], "authorities": [], "guidance": [], + "interpretation": "", +} + + +@functools.lru_cache(maxsize=8192) +def _disp_fold(text): + """Lower-cased, accent-folded (disposition roadmap T1.2: a curated name + 'no ejercicio de la acción penal' meets the record's folded words), + punctuation-spaced. Memoised (audit follow-up 2026-09-21, optimization + 1): it is pure, and _match_dispositions folds every curated name -- about + 1,350 calls -- on each call, so each name is folded once per process.""" + from canlex.screening import _accent_fold + return re.sub(r"\s+", " ", + re.sub(r"[^a-z0-9&' ]+", " ", _accent_fold(text))).strip() + + +def _disp_key(tok): + """A crude stem: 'expunged' / 'expungement' / 'expunge' -> 'expung'; + 'convicted' / 'conviction' -> 'convic'; short tokens stay whole.""" + return tok[:6] if len(tok) > 6 else tok + + +def _disp_tokens(text): + return {_disp_key(t) for t in _disp_fold(text).split() if t not in _DISP_STOP} + + +def _entry_names(e): + return list(e.get("names", [])) + list(e.get("aliases", [])) + + +class _Matches(list): + """_match_dispositions' result: the entries, best first, and for each entry + reached only by a place-gated name with no place named, the places that + gate lists (phrase_places: {entry id: [places]}).""" + phrase_places = {} + + +def _match_dispositions(data, disposition, state, jurisdiction=None, places=()): + """The curated entries a disposition string names, best first (at most + two). A name matches as a PHRASE (longer names beat shorter ones, so + 'not guilty' beats 'guilty' and 'adjudication withheld' beats + 'probation'), or by content-token overlap when the overlap covers the + name (a single generic token never carries a multi-word name). The + record's own conviction words -- 'convicted', 'pled guilty', + 'sentenced' -- resolve to the conviction entry unless the string also + names a non-conviction disposition, which then decides. + + Matching runs on screening.disposition_match_text: shorthand expanded + ('CONV', 'DEF ADJ'), negated non-conviction words dropped ('conviction + not set aside'), and 'record suspension' answered by a note rather than + an entry. Three guards (roadmap T1.5 and the critic's composed-release + regression): a name that is the bare word 'pending' steps aside when the + record also carries a conviction cue or the word 'appeal' ('found + guilty, sentence stayed pending appeal' is not a pending charge); + 'pending' alone never carries a multi-word name through the lone-word + fallback (on the approved baseline it reached 'appeal pending' -- + 'Treat it as a CONVICTION'); and when the record says the conviction was + quashed, annulled, reversed or spent, a name reached only by shared + tokens must carry that word too. + + Relief entries (roadmap T2.8, held): a name that IS a cancelled-conviction + phrase ('no conviction recorded', 'spent conviction') matches as a phrase + on the record's words before that phrase is dropped; a name whose only + word is 'refused' matches as a phrase only; and for a non-US record + (jurisdiction 'non-us') a US-framed relief entry yields to its non-US + counterpart (_FOREIGN_RELIEF), and an entry built on one US mechanism + (_US_ONLY_ENTRIES) is not matched at all. + + Record wording (disposition roadmap T1.6, T1.7, the critic's items): the + words are read after screening.record_order's routing rules; a relief + entry never outranks a non-conviction entry the record's other words + reach (_relief_yields); and the state-row bonus only breaks ties. + + Place gates (disposition roadmap T1.8(3), T1.9): an entry's 'countries' and + its 'gated_names' meet only a record from a listed place (_place_gate); + places: the non-US record's place keys (_record_places). The result's + phrase_places names, for an entry a record naming no place reached only by a + place-gated name, the places the gate lists (the review of batch 1, R10: + _resolve_step1 then leaves step 1 open on the place).""" + from canlex.screening import (_CANCELLED_CONVICTION, _cue_text, disposition_cues, + disposition_match_text, has_conviction_words, set_aside_appellate) + q = _disp_fold(disposition_match_text(disposition)) + raw_pad = f" {_disp_fold(_cue_text(disposition))} " + q_pad = f" {q} " + q_tokens = {_disp_key(t) for t in q.split() if t not in _DISP_STOP} + cues = disposition_cues(disposition) + entries = list(data.get("dispositions", [])) + if cues == "non-conviction": + # the review of batch 1, round 4: a no-charge name that yields to conviction words in + # another segment ('DA DECLINED TO FILE FELONY; MISDEMEANOR FILED; CONVICTED') is set + # aside for the cues too, so the conviction entry can answer + yielded = sorted({n for e in entries for name in _entry_names(e) + if (n := _disp_fold(name)) in _NO_CHARGE_NAMES + and f" {n} " in f" {q} " and _no_charge_yields(disposition, n)}) + if yielded: + cues = disposition_cues(disposition, without=yielded) + # (round 5: a relief application pending -- 'PARDON APPLICATION PENDING' -- is no pending + # charge either) + pending_yields = (has_conviction_words(disposition) or " appeal" in q_pad + or bool(re.search(r"\b(?:pardon|clemency|expun\w*|seal\w*|set[- ]aside|vacat\w*|" + r"commutation)\w*(?:\s+(?:application|petition|motion|request))?\s+" + r"(?:is\s+|remains\s+)?pending\b", q_pad))) + cancelled = ({_disp_key(t) for t in _disp_fold(disposition).split()} & _DISP_CANCEL + if cues == "non-conviction" else set()) + # the appeal is DECIDED when the record says quashed/reversed/overturned -- + # or acquitted -- on appeal: a name about an appeal then counts only if it + # carries that word too (roadmap T2.7: 'reversed on appeal' reaches the + # held reversal entry, never 'appeal pending') + decided = ({_disp_key(t) for t in _disp_fold(disposition).split()} + & (_DISP_CANCEL | _DISP_APPEAL_DECIDED) + if cues == "non-conviction" else set()) + state_lc = (state or "").strip().lower() + state_code = _us_state_code(state_lc) if state_lc else None + state_name = _STATE_NAMES[state_code] if state_code else _STATE_NAMES.get(state_lc, state_lc) + entries = list(data.get("dispositions", [])) + has_conviction = any(e["id"] == "conviction" for e in entries) + if not has_conviction: + entries.append(_CONVICTION_ENTRY) + scored = [] + phrase_where = {} + for e in entries: + if e["id"] == "conviction" and cues == "non-conviction": + continue + if jurisdiction == "non-us" and e["id"] in _US_ONLY_ENTRIES: + continue + if e["id"] == "set-aside-1203-4" and set_aside_appellate(disposition): + continue # round 5: a set-aside on appeal is never the rehabilitative row's + entry_gate = (_place_gate(e["countries"], state, jurisdiction, places) + if e.get("countries") else "open") + if entry_gate is None: + continue + name_gates, name_places = {}, {} + for g in e.get("gated_names") or (): + if isinstance(g, dict): + gate = _place_gate(g.get("places"), state, jurisdiction, places) + for nm in g.get("names") or (): + name_gates[nm] = gate + name_places[nm] = list(g.get("places") or ()) + best, fallback_only, best_name, best_gate, best_where = 0.0, True, None, "open", [] + best_open = (0.0, None) + + def take(score, n, outright=True, gate="open", where=()): + nonlocal best, fallback_only, best_name, best_gate, best_where, best_open + if score > best: + best, best_name, best_gate, best_where = score, n, gate, list(where) + if gate != "phrase" and outright and score > best_open[0]: + best_open = (score, n) + if outright: + fallback_only = False + for name in _entry_names(e) + list(name_gates): + gate = _combine_gates(entry_gate, name_gates.get(name, "open")) + where = ((list(e.get("countries") or ()) if entry_gate == "phrase" else []) + + (name_places.get(name, []) if name_gates.get(name) == "phrase" else [])) + n = _disp_fold(name) + if gate is None or not n or (n == "pending" and pending_yields): + continue + # the review of batch 1, round 3: a no-charge name beside conviction words + # or naming its own object, and a deferred-sentence name across a field break + if n in _NO_CHARGE_NAMES and _no_charge_yields(disposition, n): + continue + if n in _SEGMENT_BOUND_NAMES and not _segment_bound_ok(disposition, n): + continue + if jurisdiction == "non-us" and n in _FOREIGN_WORD_ACRONYMS and name.isupper(): + continue + if _CANCELLED_CONVICTION.search(n): + if f" {n} " in raw_pad: + take(1.0 + len(n) / 100.0, n, gate=gate, where=where) + continue + if decided and "appeal" in _disp_tokens(name) \ + and not (_disp_tokens(name) & decided): + continue # quashed/reversed on appeal: the appeal is decided + if f" {n} " in q_pad: + if gate == "phrase" and _gated_object_before(disposition, n): + continue # 'PROBATION VIOLATION ALLEGATION NOT PROVEN': not a verdict + if gate == "admit" and _gated_object_before(disposition, n, admitted=True): + continue # 'ALLEGATION NOT PROVEN' from Scotland (round 3) + take(1.0 + len(n) / 100.0, n, gate=gate, where=where) + continue + if gate == "phrase": + continue # the record names no place: a gated name meets it as a phrase only + n_tokens = _disp_tokens(name) + if not n_tokens or n_tokens <= (_DISP_PHRASE_ONLY | _DISP_WEAK): + continue + inter = q_tokens & n_tokens + if not inter or (cancelled and not (n_tokens & cancelled)): + continue + cov = len(inter) / len(n_tokens) + if len(n_tokens) == 1 or cov == 1.0 or len(inter) >= 2: + if cov >= 0.6: + take(0.5 + 0.4 * cov, n, gate=gate) + elif (len(q_tokens) == 1 and len(inter) == 1 + and next(iter(inter)) not in _DISP_GENERIC + and next(iter(inter)) not in _DISP_WEAK): + # a lone distinctive word ('pardoned') reaching a multi-word + # name ('state pardon'): a fallback, used only when nothing + # matched outright + take(0.85, n, outright=False, gate=gate) + if best_gate == "phrase" and best_open[0] >= 1.0: + # the record's own ungated words reach the same entry: the place does + # not change the entry (the review of batch 1, round 2) + best, best_name, best_gate, best_where = best_open[0], best_open[1], "open", [] + state_bonus = 0.02 if best and state_name and any( + state_name == (v.get("state", "").lower()) + or v.get("state", "").lower() in (state_lc, state_name) + for v in e.get("state_variations", [])) else 0.0 + bonus = state_bonus + (_GATE_BONUS[best_gate] if best else 0.0) + if best + bonus >= 0.8: + scored.append((best, bonus, e, fallback_only, best_name, state_bonus, best_gate)) + if best_gate == "phrase" and best_where: + phrase_where[e["id"]] = best_where + if any(t[0] >= 1.0 for t in scored): + scored = [t for t in scored if not t[3]] + # the state-row bonus adds to a non-relief entry's score, as before batch 1: a + # programme reached through its state row outranks a generic 'dismissed' (the + # review of batch 1, round 2: Hawaii DAG, Montana deferred imposition); for a + # relief entry it only breaks a tie (disposition roadmap T1.7: 'ACQUITTED; LATER + # EXPUNGED' NY). An entry reached only by a place-gated name while the record + # names no place ranks after a conviction entry the record's OTHER words reach by + # a phrase -- words outside the gated phrase (round 2: 'CONVICTED; SENTENCE ... NOT + # PROVEN'); another non-conviction outcome leaves the place question standing + # ('WITHHELD JUDGMENT, CASE DISMISSED'). + demoted = {t[2]["id"] for t in scored if t[6] == "phrase" and any( + u[6] != "phrase" and u[0] >= 1.0 and f" {u[4]} " not in f" {t[4]} " + and u[2].get("is_conviction") in ("yes", "likely-yes") for u in scored)} + scored.sort(key=lambda t: (t[2]["id"] in demoted, + -(t[0] + (0.0 if _is_relief_entry(t[2]) else t[5])), + -t[1], t[2]["id"])) + if cues == "conviction" and scored and all(t[3] for t in scored): + # the review of batch 1, round 5: the record's own words say conviction and nothing + # non-conviction, and only lone-word fallbacks reached any entry ('PROBATION' reaches + # 'probation (as a sentence)', 'probation before judgment' and 'juvenile probation' + # alike) -- the conviction entry ranks first, never a state row's tie-break + scored.sort(key=lambda t: t[2]["id"] != "conviction") + scored = _acquittal_first(scored, disposition) + scored = _deferred_sentence_first(scored, state) + if len(scored) >= 2: + scored[:2] = _relief_yields(scored[0], scored[1], disposition, state) + out = _Matches() + if scored: + found = [t[2] for t in scored[:2]] + out.extend(_foreign_relief(found, entries, disposition) if jurisdiction == "non-us" + else found) + out.phrase_places = {e["id"]: phrase_where[e["id"]] for e in out if e["id"] in phrase_where} + elif cues == "conviction": + out.append(next(e for e in entries if e["id"] == "conviction")) + return out + + +def _load_equivalency(): + from canlex.screening import load_aliases + data = _curated_json("us_equivalency.json") + if data is None: + return None + load_aliases(data.get("aliases")) + return data + + +# Tool registration order mirrors the s. 36 screening chain (step 1 +# disposition, step 2 equivalency, step 3 rehabilitation) so clients that +# list tools in registration order present the workflow forwards. +@mcp.tool(name="canlex_us_disposition", + annotations={"title": "Foreign Disposition vs IRPA 'Conviction' " + "(US state rows)", **_READONLY}) +def canlex_us_disposition( + disposition: Annotated[str, Field( + description="The foreign disposition, in the traveller's or record's " + "own words, e.g. 'deferred adjudication', 'nolo contendere plea', " + "'withheld adjudication', 'expunged conviction', 'ACD' -- or 'all' " + "for the complete breakdown of every covered disposition type and " + "state variation.", min_length=1, max_length=200)], + state: Annotated[Optional[str], Field( + description="Optional US state (name or 2-letter code), e.g. 'TX' or " + "'Florida' -- the analysis can vary by state. A value that is not a " + "US state or territory ('England', 'Jalisco') is read as a non-US " + "record: no state row is applied, and the answer says so.", + max_length=30)] = None, + country: Annotated[Optional[str], Field( + description="Optional country of the record, as a name ('United " + "States', 'Mexico', 'United Kingdom'). Omit for a US record. Any " + "other country is read as a non-US record: no US state row is " + "applied and the answer is labelled 'non-US record: curated coverage " + "is thin'.", max_length=60)] = None, + detail: Annotated[Literal["full", "entries"], Field( + description="With disposition 'all' only: 'full' (default) lists every " + "disposition type with every US state row and the governing framework; " + "'entries' lists the disposition types alone -- with the given state's rows " + "under each when state is given -- and the framework in brief.")] = "full", +) -> str: + """Assess whether a foreign criminal disposition is a "conviction" for + IRPA s. 36 inadmissibility purposes, from CanLex's curated, + authority-cited dataset of disposition types. The dataset was compiled + for US records (deferred adjudication, withheld adjudication, nolo + pleas, expungements, state pardons, diversion, and more) and carries + state-by-state rows for the US states. A non-US record -- a country + other than the US, or a state value that is not a US state or territory + -- gets the governing framework and the entry-level verdict only, + labelled 'non-US record: curated coverage is thin'. (The tool name keeps + its 'us' prefix so saved client configurations keep working.) + + This is STEP 1 of a s. 36(1)(b)/(2)(b) screening (is there a conviction + at all?); step 2 is offence equivalency (canlex_us_equivalency) and step + 3 is rehabilitation (canlex_rehabilitation). Every answer is CURATED + COMMENTARY, not law: entries cite their authorities, and entries with no + authority say so and label their reasoning as interpretation. + + Returns: + str: Markdown -- the matching entry/entries with authorities and + status flags, or the governing principles plus an honest no-entry + statement when nothing matches. With 'all', detail 'entries' gives the + shorter list (about a tenth of the full breakdown). + """ + from canlex.commentary import BANNER, _entry_text, row_tag + data = _load_dispositions() + if data is None: + return ("The curated US-dispositions dataset is not present in this " + "deployment. Use canlex_search_legislation (doc_type " + "'caselaw') for the governing principles: Canada (MCI) v " + "Saini, 2001 FCA 311, and IRPA s. 36(3).") + # Roadmap T2.9: a non-US state value is never matched against US rows. + j = _jurisdiction(state, country, disposition) + rstate = j["state"] + us_rows = j["kind"] in ("us", "unspecified") + if disposition.strip().lower() in ("all", "list", "list all", "*"): + lines = [BANNER, ""] + [n for n in j["notes"]] + ([""] if j["notes"] else []) + [ + "Every covered disposition type (and US state variation), with " + "its conviction verdict for IRPA s. 36. Query a specific " + "disposition for the full analysis and authorities.", ""] + for e in data.get("dispositions", []): + lines.append(f"- {e['names'][0]} -- conviction: " + f"{e['is_conviction'].upper()} " + f"[{e['status']}]") + # detail 'entries' (audit follow-up 2026-09-21, missing 13): only the + # given state's rows, none without a state + rows = (e.get("state_variations", []) if detail != "entries" + else _state_rows(e, rstate) if us_rows else []) + for v in rows: + if v.get("state", "").lower() == "general": + continue + flag = (v.get("is_conviction") or e["is_conviction"]).upper() + # every state row prints its provenance tier (roadmap T3.5) + lines.append(f" - {v['state']}: {flag} {row_tag(v)}") + lines.append("") + lines.append("Reminder: even a non-conviction disposition can ground " + "inadmissibility under the act branch, IRPA " + "s. 36(1)(c)/(2)(c).") + # disposition roadmap T6.1: the full framework prints with 'all' + fw = _framework(data, "conviction-framework") + if fw and detail == "entries": + lines += ["", "---", "", FRAMEWORK_POINTER] + elif fw: + lines += ["", "---", "", "Governing framework:", "", fw["text"]] + return "\n".join(lines) + places = _record_places(j) + matches = _match_dispositions(data, disposition, rstate, jurisdiction=j["kind"], + places=places) + held = _held_disposition(disposition, rstate, j["kind"], data, places=places) + where = ", ".join(x for x in (j["given_state"], j["country"]) if x) + # disposition roadmap T1.6, T1.11, T6.1: step 1 for the record -- the row + # selected by mechanism, the record-wording flags -- resolved once, so the + # headline, the brief and CANLEX_RESULT say the same thing + s1 = _resolve_step1(data, disposition, rstate, matches) + matches = s1["matches"] + # disposition roadmap T1.10: the caveats that bear on the governing footing + act = _act_branch(s1["entry"], s1["verdict"], disposition) + parts = [] + if matches: + parts.append(_entry_brief(matches[0], rstate, us_rows=us_rows, act=act, s1=s1)) + for e in matches[1:]: + parts.append(f"Also possible: {e['names'][0]} -- conviction verdict " + f"{e['is_conviction'].upper().replace('-', ' ')}. Query " + "it by name for its analysis" + + (" and state rows." if us_rows else ".")) + else: + parts.append(BANNER) + parts.append("\n".join(_step1_headline(s1, rstate, us_rows, act))) + parts += _flag_lines(s1["flags"], places=s1.get("gate_places")) + parts.append( + f"No curated entry matches '{disposition}'" + + (f" ({where})" if where else "") + ". " + "That does not mean the disposition is or is not a conviction -- " + "it means CanLex has not analyzed this disposition type. Apply " + "the governing principles (below) to the disposition's actual " + "features: was guilt admitted or found? is the disposition final? " + "does an analogous Canadian non-conviction regime exist?") + if held: + parts.append(_held_note(held)) + from canlex.screening import disposition_notes + parts += disposition_notes(disposition) + # roadmap T3.6: the official guidance and statute the matched entry names + # (with no entry, the conviction framework's) + from canlex import readnext + rn_items = (readnext.items(matches[0]) if matches + else readnext.items(_framework(data, "conviction-framework"))) + rn = readnext.lines(rn_items, _index_chunks()) + if rn: + parts.append("\n".join(rn)) + # disposition roadmap T6.1: the framework in full only when nothing matches + # (and with 'all'); after a matched entry, a pointer + fw = _framework(data, "conviction-framework") + if fw and matches: + parts.append("---\n\n" + FRAMEWORK_POINTER) + elif fw: + parts.append("---\n\nGoverning framework:\n\n" + fw["text"]) + # never canlex_case (it needs a CanLII key and returns the citator): the + # registry's own read note (wave-3 verifier, 2026-09-15) + from canlex.authorities import read_note + parts.append(read_note()) + parts[1:1] = j["notes"] + parts.append(_disposition_result(matches, rstate, disposition, + jurisdiction=j["kind"], + read_next=readnext.chunk_ids(rn_items, _index_chunks()), + held=held, s1=s1)) + return _with_superseded("\n\n".join(parts)) + + +def _state_row(entry, state): + """The entry's row for the requested state (name or code), or None.""" + from canlex.screening import result_line # noqa: F401 (kept adjacent) + if not state: + return None + st = state.strip().lower() + code = _us_state_code(st) # disposition roadmap T1.4: dotted, wrapped, D.C. + name = _STATE_NAMES[code] if code else _STATE_NAMES.get(st, st) + for v in entry.get("state_variations", []): + if v.get("state", "").lower() in (st, name): + return v + return None + + +# ---- row selection by mechanism (disposition roadmap T1.11, 2026-09-16) -------- +# A state row is found by its state alone (_state_row). Optional curated row +# fields NARROW that choice, never replace it (HELD FOR REVIEW; absent on the +# approved baseline, where every row applies as before): +# 'covers' -- record phrases, read only when two or more rows of the +# entry share the state: the row they name is the row; +# 'other_programs' -- [{label, cites, words?, entry | verdict, note?, +# caveats_from?, question?}]: another programme of that +# state the row does not describe; a record citing it goes +# to that entry (its own row) or takes that verdict; the +# routed entry also carries the policy caveats of the +# entries caveats_from names (a sealing that may follow an +# acquittal carries the acquittal readings), and question +# names what decides the routed reading; +# 'not_for' -- record phrases the row does not describe; +# 'mechanism' -- the mechanism(s) the row describes, compared with the +# entry's optional 'mechanism_cues' ([{mechanism, label, +# words, verdict?}]: record words that name a mechanism, +# and the entry's reading of it) and 'ground_not_stated' +# ([{words, readings, question}]: words that name a +# vacatur without its ground). +# A row is never given a default 'covers' from its label (the roadmap's NOT +# RECOMMENDED list). On a mismatch the row is not applied and the entry's verdict +# is floored at FACT-SPECIFIC (both footings), with one ROW NOT APPLIED line -- +# unless the readings agree on the footing (a legal-error vacatur and a +# likely-no vacatur row both leave the act footing: the row still applies). +ROW_MISMATCH_LINES = { + "mechanism": ( + "ROW NOT APPLIED -- {state} row describes {row_label}, not '{words}' ({mechanism}) -- row " + "not applied; obtain the order and the ground it states. The two readings put the record " + "on different footings, so step 1 is FACT-SPECIFIC and both footings are carried."), + "ground-not-stated": ( + "ROW NOT APPLIED -- {state} row describes {row_label}; the record says '{words}' without " + "the ground -- row not applied; obtain the order and the ground it states. Deciding " + "question: {question} The readings put the record on different footings, so step 1 is " + "FACT-SPECIFIC and both footings are carried."), + "not-for": ( + "ROW NOT APPLIED -- {state} row describes {row_label}, not '{words}' -- row not applied; " + "obtain the order and the ground it states. Step 1 is FACT-SPECIFIC (the entry-level " + "reading, floored): both footings are carried."), + "covers": ( + "ROW NOT APPLIED -- {state} has {count} rows for this disposition ({row_label}), and the " + "record's words name none of them -- row not applied; obtain the order and the ground it " + "states. Step 1 is FACT-SPECIFIC: both footings are carried."), + "program-entry": ( + "ROW NOT APPLIED -- {state} row of the {from_entry} entry describes {row_label}, not " + "'{words}': the record names {label} -- the {entry} entry applies.{note}{question}"), + "program-verdict": ( + "ROW NOT APPLIED -- {state} row describes {row_label}, not '{words}': the record names " + "{label} -- verdict {verdict}.{note}"), + "program-absent": ( + "ROW NOT APPLIED -- {state} row describes {row_label}, not '{words}': the record names " + "{label}, whose entry ({entry}) is not in this deployment -- row not applied; obtain the " + "order and the ground it states. Step 1 is FACT-SPECIFIC: both footings are carried."), +} +ROW_MISMATCH_CARD = { + "program": "{state} row not applied: the record names {label}", + "other": "{state} row not applied: it describes another mechanism -- obtain the order", +} +ROW_MISMATCH_TIER = "the {state} row is not applied -- ROW NOT APPLIED" +ROW_PROGRAM_NOTE = " ({note})" +ROW_PROGRAM_QUESTION = (" Deciding question: {question} Until it is answered the routed entry " + "carries the policy caveats of {caveats_from} (both readings in step 1).") + + +def _as_list(value): + """A curated string-or-list field as a list of strings.""" + if isinstance(value, str): + return [value] + return [v for v in value or () if isinstance(v, str)] + + +def _state_rows(entry, state): + """Every row of the entry for the requested state (name or code), in order.""" + if not state or not entry: + return [] + st = state.strip().lower() + code = _us_state_code(st) + name = _STATE_NAMES[code] if code else _STATE_NAMES.get(st, st) + return [v for v in entry.get("state_variations", []) or () + if isinstance(v, dict) and v.get("state", "").lower() in (st, name) + and v.get("state", "").lower() != "general"] + + +def _phrase_in(phrase, words): + """Whether a curated phrase ('CPL 160.50', 'legal error') is in the record's + folded words (_row_words) as whole words.""" + p = _disp_fold(str(phrase or "")) + return bool(p) and f" {p} " in words + + +_ROW_WORDS_CACHE = {} + + +def _row_words(disposition): + """The record's words that row selection reads: its segments, less any + segment that says relief or a motion was denied or refused ('440.10 MOTION + DENIED' names no vacatur), each read with its shorthand expanded and its + negated words dropped ('conviction not vacated'), folded and padded. The + record-order rewrites are not applied here: a cite or a programme's words + ('new trial') are read as the record gives them.""" + key = disposition or "" + hit = _ROW_WORDS_CACHE.get(key) # one lookup: a concurrent clear cannot split it + if hit is not None: + return hit + from canlex.screening import (_CUE, _NEGATED, _accent_fold, _fold, _segments, + expand_disposition, strip_appeal_upheld) + text = _accent_fold(key) + kept = [text[a:b] for a, b in _segments(text) if not _CUE["refusal"].search(text[a:b])] + out = " " + " ".join(_disp_fold(strip_appeal_upheld(_NEGATED.sub(" ", _fold( + expand_disposition(s))))) for s in kept) + " " + out = re.sub(r"\s+", " ", out) + if len(_ROW_WORDS_CACHE) > 4096: + _ROW_WORDS_CACHE.clear() + _ROW_WORDS_CACHE[key] = out + return out + + +def _row_label(row): + """A row's mechanism as its note names it: the note up to ' -- ' ('Clean Slate + automatic sealing / petition sealing (CPL § 160.57; § 160.59)').""" + from canlex.commentary import _FLAG + note = _FLAG.sub("", str((row or {}).get("note") or "")).strip() + return note.split(" -- ", 1)[0].strip() or f"the {row.get('state')} regime" + + +def _readings_agree(reading, row_verdict): + """Whether the entry's reading of a mechanism and the row's verdict put the + record on the same single footing (neither both footings).""" + if reading is None or row_verdict is None: + return False + a, b = _footing(reading), _footing("fact-specific" if row_verdict == "depends" else row_verdict) + return a == b and a != "both" + + +def _select_row(entry, state, disposition, data=None, route=True): + """The governing state row of `entry` for the record (disposition roadmap + T1.11): {entry, row, mismatch}. With no narrowing field this is _state_row. + mismatch (None when the row applies): {kind, state, row, words, ...} -- + 'covers' (the state's rows and the record naming none), 'program' (routed + to another entry, 'entry', or given a 'verdict'), 'program-absent', + 'not-for', 'mechanism', 'ground-not-stated'. route=False: the row of an + entry a programme routed to (no second hop).""" + rows = _state_rows(entry, state) + if not rows: + return {"entry": entry, "row": None, "mismatch": None} + words = _row_words(disposition) + row = rows[0] + if len(rows) > 1 and any(v.get("covers") for v in rows): + chosen = [v for v in rows if any(_phrase_in(c, words) for c in _as_list(v.get("covers")))] + plain = [v for v in rows if not v.get("covers")] + if len(chosen) == 1: + row = chosen[0] + elif not chosen and len(plain) == 1: + row = plain[0] + else: + return {"entry": entry, "row": None, "mismatch": { + "kind": "covers", "state": rows[0]["state"], "row": None, "rows": rows, + "words": None}} + row_verdict = row.get("is_conviction") or (entry or {}).get("is_conviction") + if route: + for p in row.get("other_programs") or (): + if not isinstance(p, dict): + continue + cite = next((c for c in _as_list(p.get("cites")) if _phrase_in(c, words)), None) + if cite is None or (p.get("words") and not any( + _phrase_in(w, words) for w in _as_list(p.get("words")))): + continue + mm = {"kind": "program", "state": row["state"], "row": row, "words": cite, + "label": p.get("label") or cite, "note": p.get("note"), + "from_entry": (entry or {}).get("id")} + if p.get("entry"): + target = next((e for e in (data or {}).get("dispositions") or () + if isinstance(e, dict) and e.get("id") == p["entry"]), None) + if target is None: + return {"entry": entry, "row": None, + "mismatch": dict(mm, kind="program-absent", entry=p["entry"])} + inner = _select_row(target, state, disposition, data, route=False) + target = _with_program_caveats(target, p, data) + mm.update(entry=target["id"], inner=inner["mismatch"], + question=p.get("question"), + caveats_from=[x for x in _as_list(p.get("caveats_from"))]) + return {"entry": target, "row": inner["row"], "mismatch": mm} + if p.get("verdict"): + return {"entry": entry, "row": None, "mismatch": dict(mm, verdict=p["verdict"])} + hit = next((w for w in _as_list(row.get("not_for")) if _phrase_in(w, words)), None) + if hit: + return {"entry": entry, "row": None, "mismatch": { + "kind": "not-for", "state": row["state"], "row": row, "words": hit}} + tags = _as_list(row.get("mechanism")) + if tags: + cues = [c for c in (entry or {}).get("mechanism_cues") or () + if isinstance(c, dict) and c.get("mechanism")] + named = [] + for c in cues: + w = next((x for x in _as_list(c.get("words")) if _phrase_in(x, words)), None) + if w: + named.append((c, w)) + if named: + other = [(c, w) for c, w in named if c["mechanism"] not in tags] + if other and not all(_readings_agree(c.get("verdict"), row_verdict) for c, _ in other): + c, w = next(((c, w) for c, w in other + if not _readings_agree(c.get("verdict"), row_verdict))) + return {"entry": entry, "row": None, "mismatch": { + "kind": "mechanism", "state": row["state"], "row": row, "words": w, + "mechanism": c.get("label") or c["mechanism"]}} + else: + reading = {c["mechanism"]: c.get("verdict") for c in cues} + for g in (entry or {}).get("ground_not_stated") or (): + if not isinstance(g, dict): + continue + w = next((x for x in _as_list(g.get("words")) if _phrase_in(x, words)), None) + if not w: + continue + rest = [r for r in _as_list(g.get("readings")) if r not in tags] + if rest and not all(_readings_agree(reading.get(r), row_verdict) for r in rest): + return {"entry": entry, "row": None, "mismatch": { + "kind": "ground-not-stated", "state": row["state"], "row": row, + "words": w, "question": g.get("question") or ""}} + break + return {"entry": entry, "row": row, "mismatch": None} + + +def _with_program_caveats(target, program, data): + """The entry a programme routes to, carrying the policy caveats of the + entries the programme's optional 'caveats_from' names (a copy; the entry + itself is unchanged).""" + extra, have = [], {c.get("id") for c in target.get("policy_caveats") or [] + if isinstance(c, dict)} + for eid in _as_list(program.get("caveats_from")): + src = next((e for e in (data or {}).get("dispositions") or () + if isinstance(e, dict) and e.get("id") == eid), None) + for c in (src or {}).get("policy_caveats") or []: + if isinstance(c, dict) and c.get("id") not in have: + extra.append(c) + have.add(c.get("id")) + return dict(target, policy_caveats=list(target.get("policy_caveats") or []) + extra) \ + if extra else target + + +def _row_mismatch_line(mm, card=False): + """The ROW NOT APPLIED line (or card clause) of a row mismatch.""" + if not mm: + return "" + from canlex.commentary import _vlabel + kind = mm["kind"] + if card: + key = "program" if kind == "program" else "other" + return ROW_MISMATCH_CARD[key].format(state=mm["state"], label=mm.get("label") or "") + row = mm.get("row") + fields = {"state": mm["state"], "words": mm.get("words") or "", + "row_label": _row_label(row) if row else "", + "mechanism": mm.get("mechanism") or "", "question": mm.get("question") or "", + "label": mm.get("label") or "", "from_entry": mm.get("from_entry") or "", + "entry": mm.get("entry") or "", + "note": ROW_PROGRAM_NOTE.format(note=mm["note"]) if mm.get("note") else "", + "question": ROW_PROGRAM_QUESTION.format( + question=mm["question"], + caveats_from=", ".join(mm.get("caveats_from") or ()) or "no other entry") + if mm.get("question") else "", + "verdict": _vlabel(mm.get("verdict")) if mm.get("verdict") else "", + "count": len(mm.get("rows") or ())} + if kind == "covers": + fields["row_label"] = "; ".join(_row_label(v) for v in mm.get("rows") or ()) + if kind == "program": + kind = "program-entry" if mm.get("entry") else "program-verdict" + line = ROW_MISMATCH_LINES[kind].format(**fields) + if mm.get("inner"): + line += " " + _row_mismatch_line(mm["inner"]) + return line + + +def _row_mismatch_result(mm): + """A row mismatch in CANLEX_RESULT: {state, kind, words, row_entry, routed_entry}.""" + if not mm: + return None + return {"state": mm["state"], "kind": mm["kind"], "words": mm.get("words"), + "row_entry": mm.get("from_entry"), + "routed_entry": mm.get("entry") if mm["kind"] == "program" else None, + "inner": (mm.get("inner") or {}).get("kind")} + + +def _gated_readings_non_conviction(entry, places): + """True when every reading CanLex has for a place-gated match is a non-conviction + (no or likely-no): the entry's own verdict and, for each gated place that is a US + state, that state's row (the review of batch 1, round 3).""" + readings = [(entry or {}).get("is_conviction")] + for place in places or (): + code = _us_state_code(str(place).strip().lower()) + if code: + row = _state_row(entry or {}, _STATE_NAMES[code]) + if row: + readings.append(row.get("is_conviction") or entry.get("is_conviction")) + return bool(entry) and all(v in ("no", "likely-no") for v in readings) + + +def _resolve_step1(data, disposition, state, matches): + """Step 1 on the matched entries, shared by _step1 and canlex_us_disposition so + the memo, the card, the disposition answer and CANLEX_RESULT agree: {matches, + entry, row, verdict, row_mismatch, row_not_applied, flags, question_flag}. + The governing row is _select_row's; a programme routes to its entry, a + mismatch floors the verdict at fact-specific; then a record-wording question + flag (_step1_flags) may leave step 1 open.""" + out = {"matches": list(matches or []), "entry": None, "row": None, "verdict": "unknown", + "row_mismatch": None, "row_not_applied": None, "flags": [], "question_flag": None, + "gate_places": None} + entry = None + if matches: + sel = _select_row(matches[0], state, disposition, data) + entry, row, mm = sel["entry"], sel["row"], sel["mismatch"] + if entry is not matches[0]: + out["matches"] = [entry] + [m for m in matches if m.get("id") != entry.get("id")][:1] + verdict = (row or {}).get("is_conviction") or entry["is_conviction"] + if mm and mm["kind"] == "program" and mm.get("verdict"): + verdict = mm["verdict"] + elif mm and (mm["kind"] != "program" or mm.get("inner")): + verdict = "fact-specific" # floored: the row that decided is not applied + out.update(entry=entry, row=row, row_mismatch=mm, row_not_applied=(mm or {}).get("row"), + verdict="fact-specific" if verdict == "depends" else verdict) + flags, verdict, row_used, q = _step1_flags(disposition, entry, out["row"], out["verdict"]) + # the review of batch 1 (R10): words CanLex reads only for listed places, and a + # record that names no place -- step 1 is open on the place unless the reading + # already carries both footings + where = (getattr(matches, "phrase_places", None) or {}).get((matches or [{}])[0].get("id")) + if where and q is None and _footing(out["verdict"]) != "both": + if _gated_readings_non_conviction(entry, where): + # the review of batch 1, round 3: every reading for the gated places is a + # non-conviction -- the verdict stands and the place is noted, never a + # conviction-branch draft for 'NOT PROVEN' or 'NO PAPERED' + flags = list(flags) + ["place-noted"] + else: + flags, verdict, row_used, q = list(flags) + ["place-not-named"], "fact-specific", None, \ + "place-not-named" + out["gate_places"] = list(where) + out.update(flags=flags, question_flag=q) + if q: + out.update(verdict=verdict, row=row_used, + row_not_applied=out["row"] or out["row_not_applied"]) + return out + + +# ---- the step-1 headline of canlex_us_disposition (disposition roadmap T6.1) --- +# The answer opens with the GOVERNING step-1 verdict -- the state row's when a +# row applies, the record wording's or the row selection's when they leave step +# 1 open -- so the first verdict read is the one CANLEX_RESULT carries; then the +# footing it puts the record on and what decides it. The entry-level verdict +# follows, labelled as the verdict without a state row. +STEP1_HEADLINE = "STEP 1 FOR THIS RECORD: {verdict} -- {governs}" +STEP1_GOVERNS = { + "row": "{state} row: {row_label} [{tier}]", + "program": ("the {entry} entry{row} -- reached from the {state} row of the {from_entry} entry " + "on '{words}' (ROW NOT APPLIED below)"), + "program-row": "; {state} row: {row_label} [{tier}]", + "mismatch": ("the {entry} entry, floored at FACT-SPECIFIC -- the {state} row is not applied " + "(ROW NOT APPLIED below)"), + "question": "NOT SETTLED on the record's wording -- the {entry} entry is one reading{row}", + "question-row": ", and the {state} row is not applied", + "no-row": "the {entry} entry's verdict -- no {state} row", + "no-state": "the {entry} entry's verdict -- no state given (a state row can differ)", + "not-us": "the {entry} entry's verdict -- no US state row applies to this record", + "no-entry": "no curated entry matches the record's words", +} +STEP1_CONSEQUENCE = { + "conviction": "CONSEQUENCE: the conviction footing -- {conviction}.", + "act": "CONSEQUENCE: the act footing only -- {act}.", + "both": ("CONSEQUENCE: both footings are carried until what decides it is answered -- " + "{conviction}; {act}."), +} +STEP1_CONSEQUENCE_ACT = { + "not-settled": (" After an acquittal, whether the act branch may be used is NOT SETTLED -- " + "POLICY CAVEAT ({label}); both readings below, and no act-branch allegation is " + "drafted."), + "policy-caveat": (" POLICY CAVEAT ({label}): whether the act branch is used for this " + "disposition is NOT SETTLED; both readings below."), + "running": (" The record says the programme is still running: ENF 2 s. 3's committing-an-act " + "provisions 'would generally be applied' until the trial concludes (below)."), +} +STEP1_DECIDES = "WHAT DECIDES IT: {text}" +STEP1_DECIDES_TEXT = { + "question": "{question} (RECORD WORDING below)", + "mismatch": ("the order and the ground it states -- the {state} row is not applied " + "(ROW NOT APPLIED below)"), + "ground": "{question} Obtain the order and the ground it states (ROW NOT APPLIED below).", + "program": ("the order and the section it names ('{words}') -- confirm it before relying on " + "this routing (ROW NOT APPLIED below)"), + "program-question": ("{question} Obtain the order and the section it names ('{words}') -- " + "confirm it before relying on this routing (ROW NOT APPLIED below)"), + "place": ("{question} CanLex reads these words only for a record from: {places} " + "(RECORD WORDING below)"), + "open-question": "{question} (the {state} row's open question, held for review)", + "row-differs": ("what the {state} law the row cites does ({row_label}) -- the row's reading " + "puts the record on a different footing from the entry-level verdict below; " + "read the statute before relying on it"), + "no-entry": ("the disposition's actual features -- was guilt admitted or found, is the " + "disposition final, and does an analogous Canadian non-conviction regime exist?"), +} +ENTRY_LEVEL_VERDICT = ("Entry-level verdict (without a state row): {verdict} -- is the COMPLETED " + "disposition a conviction for IRPA s. 36") +OTHER_STATES_COUNT = ("Other states: {count} more state row(s) on this entry, not printed -- " + "query with the state for its row.") +ROW_NOT_APPLIED_HEAD = { + "question": ("{state} row (one reading only, NOT applied -- the record's wording leaves step 1 " + "open; see RECORD WORDING above):"), + "mismatch": "{state} row (NOT applied -- see ROW NOT APPLIED above):", +} +FRAMEWORK_POINTER = ( + "Governing framework, in brief (the full text prints when nothing matches, or with " + "disposition 'all'): whether a foreign disposition is a conviction for IRPA s. 36(1)(b)/(2)(b) " + "is a question of Canadian law informed by the foreign law's effect. A disposition the foreign " + "law deems a non-conviction, or an expungement, may be respected where the legal systems are " + "similar (Canada (MEI) v Burgon, [1991] 3 FC 44 (CA); Barnett v Canada (MCI) (1996), 109 FTR " + "154 (FCTD)); a foreign discharge or pardon is recognized only where the three elements of " + "Canada (MCI) v Saini, 2001 FCA 311, para 24 are met (a similar legal system; a similar aim, " + "content and effect of the specific law; no valid reason not to recognize it). Where there is " + "no conviction, the act branch, s. 36(1)(c)/(2)(c), turns on committing the act: under s. 33 " + "the facts need only meet the standard of 'reasonable grounds to believe' -- more than mere " + "suspicion, less than a balance of probabilities (Mugesera v Canada (MCI), 2005 SCC 40, para " + "114) -- except that a permanent resident's s. 36(1)(c) determination 'must be based on a " + "balance of probabilities' (s. 36(3)(d)).") + + +_DECIDING_SPLIT = re.compile(r"(?<=[.?])\s+(?=[A-Z])") + + +def _deciding_sentence(text): + """What decides step 1, from a curated text: its first sentence that asks + the question ('Deciding question: ...?'), else its first sentence ('' when + there is none).""" + sentences = [s.strip() for s in _DECIDING_SPLIT.split(str(text or "").strip()) if s.strip()] + return next((s for s in sentences if s.endswith("?") or s.lower().startswith("deciding question")), + sentences[0] if sentences else "") + + +def _general_notes(e): + """An entry's General notes, each once (disposition roadmap T6.1).""" + return list(dict.fromkeys(v["note"] for v in e.get("state_variations", []) or () + if str(v.get("state", "")).lower() == "general" and v.get("note"))) + + +def _step1_headline(s1, state, us_rows=True, act=None): + """The three lines canlex_us_disposition opens with (disposition roadmap + T6.1): STEP 1 FOR THIS RECORD (the governing verdict, the governing row's + mechanism and tier, or why no row governs), CONSEQUENCE (the footing) and + WHAT DECIDES IT (the question flag, the row selection, the row's open + question, else the first sentence of the entry's bottom line).""" + from canlex.commentary import _vlabel, row_tier + s1 = s1 or {} + entry, row, mm, q = s1.get("entry"), s1.get("row"), s1.get("row_mismatch"), s1.get("question_flag") + verdict = s1.get("verdict") or "unknown" + name = f"'{entry['names'][0]}'" if entry else "" + st = (mm or {}).get("state") or (row or {}).get("state") or state + if entry is None: + governs = STEP1_GOVERNS["no-entry"] + elif q: + skipped = s1.get("row_not_applied") + governs = STEP1_GOVERNS["question"].format( + entry=name, row=STEP1_GOVERNS["question-row"].format(state=skipped["state"]) + if skipped else "") + elif mm and mm["kind"] == "program": + rowtxt = (STEP1_GOVERNS["program-row"].format(state=row["state"], row_label=_row_label(row), + tier=row_tier(row)) if row else "") + governs = STEP1_GOVERNS["program"].format(entry=name, row=rowtxt, state=mm["state"], + from_entry=mm.get("from_entry") or "", + words=mm.get("words") or "") + elif mm: + governs = STEP1_GOVERNS["mismatch"].format(entry=name, state=mm["state"]) + elif row: + governs = STEP1_GOVERNS["row"].format(state=row["state"], row_label=_row_label(row), + tier=row_tier(row)) + elif not us_rows: + governs = STEP1_GOVERNS["not-us"].format(entry=name) + elif state: + governs = STEP1_GOVERNS["no-row"].format(entry=name, state=state) + else: + governs = STEP1_GOVERNS["no-state"].format(entry=name) + act = act or _act_branch(entry, verdict, "") + footing = act.get("footing") or _footing(verdict) + consequence = STEP1_CONSEQUENCE[footing].format( + conviction=_basis_label("foreign-conviction"), act=_basis_label("foreign-act")) + if footing != "conviction" and act.get("act_branch") in STEP1_CONSEQUENCE_ACT: + consequence += STEP1_CONSEQUENCE_ACT[act["act_branch"]].format(label=act.get("label") or "") + if entry is None and not q: + decides = STEP1_DECIDES_TEXT["no-entry"] + elif q == "place-not-named" and s1.get("gate_places"): + decides = STEP1_DECIDES_TEXT["place"].format(question=DISPOSITION_FLAG_QUESTIONS[q], + places="; ".join(s1["gate_places"])) + elif q: + decides = STEP1_DECIDES_TEXT["question"].format(question=DISPOSITION_FLAG_QUESTIONS[q]) + elif mm and mm["kind"] == "ground-not-stated": + decides = STEP1_DECIDES_TEXT["ground"].format(question=mm.get("question") or "") + elif mm and (mm["kind"] != "program" or mm.get("inner")): + decides = STEP1_DECIDES_TEXT["mismatch"].format(state=st) + elif mm and mm.get("question"): + decides = STEP1_DECIDES_TEXT["program-question"].format(question=mm["question"], + words=mm.get("words") or "") + elif mm: + decides = STEP1_DECIDES_TEXT["program"].format(words=mm.get("words") or "") + elif row and row.get("open_question"): + decides = STEP1_DECIDES_TEXT["open-question"].format(question=row["open_question"], + state=row["state"]) + elif row and _footing(verdict) != _footing( + "fact-specific" if entry["is_conviction"] == "depends" else entry["is_conviction"]): + # the entry's bottom line would state the entry-level reading the row overrides + decides = STEP1_DECIDES_TEXT["row-differs"].format(state=row["state"], + row_label=_row_label(row)) + else: + decides = (_deciding_sentence(entry.get("bottom_line")) + or _deciding_sentence(entry.get("analysis"))) + return [STEP1_HEADLINE.format(verdict=_vlabel(verdict), governs=governs), consequence, + STEP1_DECIDES.format(text=decides)] + + +def _entry_brief(e, state, max_other=60, us_rows=True, act=None, s1=None): + """One disposition entry sized for a tool answer: the step-1 headline, the + governing row, the analysis, every other state as a count (a state given) + or a one-line verdict each, and the authorities -- instead of all 51 rows of + every matched entry (56 KB for 'pled guilty, deferred'). us_rows=False (a + non-US record, roadmap T2.9) leaves the US state rows out and says so. + s1: the record's step 1 (_resolve_step1) -- the headline, the record-wording + lines (disposition roadmap T1.6), the ROW NOT APPLIED line (T1.11) and the + governing row come from it (disposition roadmap T6.1); None: the entry's + own row for the state, with no record wording. act: step 1's _act_branch + -- only the caveats that bear on its footing print (T1.10); None prints + every caveat.""" + from canlex.commentary import (BANNER, GUIDANCE_HEADING, SURVEY_NOTE, + _STATUS_LABEL, _row_lines, _vlabel) + from canlex.screening import saini_checklist + if s1 is None: + row0 = _state_row(e, state) + verdict0 = (row0 or {}).get("is_conviction") or e["is_conviction"] + s1 = {"entry": e, "row": row0, "row_not_applied": None, "row_mismatch": None, + "verdict": "fact-specific" if verdict0 == "depends" else verdict0, + "question_flag": None, "flags": []} + flags_text = _flag_lines(s1.get("flags"), places=s1.get("gate_places")) + q, mm = s1.get("question_flag"), s1.get("row_mismatch") + row = s1.get("row") + skipped = s1.get("row_not_applied") + lines = [BANNER, ""] + _step1_headline(s1, state, us_rows, act) + [""] + if flags_text: + lines += flags_text + [""] + if q: + lines += [STEP1_ONE_READING, ""] + if mm: + lines += [_row_mismatch_line(mm), ""] + own_rows = [v for v in e.get("state_variations", []) if v.get("state", "").lower() != "general"] + if row is not None and us_rows and any(v is row for v in own_rows): + flag = (f" (conviction: {_vlabel(row['is_conviction'])})" + if row.get("is_conviction") else "") + lines.append(f"{row['state']} row:") + lines += _row_lines(row, flag, full=True) + [""] + lines += [f"Disposition: {'; '.join(e['names'])}", + ENTRY_LEVEL_VERDICT.format(verdict=_vlabel(e["is_conviction"])), + f"Authority status: {_STATUS_LABEL[e['status']]}"] + if e.get("bottom_line"): + lines += ["", f"BOTTOM LINE: {e['bottom_line']}"] + from canlex.commentary import policy_caveat_lines + # roadmap T3.6: held; absent on the baseline + caveat = _caveat_block(e, act) if act is not None else policy_caveat_lines(e) + if caveat: + lines += [""] + caveat + elif act is not None and e.get("policy_caveats"): + lines += ["", CAVEAT_NOT_IN_PLAY.format(footing=STEP1_FOOTING_LABEL[act["footing"]])] + lines += ["", e["analysis"]] + rows = own_rows + if rows and not us_rows: + lines += ["", f"State rows: {len(rows)} US jurisdictions, not applied -- " + "this is not a US record, and the rows describe US state " + "law only."] + lines += [f"- General: {n}" for n in _general_notes(e)] + rows = [] + if rows: + here = [v for v in rows if any(v is x for x in _state_rows(e, state))] if state else [] + block = [] + if skipped is not None and any(v is skipped for v in rows): + flag = (f" (conviction: {_vlabel(skipped['is_conviction'])})" + if skipped.get("is_conviction") else "") + block.append(ROW_NOT_APPLIED_HEAD["question" if q else "mismatch"].format( + state=skipped["state"])) + block += _row_lines(skipped, flag, full=True) + elif mm and mm["kind"] == "covers" and here: + for v in here: + flag = (f" (conviction: {_vlabel(v['is_conviction'])})" + if v.get("is_conviction") else "") + block.append(ROW_NOT_APPLIED_HEAD["mismatch"].format(state=v["state"])) + block += _row_lines(v, flag, full=True) + elif state and not any(v is row for v in here): + block.append(f"No row for '{state}'; the entry-level verdict applies.") + if block: + lines += [""] + block + others = [v for v in rows if not any(v is x for x in here)] + if others and state: + lines += ["", OTHER_STATES_COUNT.format(count=len(others))] + elif others: + lines += ["", "Other states (verdict only; query with the state for " + "the row): " + "; ".join( + f"{v['state']}: {_vlabel(v.get('is_conviction') or e['is_conviction'])}" + for v in others[:max_other])] + general = [f"- General: {n}" for n in _general_notes(e)] + if general and not (block or others): + lines.append("") + lines += general + ["", SURVEY_NOTE] + checklist = saini_checklist(e) # roadmap T2.8: only with a curated pardon_test + if checklist: + lines += [""] + checklist + if e.get("authorities"): + # each followed by the authority registry's pointer (roadmap T3.2) + from canlex.authorities import authority_lines + lines += ["", "Authorities:"] + lines += authority_lines(e["authorities"], index_chunks=_index_chunks()) + if e.get("guidance"): + lines += ["", GUIDANCE_HEADING] + lines += [f"- {g['ref']}: {g['note']}" for g in e["guidance"]] + if e.get("interpretation"): + lines += ["", "INTERPRETATION (no direct authority -- CanLex's reasoned " + "view from the governing principles; a starting point, " + "not an answer): " + e["interpretation"]] + # A curated reference to a decision later reversed (roadmap T1.11). + for note in _superseded_notes("\n".join(lines)): + lines += ["", note] + return "\n".join(lines) + + +def _disposition_result(matches, state, disposition=None, jurisdiction="unspecified", + read_next=None, held=None, s1=None): + """The structured hand-off for step 1. jurisdiction: _jurisdiction()'s + kind ('us', 'non-us', 'canada' or 'unspecified'). read_next: the chunk + ids the READ NEXT block printed (roadmap T3.6); held: a held-content + match (_held_disposition) -- 'assumed' then carries 'held-content'. s1: + the record's step 1 (_resolve_step1) the text printed, so the result and + the text agree (disposition roadmap T6.1); None resolves it here.""" + from canlex.commentary import row_provenance + from canlex.screening import disposition_cues, result_line, saini_checklist + if s1 is None: + s1 = _resolve_step1(None, disposition, state, matches) + cues = disposition_cues(disposition) if disposition else None + extra = {} + if read_next: + extra["read_next"] = list(read_next) + if held: + extra.update(held=[_held_id(held)], assumed=["held-content"]) + from canlex.screening import confidence_block, step1_confidence + qflag = s1.get("question_flag") + if not s1.get("entry"): + # disposition roadmap T1.6 / critic: the record-wording flags, and a + # question that leaves step 1 open ('sentencing pending') + return result_line(tool="canlex_us_disposition", entry=None, + verdict=s1["verdict"], cues=cues, act_branch_open=True, + footing=_footing(s1["verdict"]), # round 5: 'both' on an UNKNOWN step 1 + jurisdiction=jurisdiction, saini_checklist=False, + disposition_flags=list(s1.get("flags") or []), + **({"question_flag": qflag} if qflag else {}), + confidence=confidence_block(step1=[]), **extra) + e, row, verdict = s1["entry"], s1["row"], s1["verdict"] + caveats = [c.get("id") for c in e.get("policy_caveats") or [] if isinstance(c, dict)] + if caveats: + extra["policy_caveats"] = caveats + extra["disposition_flags"] = list(s1.get("flags") or []) + if qflag: + extra["question_flag"] = qflag + if s1.get("gate_places"): + extra["gate_places"] = list(s1["gate_places"]) + if s1.get("row_mismatch"): + # disposition roadmap T1.11: the row selection set the row aside or routed + extra["row_mismatch"] = _row_mismatch_result(s1["row_mismatch"]) + # disposition roadmap T1.5 / T1.10: the footing and how the act branch + # stands -- after an acquittal act_branch_open is null (NOT SETTLED) + act = _act_branch(e, verdict, disposition) + extra.update(footing=act["footing"], act_branch=act["act_branch"]) + return result_line(tool="canlex_us_disposition", entry=e["id"], + verdict=verdict, state_row=bool(row), + verified=bool((row or {}).get("verified")), + row_provenance=row_provenance(row), + conviction=(verdict in ("yes", "likely-yes")), + cues=cues, act_branch_open=None if _act_not_settled(act) else True, + jurisdiction=jurisdiction, + saini_checklist=bool(saini_checklist(e)), + confidence=confidence_block(step1=[step1_confidence(e, row)]), + **extra) + + +# ---- read next (roadmap T3.6) -------------------------------------------------- +# The official guidance and statute a curated entry names (its optional +# 'see_also', HELD FOR REVIEW when added -- canlex/readnext.py). The approved +# baseline predates the field, so a composed release prints no READ NEXT block. + +def _framework(data, mid): + """The curated methodology item `mid` ('conviction-framework', + 'equivalency-framework') of a dataset, or None.""" + return next((m for m in (data or {}).get("methodology") or [] + if isinstance(m, dict) and m.get("id") == mid), None) + + +def _pairing_read_next_items(pairings, data): + """The read-next items a step-2 answer shows: each matched pairing's own + (a tie: both), then the equivalency framework's when none of them names + guidance -- most pairings name only their Canadian statute sections.""" + from canlex import readnext + own = [i for p in pairings or () if p for i in readnext.items(p)] + return readnext.with_fallback( + own, readnext.items(_framework(data, "equivalency-framework")), _index_chunks()) + + +# ---- content held for review and absent here (wave 2 verifier, principle 2) ------- +# A composed release ships the APPROVED curated files, so a record whose words +# reach only content the maintainer has not yet approved -- a disposition +# entry, an equivalency pairing, words added to an approved one, a statute-code +# reading -- would come back as a bare no-match, or as a weaker match. The +# composer (tools/compose_release.py) writes the held ids and their MATCHING +# WORDS -- never their legal characterisation -- into data/curated/ +# held_manifest.json in the composed tree. When the record's words reach held +# content ahead of everything loaded, the tools say 'HELD FOR REVIEW -- not in +# this deployment' and CANLEX_RESULT 'assumed' carries 'held-content'. main has +# no manifest: all of its content is loaded. +HELD_MANIFEST_FILE = "held_manifest.json" +HELD_PREFIX = "HELD FOR REVIEW -- not in this deployment: " +HELD_WHAT = {"disposition": "CanLex's disposition entry", + "pairing": "CanLex's equivalency pairing"} +HELD_TODO = { + "disposition": ("step 1 is answered without it -- apply the conviction framework " + "(canlex_us_disposition) to the disposition's actual features before " + "relying on the verdict"), + "pairing": ("step 2 is answered without it -- compare the foreign offence with the " + "Canadian provision (Hill) before relying on the band")} +HELD_ALIAS = ("CanLex's reading of the statute code '{code}' (as '{plain}') is held for " + "review, so the code is not read here -- read the foreign statute and " + "compare it with the Canadian provision (Hill)") +_HELD_CACHE = {} +_ALIAS_KEYS_READ = {} + + +def _held_manifest(): + """The composed release's held manifest (data/curated/held_manifest.json), + or None -- main's tree has none. Cached by mtime as one (stamp, data) + pair published whole, so a second screening thread never reads the cache + between a clear and a fill (audit follow-up 2026-09-21).""" + from canlex.config import DATA_DIR + path = DATA_DIR / "curated" / HELD_MANIFEST_FILE + try: + stamp = path.stat().st_mtime_ns + except OSError: + return None + hit = _HELD_CACHE.get("manifest") + if hit is None or hit[0] != stamp: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + data = None + hit = (stamp, data if isinstance(data, dict) else None) + _HELD_CACHE["manifest"] = hit + return hit[1] + + +def _held_id(h): + return h.get("id") or h.get("code") + + +def _held_note(h): + """The HELD FOR REVIEW line for a _held_disposition, _held_pairing or + _held_alias match.""" + if h["kind"] == "alias": + return HELD_PREFIX + HELD_ALIAS.format(code=h["code"], plain=h["plain"]) + "." + what = HELD_WHAT[h["kind"]] + ahead = (f", ahead of '{h['loaded']}', which this answer uses" if h.get("loaded") else "") + if h.get("held") in ("caveat", "row", "mechanism"): + # round 4 (RP-04): the answering entry itself carries the held content (round 5: its + # readings by the mechanism the record names -- a vacatur for legal error -- too) + body = (f"{what} '{h['id']}' answers this record (on '{h['words']}'), and " + + ("the readings that leave the act branch NOT SETTLED on that entry" + if h["held"] == "caveat" else "the state row's programme readings" + if h["held"] == "row" else "the readings of the mechanism those words name on that entry") + + " are held for review and not loaded here, so ") + return HELD_PREFIX + body + HELD_TODO[h["kind"]] + " -- no allegation is drafted from it." + if h.get("held") in ("entry", "pairing"): + body = (f"the record's words match {what} '{h['id']}' (on '{h['words']}'){ahead}; " + "it is held for review and not loaded here, so ") + else: + body = (f"the record's words ('{h['words']}') match words of {what} '{h['id']}' " + f"that are held for review and not loaded here{ahead}, so ") + return HELD_PREFIX + body + HELD_TODO[h["kind"]] + "." + + +def _held_moved(disposition, state, jurisdiction, loaded, held, places=()): + """A held move the record's words turn on ({kind, id, held 'moved', words, loaded}), or + None: the loaded answer is the entry a name was moved FROM, and without the moved + words the loaded data would not answer with it -- the words' held reading is another + entry's (the review of batch 1, round 3, RP-04: 'NOT GUILTY' in a composed release + reaches the approved dismissed-nolle entry while main moved the name to the held + acquittal entry).""" + moves = [d for d in held if d.get("held") == "moved" and d.get("moved_from")] + if not moves: + return None + before = _match_dispositions({"dispositions": loaded}, disposition, state, jurisdiction, places) + loaded_id = before[0]["id"] if before else None + for d in moves: + if d["moved_from"] != loaded_id: + continue + words = set(d.get("names") or []) | set(d.get("aliases") or []) + stripped = [dict(e, names=[n for n in e.get("names") or [] if n not in words], + aliases=[n for n in e.get("aliases") or [] if n not in words]) + if e.get("id") == loaded_id else e for e in loaded] + # (round 5: the moved words are blanked from the record too -- 'DEFERRED SENTENCE 6 + # MONTHS' had reached the approved entry again through its shorter alias 'deferred', + # and the Scottish deferred sentence with a plea and an admonition drafted a report) + without = disposition + for w in sorted(words, key=len, reverse=True): + ws = _disp_fold(w).split() + if ws: + without = re.sub(r"(?i)\b" + r"[^a-z0-9]+".join(re.escape(x) for x in ws) + r"\b", " ", + without) + alt = _match_dispositions({"dispositions": stripped}, without, state, jurisdiction, places) + if alt and alt[0]["id"] == loaded_id: + continue # the entry's other words answer the record anyway + hit = next((n for n in list(d.get("names") or []) + list(d.get("aliases") or []) + if _match_dispositions({"dispositions": [{"id": d["id"], "names": [n], "aliases": [], + "is_conviction": "unknown"}]}, + disposition, state, jurisdiction, places)), None) + if hit: + return {"kind": "disposition", "id": d["id"], "held": "moved", "words": hit, + "loaded": loaded_id} + return None + + +def _held_disposition(disposition, state=None, jurisdiction=None, data=None, manifest=None, + places=()): + """The held disposition entry the record's words reach ahead of every loaded + entry ({kind, id, held, words, loaded}), or None. The loaded entries and the + manifest's (their matching words only) are matched together by the step-1 + rule; the held one must come first and differ from what the loaded data + alone gives ('loaded': that entry, None when nothing loaded matched).""" + man = _held_manifest() if manifest is None else manifest + held = [d for d in (man or {}).get("dispositions") or [] + if isinstance(d, dict) and d.get("id")] + if not held or not (disposition or "").strip(): + return None + loaded = list((data or {}).get("dispositions") or []) + moved = _held_moved(disposition, state, jurisdiction, loaded, held, places) + if moved: + return moved + # the review of batch 1, round 4 (RP-04): the loaded entry that answers carries held + # acquittal-reading caveats, or a held routed row for the record's state + on_entry = [d for d in held if d.get("held") in ("caveat", "row", "mechanism")] + if on_entry: + before = _match_dispositions({"dispositions": loaded}, disposition, state, jurisdiction, + places) + loaded_id = before[0]["id"] if before else None + st = _place_key(_STATE_NAMES.get(_us_state_code((state or "").strip().lower()) or "", state or "")) + for d in on_entry: + if d["id"] != loaded_id: + continue + if d["held"] == "row" and st not in {_place_key(s) for s in d.get("states") or []}: + continue + folded = f" {_disp_fold(disposition)} " + words = next((n for n in _entry_names(before[0]) if f" {_disp_fold(n)} " in folded), + (before[0].get("names") or [d["id"]])[0]) + if d["held"] == "mechanism": + # round 5 (RP-04): the entry's held readings by mechanism ('legal error', + # 'vacated') -- only when the record names one of their words + rw = _row_words(disposition) + words = next((w for w in d.get("words") or [] if _phrase_in(w, rw)), None) + if words is None: + continue + return {"kind": "disposition", "id": d["id"], "held": d["held"], "words": words, + "loaded": loaded_id} + held = [d for d in held if d.get("held") not in ("moved", "caveat", "row", "mechanism")] + pseudo = [{"id": d["id"], "names": list(d.get("names") or []), + "aliases": list(d.get("aliases") or []), "is_conviction": "unknown", + # the review of batch 1, round 2: gated names and a country gate travel + **({"gated_names": list(d["gated_names"])} if d.get("gated_names") else {}), + **({"countries": list(d["countries"])} if d.get("countries") else {}), + "_held": d.get("held") or "entry"} for d in held] + top = _match_dispositions({"dispositions": loaded + pseudo}, disposition, state, + jurisdiction, places) + if not top or not top[0].get("_held"): + return None + rec = top[0] + before = _match_dispositions({"dispositions": loaded}, disposition, state, jurisdiction, + places) + loaded_id = before[0]["id"] if before else None + if loaded_id == rec["id"]: + return None # its held words reach an entry that is loaded anyway + + def reaches(name): + # (the review of batch 1, round 4: the one tested name alone -- the entry's gated + # names travel no further, so 'NOT PROVEN' is not reported as 'acquitted') + got = _match_dispositions({"dispositions": [dict(rec, names=[name], aliases=[], + gated_names=[])]}, + disposition, state, jurisdiction, places) + return bool(got and got[0].get("_held")) + gated = [n for g in rec.get("gated_names") or [] for n in g.get("names") or []] + words = (next((n for n in rec["names"] + rec["aliases"] if reaches(n)), None) + or next((n for n in gated if f" {_disp_fold(n)} " in f" {_disp_fold(disposition)} "), None) + or (rec["names"] + rec["aliases"] + gated + [rec["id"]])[0]) + return {"kind": "disposition", "id": rec["id"], "held": rec["_held"], "words": words, + "loaded": loaded_id} + + +def _held_pairing(offense, data=None, state=None, manifest=None): + """The held equivalency pairing the offence's words reach ahead of every + loaded pairing ({kind, id, held, words, loaded}), or None: the step-2 + matcher's score and matching-term length, the held one strictly ahead.""" + from canlex import screening as sc + man = _held_manifest() if manifest is None else manifest + held = [p for p in (man or {}).get("pairings") or [] if isinstance(p, dict) and p.get("id")] + if not held or not (offense or "").strip(): + return None + pseudo = [{"id": p["id"], "us_terms": list(p.get("us_terms") or []), + "state_terms": dict(p.get("state_terms") or {}), + "negative_terms": list(p.get("negative_terms") or []), + "_held": p.get("held") or "pairing"} for p in held] + got = sc.match_pairings(offense, pseudo, top=1, state=state) + if not got: + return None + rec = got[0][1] + text, _ = sc.expand(offense, state) + q = sc.content_tokens(text) + h_score, _, h_len = sc._best_match(text, q, rec, state) + best = sc.match_pairings(offense, list((data or {}).get("pairings") or []), top=1, + state=state) + loaded_id = best[0][1]["id"] if best else None + if best: + l_score, _, l_len = sc._best_match(text, q, best[0][1], state) + if loaded_id == rec["id"] or (l_score, l_len) >= (h_score, h_len): + return None + words = (next((t for t in rec["us_terms"] if sc.match_pairings( + offense, [dict(rec, us_terms=[t], state_terms={})], state=state)), None) + or (rec["us_terms"] + [rec["id"]])[0]) + return {"kind": "pairing", "id": rec["id"], "held": rec["_held"], "words": words, + "loaded": loaded_id} + + +def _alias_keys_read(): + """The read keys of every statute code this deployment reads, cached per + code set as one (codes, keys) pair published whole (audit follow-up + 2026-09-21: safe for a second screening thread).""" + from canlex.screening import alias_key, all_aliases + codes = frozenset(all_aliases()) + hit = _ALIAS_KEYS_READ.get("keys") + if hit is None or hit[0] != codes: + hit = (codes, frozenset(alias_key(c) for c in codes)) + _ALIAS_KEYS_READ["keys"] = hit + return hit[1] + + +def _held_alias(offense, manifest=None): + """A statute code the offence names that only a held alias reads + ({kind, code, plain}), or None.""" + from canlex.screening import alias_key, alias_pattern + man = _held_manifest() if manifest is None else manifest + aliases = (man or {}).get("aliases") or {} + if not aliases or not (offense or "").strip(): + return None + read, text = _alias_keys_read(), alias_key(offense) + for code, plain in aliases.items(): + key = alias_key(code) + if key and key not in read and alias_pattern(key).search(text): + return {"kind": "alias", "code": code, "plain": plain} + return None + + +@mcp.tool(name="canlex_us_equivalency", + annotations={"title": "Foreign Offence Equivalency (US-curated pairings)", + **_READONLY}) +def canlex_us_equivalency( + offense: Annotated[str, Field( + description="The foreign offence as charged or described, e.g. 'DUI', " + "'petty theft', 'aggravated assault', 'possession of a controlled " + "substance', 'disorderly conduct'. Statute codes are understood (US " + "codes such as 'VC 23152', 'PC 647(f)', 'VTL 1192') and drug names " + "resolve to their CDSA schedule ('sale of Xanax'). A drug amount is read " + "as written ('5,000 grams' is 5 kg: a comma followed by three digits is a " + "thousands separator; '2,5 kg' is 2.5 kg).", + min_length=2, max_length=200)], + offence_date: Annotated[Optional[str], Field( + description="Optional: the date the offence was COMMITTED " + "(YYYY-MM-DD). The maximum penalty is assessed as of that date " + "(Tran), so a pre-2018-12-18 DUI equates to the former 5-year " + "offence.", max_length=10)] = None, + status: Annotated[Literal["foreign-national", "permanent-resident"], + Field(description="Whose record it is: IRPA s. 36(2) " + "(criminality) reaches foreign nationals only.") + ] = "foreign-national", + state: Annotated[Optional[str], Field( + description="Optional US state (name or 2-letter code): some charge " + "labels mean different things by state ('assault in the third " + "degree' is a misdemeanour in New York and a felony in Washington). " + "A value that is not a US state or territory is read as a non-US " + "record: no US state term is applied.", + max_length=30)] = None, + value_over_5000: Annotated[Optional[bool], Field( + description="For theft, fraud, mischief and stolen-property matters: " + "True if the value exceeded CAD 5,000, False if not. Resolves the " + "value-dependent branch.")] = None, + country: Annotated[Optional[str], Field( + description="Optional country of the record, as a name ('United " + "States', 'Mexico', 'United Kingdom'). Omit for a US record. Any " + "other country is read as a non-US record: no US state term is " + "applied and the answer is labelled 'non-US record: curated coverage " + "is thin'.", max_length=60)] = None, +) -> str: + """Map a foreign offence to its Canadian equivalent and the resulting + IRPA s. 36 inadmissibility branch (serious criminality vs criminality vs + none), from CanLex's curated pairing table with corpus-verified maximum + penalties. The pairings were curated from US charge labels, with US + state terms where a label differs by state; a non-US record -- a + country other than the US, or a state value that is not a US state or + territory -- is matched on the generic terms only and labelled 'non-US + record: curated coverage is thin'. (The tool name keeps its 'us' prefix + so saved client configurations keep working.) + + This is step 2 of a s. 36 analysis (step 1: is the disposition a + conviction -- canlex_us_disposition; step 3: rehabilitation -- + canlex_rehabilitation, which takes this tool's max-penalty answer as + input). Covers the COMMON pairings (DUI, theft, assault, fraud, drugs, + cannabis, disorderly conduct...); anything unusual gets the Hill + methodology and an honest no-entry statement. + + Returns: + str: Markdown -- the pairing with penalty, branch, caveats and + authorities, or the Hill framework when nothing matches. + """ + return _equivalency_report(offense, offence_date, status, state, + value_over_5000, country=country) + + +_DEFAULT_ELEMENTS_QUESTION = ( + "the elements decide whether (and to which Canadian offence) this is " + "equivalent: set the foreign statute's elements beside the Canadian " + "provision's (canlex_us_equivalency prints the pairing's analysis) and " + "re-run with the answer") + + +# _resolve_pairing's inc when the caller gives none: the record's inchoate +# liability read against this pairing alone (screening.inchoate_match(offense, +# [p])). Step 2 always passes its own reading (None: no inchoate reading). +_AUTO = object() + + +def _resolve_pairing(p, offense, offence_date, status, value_over=None, + schedule_hint=None, completed=None, facts_text=None, inc=_AUTO, + convicted=None, quantity_text=None): + """The structured facts for one pairing on this record (see + _resolve_limb for the Tran, value, schedule, attempt and elements rules). + + Limbs (roadmap T1.13): when the pairing names several Canadian provisions + whose maxima have different histories (curated 'limbs'), the record's + words -- and, for the former CDSA quantity line, an amount written in it + -- select the limb (screening.select_limbs). When they select one, it is + resolved alone. When they do not, every candidate limb is resolved: if + all give the same branch the band is settled (the lowest maximum is + carried); if not, the result is 'limb-open' -- no band, each reading + listed -- like an unresolved tie. Never one reading chosen. + + Also carried: the provisions the pairing names that were NOT in force + at the commission date (roadmap DECISION (d): printed with both + readings), whether the date precedes the point-in-time floor, and the + pairing's pit_checked date. facts_text: the officer's facts, read by the + curated element resolvers (roadmap T2.10). completed / convicted: the + sentence-completion and conviction dates, which bound an offence date + that is not given (offence <= conviction <= completion; roadmap T4.1 adds + the conviction date). + + Inchoate and party liability (roadmap T2.4): `inc` is + screening.inchoate_match's reading of the record, passed by step 2 when + this pairing is the OBJECT offence matched on its own. Each of its + readings (an attempt; a conspiracy; a counselled offence that was, or was + not, committed; ...) is resolved with its Criminal Code rule applied to + the object's maximum, limb by limb. Readings that give different + branches are 'limb-open', like limbs, with an 'inchoate-role' fact in + needs; the facts answer it ('inchoate-role: not-committed'). + + Wave 2 verifier: (1) a pairing WITHOUT limbs (the approved baseline) + whose verify rows quote a maximum higher than its structured one, where + an inchoate rule puts the two in different branches, is read as two + synthetic limbs (screening.stated_limbs; open dimension 'stated') -- an + attempted burglary is 14 years for a dwelling-house and 5 otherwise, never + 5 settled. The effective pairing is f['_p']. (2) A history row whose + maximum at commission is HIGHER than the provision's now: the result + applies the lower current maximum (methodology (b)); where the maximum at + commission (Tran read strictly) gives a different branch, it is a second + reading (open dimension 'lenient'); at_commission_higher lists the rows.""" + from canlex.screening import (INCHOATE_FACT, INCHOATE_READINGS, STRUCTURED_LIMB, + inchoate_match, lenient_rows, limb_label, limb_max, + murder_held_note, not_in_force, parse_date, pit_floor, + resolver_answers, select_limbs, stated_limbs) + if inc is _AUTO: + inc = inchoate_match(offense, [p]) + is_object = bool(inc and p["id"] in (inc.get("object_pairings") or ())) + stated = [] + if is_object: + stated = stated_limbs(p, [INCHOATE_READINGS[k]["as"] for k in inc["readings"]], status) + if stated: + # the pairing-wide history belongs to the structured maximum's provision + p = dict(p, limbs=stated, + penalty_history=[h if h.get("limb") else dict(h, limb=STRUCTURED_LIMB) + for h in p.get("penalty_history") or []]) + # verification round H1 (GQ-5): the facts and the disposition are read for + # an amount the offence text does not state, or a qualifier -- the + # officer's own 'quantity: over 3 kg' never leaves a 3-kg-or-less settle + # standing. They never settle a quantity, only open it. + sel = select_limbs(p, offense, context=quantity_text) + if stated: + sel = dict(sel, open=["stated"]) + irds, answered = [None], None + if is_object: + keys = list(inc["readings"]) + said = [k for k in resolver_answers(INCHOATE_FACT, facts_text) if k in keys] + if len(keys) > 1 and len(said) == 1: + keys, answered = said, said[0] + irds = [dict(INCHOATE_READINGS[k], key=k) for k in keys] + readings = [] + for k in sel["keys"]: + for r in irds: + base = _resolve_limb(p, offense, offence_date, status, value_over, schedule_hint, + completed, k, facts_text=facts_text, inc_reading=r, + convicted=convicted) + readings.append(base) + if lenient_rows(p, k): + tran = _resolve_limb(p, offense, offence_date, status, value_over, + schedule_hint, completed, k, facts_text=facts_text, + inc_reading=r, strict=True, convicted=convicted) + if _branch_set(tran) != _branch_set(base): + base["_basis"] = "lenient" + readings.append(tran) + f = readings[0] if len(readings) == 1 else _combine_limbs(p, readings) + one = sel["keys"][0] if len(sel["keys"]) == 1 else None + at = f["_at"] + nif = not_in_force(p, at, sel["keys"]) + f.update(limb=one, limb_label=limb_label(p, one) if one else None, + limb_open=((list(sel["open"]) if len(sel["keys"]) > 1 else []) + + (["inchoate"] if len(irds) > 1 else []) + + (["lenient"] if any(r.get("_basis") for r in readings) else [])), + limb_candidates=[k for k in sel["keys"] if k] if len(sel["keys"]) > 1 else [], + quantity_kg=sel["quantity_kg"], + before_pit_floor=bool(at is not None and at < pit_floor(p)), + pit_checked=p.get("pit_checked")) + f.setdefault("limb_readings", None) + f.setdefault("if_elements_by_limb", None) + f["inchoate"] = (_inchoate_field(inc, readings, answered, f) + if irds != [None] else None) + if f["inchoate"]: + f["inchoate"]["murder_limb_held"] = bool(murder_held_note(inc, p, None)) + # the rows whose maximum at commission is higher than now, where the + # offence could fall under them (the date, or its bound, is on or before + # the row's last day; or no date is known) + dated = f["assessed_basis"] == "offence-date" + higher = [] + for k in sel["keys"]: + for h in lenient_rows(p, k): + until = parse_date(h["until"]) + if at is None or at <= until or not dated: + higher.append({"limb": k, "limb_label": limb_label(p, k) if k else None, + "until": h["until"], "at_commission": h["max_years"], + "now": limb_max(p, k), "note": h.get("note", ""), + "pit_url": h.get("pit_url")}) + f["at_commission_higher"] = higher + f["not_in_force_at_commission"] = [_nif_entry(p, e, f, status) for e in nif] + f["_sel"], f["_nif"], f["_p"] = sel, f["not_in_force_at_commission"], p + if f["branch"] == "limb-open" and "quantity" in f["limb_open"]: + # verification round G1: why the quantity is open, for the 'quantity' question + f["quantity_open"] = {"why": sel.get("quantity_why"), "forms": sel.get("forms") or []} + from canlex.screening import POSSESSION_LIMIT_QUESTION, possession_limit_open + if f["branch"] == "none" and possession_limit_open(p, offense, quantity_text): + # verification round G1 (Q6): 'no equivalent' is curated for possession within + # the limits; words that do not show the limits (an amount over them, a bound, + # an estimate) leave it DEPENDS -- no equivalent, or a hybrid offence of five + # years less a day (former CDSA s. 4(4); Cannabis Act s. 8(1)(a)/(b), 8(2)(a)) + f.update(branch="depends", max_years=None, max_penalty_lt_10=None, + depends_on="elements", equivalent=None, hybrid=True, + elements_question=POSSESSION_LIMIT_QUESTION, possession_limit_open=True) + # the provision as it now stands (s. 8) depends on the same facts + nif_now = [dict(e, **{k: dict(e[k], branch="depends") for k in ("as_now", "applied") + if (e.get(k) or {}).get("branch") == "none"}) + for e in f["not_in_force_at_commission"]] + f["not_in_force_at_commission"] = f["_nif"] = nif_now + return f + + +def _branch_set(r): + """The branches one reading can give: its own, or its periods' when the + offence date is open.""" + return frozenset([r["branch"]] + [c["branch"] for c in r.get("tran_candidates") or []]) + + +def _nif_entry(p, e, f, status): + """Both possibilities for a Canadian provision that was NOT in force at + the commission date (roadmap DECISION (d)), as data: (A) the law in + force at commission -- a curated history row for that date, where one + applies; (B) the provision as it now stands (its current maximum and + branch for this person). result_follows names what the result uses: + 'at-commission-row' (a curated row for that date), 'at-commission-rows' + (several candidate limbs, each on its own rows), 'first-maximum' (the + provision's own first maximum, applied as if it had been in force) or + 'as-now'. Neither possibility is chosen as the law; the memo prints + both (_not_in_force_line). + + Wave 2 verifier: (A) is read from the rows AS CURATED (Tran; a row + higher than now is not lowered here -- 'methodology-b' names that case, + where the result applies the lower current maximum and shows (A) as a + second reading when it gives a different branch). With several + candidate limbs, a limb with no curated row covering the date has no + computed (A): 'not_computed' names it, and the memo says its reading + uses the provision as it now stands.""" + from canlex.screening import (branch_for, inchoate_max, lenient_since, limb_label, + limb_max, limb_provision, parse_date, penalty_at) + at, limb = f["_at"], e.get("limb") or f.get("limb") + as_kind = (f.get("_inc_reading") or {}).get("as") + + def reading(mx, so, lad=False): + changed = False + if as_kind: + mx, rule = inchoate_max(as_kind, mx, so, limb_provision(p, limb), p.get("hybrid")) + changed = rule["changed"] + return {"max_years": mx, "summary_only": so, + "less_a_day": bool(lad and not changed and mx is not None), + "branch": branch_for(dict(p), mx, status, summary_only=so)} + + so_now = bool(p.get("summary_only")) + as_now = reading(limb_max(p, limb), so_now) + mx, _, row = penalty_at(p, at, limb, strict=True) + applied = at_comm = None + not_computed = [] + if f.get("limb_candidates") and not e.get("limb"): + cands = f["limb_candidates"] + uncovered = [k for k in cands if penalty_at(p, at, k, strict=True)[2] is None] + not_computed = [limb_label(p, k) for k in uncovered] + follows = "at-commission-rows" if len(uncovered) < len(cands) else "as-now" + elif row is None: + follows = "as-now" + else: + applied = reading(mx, bool(so_now or row.get("summary_only")), row.get("less_a_day")) + if row.get("from") and parse_date(row["from"]) > at: + follows = "first-maximum" + elif lenient_since(p, row, limb): + follows, at_comm = "methodology-b", applied + else: + follows, at_comm = "at-commission-row", applied + return {"provision": e["provision"], "from": e["from"], "pit_url": e.get("pit_url"), + "result_follows": follows, "at_commission": at_comm, "applied": applied, + "as_now": as_now, "not_computed": not_computed} + + +def _reading_label(p, r): + """How one reading of a pairing is named: its limb's label and/or its + inchoate reading's (roadmap T2.4) -- 'murder (C-46 s. 235); an attempt + (C-46 s. 24; punished under s. 463)'. '' for a plain pairing. A + reading of a row higher than now names its basis (methodology (b), or + the maximum at commission).""" + from canlex.screening import LENIENT_LABELS, limb_label + parts = [limb_label(p, r["_limb"])] if r.get("_limb") else [] + if r.get("_inc_reading"): + parts.append(r["_inc_reading"]["label"]) + if r.get("_basis"): + parts.append(LENIENT_LABELS[r["_basis"]]) + return "; ".join(x for x in parts if x) + + +def _inchoate_decisive(cands): + """True when the inchoate readings of the same limb and period give + different branches (the 'inchoate-role' fact decides the band).""" + groups = {} + for c in cands or []: + if c.get("inchoate_reading"): + groups.setdefault((c.get("limb"), c.get("period"), c.get("reading_basis")), + set()).add(c["branch"]) + return any(len(b) > 1 for b in groups.values()) + + +def _inchoate_field(inc, readings, answered, f): + """CANLEX_RESULT 'inchoate' (roadmap T2.4): the words, the object, and + each reading's Criminal Code rule, maximum and branch. Where the object's + pairing has several candidate limbs whose results differ, a reading's + maximum and branch are null (each provision's reading is in + limb_readings / tran_candidates).""" + by_key = {} + for r in readings: + by_key.setdefault((r.get("_inc_reading") or {}).get("key"), []).append(r) + rows = [] + for key, rs in by_key.items(): + rd = rs[0]["_inc_reading"] + rules = [r.get("_inc_rule") or {} for r in rs] + same = len({(r["max_years"], r["branch"]) for r in rs}) == 1 + secs = list(dict.fromkeys(x["section"] for x in rules if x.get("section"))) + texts = list(dict.fromkeys(x.get("rule") for x in rules)) + rows.append({"reading": key, "label": rd["label"], "as": rd["as"], + "section": " or ".join(secs) or None, + "rule": texts[0] if len(texts) == 1 else None, + "exception": any(x.get("exception") for x in rules), + "max_years": rs[0]["max_years"] if same else None, + "branch": rs[0]["branch"] if same else None, + "by_provision": not same}) + return {"kind": inc["kind"], "cue": inc["cue"], "object": inc["object"], + "readings": rows, "answered": answered, + "open": f["branch"] == "limb-open" and _inchoate_decisive(f.get("tran_candidates"))} + + +def _combine_limbs(p, readings): + """One result from the readings of several candidate limbs (the record's + words do not select one). Settled when every reading gives the same + branch (the lowest maximum is carried). When the limbs agree period by + period and only the offence date is open, 'tran-open' with one reading + per period. Otherwise 'limb-open': no band, every reading (limb, and + the period where the date is open too) in tran_candidates.""" + flat = [] + for r in readings: + lab = _reading_label(p, r) + ird = (r.get("_inc_reading") or {}).get("key") + if r["tran_candidates"]: + flat += [dict(c, limb=r["_limb"], limb_label=lab, if_elements_met_max=None, + inchoate_reading=ird, reading_basis=r.get("_basis")) + for c in r["tran_candidates"]] + else: + flat.append({"period": None, "after": None, "until": None, + "limb": r["_limb"], "limb_label": lab, "inchoate_reading": ird, + "reading_basis": r.get("_basis"), + "max_years": r["max_years"], "summary_only": r["summary_only"], + "less_a_day": r["less_a_day"], "branch": r["branch"], + "max_penalty_lt_10": r["max_penalty_lt_10"], + "if_elements_met_max": r["if_elements_met_max"]}) + f = dict(readings[0]) + bases = {r["assessed_basis"] for r in readings} + ats = {r["_at"] for r in readings} + f["_hist"], f["tran_note"] = None, "" + f["assessed_basis"] = bases.pop() if len(bases) == 1 else "undetermined" + f["_at"] = ats.pop() if len(ats) == 1 else None + f["assessed_at"] = f["_at"].isoformat() if f["_at"] else "current" + f["tran_open"] = any(r["tran_open"] for r in readings) + f["limb_readings"] = flat + f["if_elements_by_limb"] = [{"limb": c["limb"], "limb_label": c["limb_label"], + "max_years": c["if_elements_met_max"]} + for c in flat if c.get("if_elements_met_max") is not None] or None + ife = {c.get("if_elements_met_max") for c in flat} + f["if_elements_met_max"] = ife.pop() if len(ife) == 1 else None + + def lowest(cs): + mxs = [c["max_years"] for c in cs] + return min(mxs) if mxs and None not in mxs else None + if len({c["branch"] for c in flat}) == 1: + mx = lowest(flat) + f.update(branch=flat[0]["branch"], max_years=mx, + max_penalty_lt_10=None if mx is None else mx < 10, + summary_only=all(c["summary_only"] for c in flat), + less_a_day=bool(mx is not None and all( + c["less_a_day"] for c in flat if c["max_years"] == mx)), + tran_candidates=None) + return f + by_period = {} + for c in flat: + by_period.setdefault(c["period"], []).append(c) + if (None not in by_period + and all(len(g) == len(readings) and len({c["branch"] for c in g}) == 1 + for g in by_period.values())): + cands = [] + for g in by_period.values(): + mx = lowest(g) + cands.append(dict(g[0], limb=None, limb_label=None, inchoate_reading=None, + reading_basis=None, max_years=mx, + max_penalty_lt_10=None if mx is None else mx < 10)) + f.update(branch="tran-open", max_years=None, max_penalty_lt_10=None, + summary_only=False, less_a_day=False, tran_candidates=cands) + return f + f.update(branch="limb-open", max_years=None, max_penalty_lt_10=None, + summary_only=False, less_a_day=False, tran_candidates=flat, + if_elements_met_max=None) + return f + + +def _resolve_limb(p, offense, offence_date, status, value_over=None, + schedule_hint=None, completed=None, limb=None, facts_text=None, + inc_reading=None, strict=False, convicted=None): + """One reading of _resolve_pairing: the pairing read as `limb` (None for + a pairing without limbs). The structured facts for one pairing on this record: the maximum at + the offence date (Tran), with the value / schedule / attempt resolutions + applied when the record supplies them, and the branch for this person. + Returns a dict the memo and the result line share (keys starting with + '_' are internal). + + Dates are compared as dates; offence_date and completed may be date + objects or YYYY-MM-DD strings (callers validate first). With no offence + date, a sentence-completion date -- or a conviction date, the earlier of + the two (roadmap T4.1) -- bounds it (offence <= conviction <= completion, + roadmap T1.1(2)): when every maximum in force up to that date is the same, + it is applied and the result says it was inferred. + + A history row may not settle a pairing whose top-level maximum is null + because 'the elements decide' (roadmap T1.6): the branch stays + 'depends', and the history figure is exposed as if_elements_met_max. + Pairings that carry a structured maximum (probation-violation) keep + their history. + + Curated element resolvers (roadmap T2.10; held, optional): on a pairing + that depends on the elements, the officer's answer in facts_text, or the + Canadian element's own words in the record, select the provision and so + the verified maximum (screening.resolve_elements); otherwise the result + stays 'depends' and carries the resolver's question in resolver_needs. + + inc_reading (roadmap T2.4): one reading of an inchoate or party record + (screening.INCHOATE_READINGS, with its key) -- its rule is applied to the + object's maximum here (screening.inchoate_max; an express exception + keyed on this limb's curated provision governs instead). + + strict (wave 2): read a history row higher than the provision's maximum + now as curated (Tran read strictly) instead of the lower current maximum + (methodology (b), the default) -- _resolve_pairing's second reading.""" + from canlex.screening import (branch_for, expand, history_rows, inchoate_max, + limb_max, limb_provision, offence_flags, parse_date, + penalty_at, resolve_elements, tran_bound, + tran_outcomes) + od = parse_date(offence_date) + at, basis, bound_label = od, ("offence-date" if od else "current"), None + if od is None: + bound_date, bound_label = tran_bound(p, completed, convicted, limb=limb, + strict=strict) + if bound_date: + at, basis = bound_date, f"inferred-from-{bound_label}" + elif bound_label: + basis = "undetermined" + max_years, tran_note, hist = penalty_at(p, at, limb, strict=strict) + summary_only = bool(p.get("summary_only") or (hist or {}).get("summary_only")) + if_elements = None + if (hist is not None and p.get("max_years") is None + and p.get("depends_on") == "elements"): + if_elements = hist.get("max_years") + max_years = None + summary_only = bool(p.get("summary_only")) + _, schedule = expand(offense) + schedule = schedule_hint or schedule + resolved = None + if p.get("depends_on") == "schedule" and schedule and p.get("schedule_max"): + table = p["schedule_max"] + if schedule in table: + val = table[schedule] + if val is None: + resolved = ("no-offence", None) + else: + resolved = ("schedule", val) + max_years = val + elif p.get("depends_on") == "value" and value_over is not None and p.get("value_max"): + key = "over" if value_over else "under" + if key in p["value_max"]: + resolved = ("value", p["value_max"][key]) + max_years = p["value_max"][key] + rinfo = None + if (resolved is None and max_years is None and p.get("depends_on") == "elements" + and p.get("resolvers")): + rinfo = resolve_elements(p, offense, facts_text, at, + dated=(basis == "offence-date"), status=status) + if rinfo["resolved"]: + resolved = ("elements", rinfo["resolved"]["max_years"]) + max_years, if_elements = rinfo["resolved"]["max_years"], None + # inchoate and party liability (roadmap T2.4): the reading's rule applied + # to the object's maximum; an express exception keyed on the limb's + # curated provision (murder: C-46 s. 235) governs instead + as_kind = (inc_reading or {}).get("as") + attempt = as_kind == "attempt" + prov, hyb = limb_provision(p, limb), p.get("hybrid") + inc_rule = None + if as_kind: + max_years, inc_rule = inchoate_max(as_kind, max_years, summary_only, prov, hyb) + if if_elements is not None: + if_elements = inchoate_max(as_kind, if_elements, + bool((hist or {}).get("summary_only")), prov, hyb)[0] + halved = bool(inc_rule and inc_rule["changed"]) + # 'five years less a day' (former CDSA s. 5(4)/(3)(a.1)): shown as such, + # never for a halved inchoate maximum or a value/schedule resolution + less_a_day = bool((hist or {}).get("less_a_day") and max_years is not None + and not halved and resolved is None) + if resolved and resolved[0] == "no-offence": + code = "none" + else: + view = dict(p) + if resolved: + view["depends_on"] = None + code = branch_for(view, max_years, status, summary_only=summary_only) + current = (limb_max(p, limb), bool(p.get("summary_only"))) + tran_open = basis in ("current", "undetermined") and any( + (h.get("max_years"), bool(h.get("summary_only"))) != current + for _, h in history_rows(p, limb, strict)) + # A straddled maximum is reported, not chosen (verifier, 2026-09-12; + # roadmap principle 5): when the offence date is open and the periods it + # could fall in give DIFFERENT branches for this person, the result is + # 'tran-open' with no band and each period's branch -- like an unresolved + # tie. Where every period gives the same branch, the current maximum is + # used and flagged as assumed, as before. + tran_cands = None + if tran_open and resolved is None and if_elements is None and code not in ("depends", "none"): + view = dict(p) + cands = [] + for per in tran_outcomes(p, completed if basis == "undetermined" else None, + convicted if basis == "undetermined" else None, + limb=limb, strict=strict): + so = bool(p.get("summary_only") or per["summary_only"]) + mx, chg = per["max_years"], False + if as_kind: + mx, crule = inchoate_max(as_kind, mx, so, prov, hyb) + chg = crule["changed"] + cands.append({"period": per["period"], "after": per["after"], + "until": per["until"], "max_years": mx, "summary_only": so, + "less_a_day": bool(per["less_a_day"] and not chg), + "branch": branch_for(view, mx, status, summary_only=so), + "max_penalty_lt_10": None if mx is None else mx < 10}) + if len({c["branch"] for c in cands}) > 1: + tran_cands = cands + code, max_years, summary_only = "tran-open", None, False + elements_q = None + if code == "depends" and (resolved is None) and p.get("depends_on") == "elements": + elements_q = p.get("elements_question") or _DEFAULT_ELEMENTS_QUESTION + if rinfo and (rinfo["needs"] or rinfo["notes"]): + elements_q = "; ".join([n["question"] for n in rinfo["needs"]] + rinfo["notes"]) + return { + "pairing": p["id"], "branch": code, "max_years": max_years, + "max_penalty_lt_10": (None if max_years is None else max_years < 10), + "hybrid": p.get("hybrid"), "equivalent": p.get("equivalent", True), + "depends_on": (None if resolved else p.get("depends_on")), + "resolved_by": (resolved[0] if resolved else None), + "schedule": schedule, "attempt": attempt, + "summary_only": summary_only, + "assessed_at": at.isoformat() if at else "current", + "assessed_basis": basis, "tran_open": tran_open, + "tran_candidates": tran_cands, + "if_elements_met_max": if_elements, + "elements_question": elements_q, + "matcher_flags": offence_flags(offense), + "tran_note": tran_note, "less_a_day": less_a_day, + "resolver": (rinfo or {}).get("resolved"), + "resolver_needs": [] if resolved else list((rinfo or {}).get("needs") or []), + "resolver_notes": list((rinfo or {}).get("notes") or []), + "_at": at, "_hist": hist, "_bound": bound_label, "_limb": limb, + # the date the bound label names (the memo prints it) + "_bound_date": {"conviction": parse_date(convicted), + "completion": parse_date(completed)}.get(bound_label), + "_inc_reading": inc_reading, "_inc_rule": inc_rule, + "_basis": "strict" if strict else None, + } + + +def _years(m, less_a_day=False): + if m is None: + return "no single structured maximum (see the penalty line)" + from canlex.screening import LIFE + return ("life" if m >= LIFE else f"{m:g} years") + (" less a day" if less_a_day else "") + + +def _max_shown(c): + """A reading's maximum as printed: 'summary conviction only', 'life', + '5 years less a day', '10 years'.""" + if c.get("summary_only") and c.get("max_years") is None: + return "summary conviction only" + return _years(c.get("max_years"), c.get("less_a_day")) + + +def _reading(c, subject=False): + """One reading of a result that is not settled (branch 'tran-open' or + 'limb-open'): the provision it assumes and/or the period of commission. + subject=True reads as a clause ('the offence was committed ...').""" + per, lab = c.get("period"), c.get("limb_label") + if subject: + if lab and per: + return f"the offence is {lab} and was committed {per}" + return f"the offence is {lab}" if lab else f"the offence was committed {per}" + if lab and per: + return f"{lab}, committed {per}" + return lab or f"committed {per}" + + +def _open_reason(eq): + """Why a result reports several readings and no band -- the words the + step-3, next-steps and bottom-line lines share.""" + if eq.get("branch") == "tran-open": + return ("the offence date decides which maximum applied (Tran), and " + "the periods give different branches") + from canlex.screening import INCHOATE_OPEN_REASON, LENIENT_OPEN_REASON, STATED_OPEN_REASON + dims = eq.get("limb_open") or [] + inc = eq.get("inchoate") or {} + inc_why = (INCHOATE_OPEN_REASON.get(inc.get("kind")) + if "inchoate" in dims and inc.get("open") else None) + parts = [] + if "quantity" in dims and "term" not in dims: + parts.append("the quantity decides which Canadian provision applied, and " + "the record states no exact amount CanLex can settle") + elif "term" in dims: + parts.append("the record's words do not say which of the pairing's Canadian " + "provisions it is" + (", nor the quantity that decides between " + "two of them" if "quantity" in dims else "")) + if "stated" in dims: + parts.append(STATED_OPEN_REASON) + if inc_why: + parts.append(inc_why) + if "lenient" in dims: + parts.append(LENIENT_OPEN_REASON) + what = "; and ".join(parts) or ("the record's words do not say which of the pairing's " + "Canadian provisions it is") + tail = ", and the readings give different branches" + if any(c.get("period") for c in eq.get("tran_candidates") or []): + tail += "; the offence date, not given, also decides which maximum applied (Tran)" + return what + tail + + +def _history_desc(p, limb=None): + """'on or before 2018-12-17: 5 years; after 2018-12-17: 10 years' (the + rows that apply to this limb).""" + from canlex.screening import history_rows, limb_max + rows = history_rows(p, limb) + if not rows: + return "" + out = [f"on or before {u.isoformat()}: " + + ("summary conviction only" if h.get("summary_only") and h.get("max_years") is None + else _years(h.get("max_years"), h.get("less_a_day"))) for u, h in rows] + out.append(f"after {rows[-1][0].isoformat()}: " + _years(limb_max(p, limb))) + return "; ".join(out) + + +def _limb_lines(p, f): + """The step-2 lines for a record whose words select no single limb of the + pairing (roadmap T1.13): the candidate provisions, then each reading when + they give different branches -- reported, never chosen.""" + from canlex.screening import branch_text, limb_label, quantity_open_why + sel = f["_sel"] + reads = f.get("limb_readings") or [] + if (f["branch"] not in ("limb-open", "tran-open") and not f.get("if_elements_by_limb") + and len({(c["max_years"], c["summary_only"], c["less_a_day"], c["branch"]) + for c in reads}) <= 1): + # nothing separates the provisions for this record: one line, or none + if not reads or f["branch"] in ("depends", "none", "unknown"): + return [] + return [f"Maximum for this record: {_max_shown(reads[0])} -- the same whichever " + "of these provisions it is (" + + "; ".join(limb_label(p, k) for k in sel["keys"]) + ")."] + why = [] + if "term" in sel["open"]: + why.append("the record's words do not say which of them it is") + if "quantity" in sel["open"]: + # verification round G1 (Q10/Q13): the EXACT amount, never a bound or an estimate + why.append(quantity_open_why(sel.get("forms") or [], sel.get("quantity_why"))) + if "stated" in sel["open"]: + why.append("the record's words do not say which provision the conduct is, and " + "ss. 463/464 take the object offence's maximum (give the charge more " + "specifically -- the foreign statute, or the conduct -- and re-run)") + head = ("PROVISION NOT SELECTED: this pairing carries one structured maximum, its " + "lowest provision's, and its verified text quotes a higher one -- ") + else: + head = ("LIMB NOT SELECTED: this pairing names Canadian provisions whose " + "maxima have different histories -- ") + lines = [head + "; ".join(limb_label(p, k) for k in sel["keys"]) + + (" -- and " + "; ".join(why) if why else "") + "."] + if f["branch"] == "limb-open": + lines.append("They give different branches for this record, so no band " + "is chosen. Each reading (Tran: the maximum at commission " + "governs):") + lines += [f"- if {_reading(c)}: maximum {_max_shown(c)} -- " + f"{branch_text(c['branch'])}" for c in f["tran_candidates"]] + elif f["branch"] == "tran-open": + lines.append("Every provision gives the same branch period by period, so " + "only the offence date is open (below).") + elif f["branch"] not in ("depends", "none", "unknown"): + lines.append("Every reading gives the same branch for this record, so the " + "band is settled whichever provision it is (the lowest " + "maximum is carried in the result):") + lines += [f"- {c['limb_label']}: maximum {_max_shown(c)}" + for c in f["limb_readings"]] + for e in f.get("if_elements_by_limb") or []: + lines.append(f"- IF the elements make it {e['limb_label']}: per Tran the " + f"maximum at the offence date was {_years(e['max_years'])}. A " + "history row does not decide whether the foreign offence has " + "a federal equivalent at all: the branch stays DEPENDS on the " + "elements.") + return lines + + +_INC_KIND = {"attempt": "an attempt", "accessory": "an accessory's liability", + "counselling": "counselling (a solicitation or incitement)", + "conspiracy": "a conspiracy", + "party": "a party's liability (aiding, abetting, an accomplice)"} + + +def _inchoate_applied(f): + """' (C-46 s. 463(b) applied)' for a one-reading inchoate result, else ''.""" + rows = (f.get("inchoate") or {}).get("readings") or [] + return f" ({rows[0]['section']} applied)" if len(rows) == 1 and rows[0].get("section") else "" + + +def _inchoate_lines(p, f): + """The step-2 lines for a record read as inchoate or party liability + (roadmap T2.4): the words, the object offence matched on its own, and + each reading's Criminal Code rule and maximum -- several readings + reported, never chosen, where they give different branches.""" + from canlex.screening import INCHOATE_FACT, branch_text + inc = f.get("inchoate") + if not inc: + return [] + head = (f"INCHOATE OR PARTY LIABILITY: the record's words ('{inc['cue']}') make it " + f"{_INC_KIND.get(inc['kind'], inc['kind'])} of another offence. The object " + f"offence ('{inc['object']}') is matched on its own -- this pairing is the " + "object's -- and the Criminal Code sets the maximum by reference to it") + if inc.get("answered"): + head += f" (the facts give '{INCHOATE_FACT}: {inc['answered']}')" + lines = [head + ":"] + rows = inc["readings"] + if inc.get("murder_limb_held"): + from canlex.screening import murder_held_note + secs = [r["section"] for r in rows if r.get("section")] + lines.append(murder_held_note(inc, p, secs[0] if len(secs) == 1 else None)) + for r in rows: + lead = "- if " if len(rows) > 1 else "- " + if r["by_provision"] or r["rule"] is None: + lines.append(f"{lead}{r['label']}: {r['section']} -- the rule and the maximum " + "differ by provision (below)" + + ("; one of them is an EXPRESS EXCEPTION to ss. 463-465" + if r["exception"] else "") + ".") + continue + shown = ("no single structured maximum (see the penalty line)" + if r["max_years"] is None else f"maximum {_years(r['max_years'])}") + exc = " -- an EXPRESS EXCEPTION to ss. 463-465" if r["exception"] else "" + tail = (f" -- {branch_text(r['branch'])}" if len(rows) > 1 and r["branch"] else "") + lines.append(f"{lead}{r['label']}: {r['section']}, {r['rule']}{exc}; {shown}{tail}.") + if len(rows) > 1: + if inc["open"]: + lines.append("The record does not say which, and they give different branches, " + "so no band is chosen. Answer it in canlex_screen_record's facts (or " + "a canlex_screen_history entry's 'facts') as " + f"'{INCHOATE_FACT}: <{'|'.join(r['reading'] for r in rows)}>' " + "and re-run.") + elif f["branch"] not in ("depends", "none", "unknown", "tran-open", "limb-open"): + lines.append("The record does not say which, but every reading gives the same " + "branch, so the band is settled (the lowest maximum is carried in " + "the result).") + return lines + + +def _lenient_lines(f, multi=False): + """The MAXIMUM AT COMMISSION HIGHER THAN NOW lines (wave 2 verifier): each + curated row whose maximum at commission exceeds the provision's now, where + the offence can fall under it; then whether methodology (b) and the + maximum at commission (Tran read strictly) give the same branch -- or, if + not, each reading (unless _limb_lines already lists them).""" + from canlex.screening import LENIENT_NOTE, branch_text + rows = f.get("at_commission_higher") or [] + if not rows: + return [] + dated = f["assessed_basis"] == "offence-date" + lines = [LENIENT_NOTE.format( + limb=r["limb_label"] or "this pairing", + when=("at the offence date" if dated else + f"for an offence committed on or before {r['until']}"), + then=_years(r["at_commission"]), now=_years(r["now"]), note=r["note"], + pit_url=r["pit_url"] or "no point-in-time page recorded") for r in rows] + if "lenient" in (f.get("limb_open") or []): + if multi: + lines.append("The two give different branches for this record (the readings " + "above), so no band is chosen.") + else: + lines.append("The two give different branches for this record, so no band is " + "chosen. Each reading:") + lines += [f"- if {_reading(c)}: maximum {_max_shown(c)} -- " + f"{branch_text(c['branch'])}" for c in f.get("tran_candidates") or []] + elif f["branch"] not in ("depends", "none", "unknown", "tran-open", "limb-open"): + lines.append("Both give the same branch for this record, so the band is settled; the " + "lower maximum (methodology (b)) is carried in the result.") + return lines + + +def _not_in_force_line(p, n, f): + """The NOT IN FORCE AT COMMISSION note (roadmap DECISION (d)): the + Canadian provision the pairing names did not exist at the commission + date. The fact, both possibilities -- (A) the law in force at + commission, (B) the provision as it now stands -- and which one the + result below follows (n: a _nif_entry). Neither is chosen as the law.""" + from canlex.screening import branch_text + at = f["_at"] + when = "on" if f["assessed_basis"] == "offence-date" else "on or before" + a, now, follows = n["at_commission"], n["as_now"], n["result_follows"] + head = (f"NOT IN FORCE AT COMMISSION: {n['provision']} came into force on " + f"{n['from']} (its first point-in-time version: " + f"{n.get('pit_url') or 'not recorded'}), and the offence was committed " + f"{when} {at.isoformat()}, before it existed. Whether the equivalent is " + "the law in force at commission or the provision this pairing names is " + "not settled in CanLex (roadmap DECISION (d)). Both possibilities: ") + if follows == "at-commission-row": + pa = ("(A) the law in force at commission (Tran): the curated history row for " + "that date (above) reads the provision then in force as the equivalent -- " + f"maximum {_max_shown(a)} -- {branch_text(a['branch'])} (treating an earlier " + "provision as the equivalent is a curated reading, roadmap DECISION (d))") + elif follows == "methodology-b": + pa = ("(A) the law in force at commission (Tran): the curated history row for " + "that date (above) reads the provision then in force as the equivalent -- " + f"maximum {_max_shown(a)} -- {branch_text(a['branch'])}, HIGHER than the " + "provision's maximum now (treating an earlier provision as the equivalent is a " + "curated reading, roadmap DECISION (d))") + elif follows == "at-commission-rows": + pa = ("(A) the law in force at commission (Tran): the readings above apply the " + "curated history rows for that date where one covers it, which read the " + "provisions then in force as the equivalent (a curated reading, roadmap " + "DECISION (d))") + if n.get("not_computed"): + pa += ("; (A) NOT COMPUTED for " + " and ".join(n["not_computed"]) + + ": no curated row for that provision at commission -- its reading " + "above uses the provision as it now stands, which is (B)") + elif n.get("not_computed"): + pa = ("(A) the law in force at commission (Tran): NOT COMPUTED -- no curated row " + "covers that date for " + " or ".join(n["not_computed"]) + ", and CanLex " + "does not adopt predecessor provisions on its own; whether one covered the " + "conduct, and its maximum then, must be established; if none did, there is " + "no equivalent") + else: + pa = ("(A) the law in force at commission (Tran): CanLex records no provision " + "then in force for this pairing and does not adopt predecessor provisions " + "on its own -- whether one covered the conduct, and its maximum then, must " + "be established; if none did, there is no equivalent") + pb = (f"(B) {n['provision']} as it now stands: maximum {_max_shown(now)} -- " + f"{branch_text(now['branch'])}") + if follows == "methodology-b": + tail = ("The result below applies the lower maximum now (methodology (b): Canadian " + "law has since become more lenient)") + if a and a["branch"] != now["branch"]: + tail += ("; (A) and (B) give different branches for this person, so the " + "maximum at commission is shown as a second reading and no band is " + "chosen") + elif follows in ("at-commission-row", "at-commission-rows"): + tail = "The result below follows (A)" + if n.get("not_computed"): + tail += " where a curated row covers the date, and (B) for the rest" + if a and a["branch"] != now["branch"]: + tail += "; (A) and (B) give different branches for this person" + elif follows == "first-maximum": + tail = ("The result below follows neither exactly: it applies the provision's " + f"first maximum ({_max_shown(n['applied'])}), the lowest it has carried, " + "as if it had been in force; on (A) there may be no equivalent at all") + else: + tail = "The result below follows (B); on (A) there may be no equivalent at all" + return (head + pa + "; " + pb + ". " + tail + + " -- confirm which governs before relying on it.") + + +def _pairing_block(p, offense, offence_date, status, suggested=None, + value_over=None, schedule_hint=None, tied_with=None, + completed=None, tie_question=None, facts_text=None, inc=_AUTO, + convicted=None, quantity_text=None): + """One pairing rendered for the client, with its structured fields + (inc: step 2's inchoate reading, roadmap T2.4).""" + from canlex.screening import (PIT_FLOOR_NOTE, branch_text, inchoate_note, + offence_notes, parse_date, penalty_at, pit_floor) + f = _resolve_pairing(p, offense, offence_date, status, value_over, + schedule_hint, completed, facts_text=facts_text, inc=inc, + convicted=convicted, quantity_text=quantity_text) + # the effective pairing: the curated one, or -- where its verified text + # quotes a higher maximum an inchoate rule separates -- with two + # synthetic limbs (wave 2 verifier; screening.stated_limbs) + p = f["_p"] + at, hist, basis = f["_at"], f["_hist"], f["assessed_basis"] + sel = f["_sel"] + multi = len(sel["keys"]) > 1 + base_max, _, _ = penalty_at(p, at, f["limb"]) + lines = [] + if suggested: + lines.append(f"CLOSEST PAIRING BY MEANING (embedding similarity " + f"{suggested:.2f}) -- no curated term names this offence " + "exactly; confirm the foreign statute's elements fit before " + "relying on it:") + if tied_with: + lines.append(f"TWO PAIRINGS MATCH THIS LABEL EQUALLY ({p['id']} and " + f"{tied_with}): the record's own words do not say which. " + "Deciding fact: " + + (tie_question or "the fact that separates them (give it, " + "or a more specific charge)") + + ". Nothing below is chosen for you: the result carries " + "branch 'tie' and no band until it is settled.") + lines += [f"US offence: {'; '.join(p['us_terms'][:5])}", + f"Canadian equivalent: {p['canadian_offence']}", + f"Maximum penalty (verified against the corpus text): " + f"{p['penalty']}"] + # the statute's own sentence behind each verify phrase (roadmap T3.2) + from canlex.authorities import verify_lines + lines += verify_lines(p, f["limb"]) + if f["limb"] and p.get("limbs"): + how = ("selected by the record's words ('" + "', '".join(sel["terms"]) + "')" + if sel["terms"] else "the only provision the record can be here") + if sel["quantity_kg"] is not None: + how += f"; quantity read from the record: {sel['quantity_kg']:g} kg" + lines.append(f"LIMB: {f['limb_label']} -- {how}.") + lines += _inchoate_lines(p, f) + when = (f"AT THE DATE OF THE OFFENCE ({at.isoformat()})" if basis == "offence-date" + else f"OFFENCE DATE NOT GIVEN -- inferred from the {f['_bound']} date: " + f"the offence was committed on or before {at.isoformat()} " + "(offence, then conviction, then completion), and the same " + "maximum was in force on every date up to then" if at else "") + if multi: + lines += _limb_lines(p, f) + elif f["if_elements_met_max"] is not None or ( + hist is not None and f["branch"] == "depends" and p.get("depends_on") == "elements"): + shown = ("summary conviction only" if hist.get("summary_only") and hist.get("max_years") is None + else _years(f["if_elements_met_max"])) + lines.append(f"{when}, per Tran: IF the elements make it the Canadian " + f"offence, the maximum was {shown} -- {f['tran_note']}. " + "A history row does not decide whether the foreign offence " + "has a federal equivalent at all: the branch stays DEPENDS " + "on the elements.") + elif at and f["tran_note"] and not (hist or {}).get("lenient"): + shown = ("summary conviction only" if f["summary_only"] and base_max is None + else _years(base_max, bool((hist or {}).get("less_a_day")))) + lines.append(f"{when}, per Tran: maximum {shown} -- {f['tran_note']}") + elif (hist or {}).get("lenient"): + pass # _lenient_lines below + elif basis == "offence-date" and base_max is not None and not f["summary_only"]: + lines.append(f"At the date of the offence ({at.isoformat()}) the current " + f"maximum applies: {_years(base_max)}.") + elif basis.startswith("inferred-from-") and base_max is not None: + lines.append(f"{when}: the current maximum applies ({_years(base_max)}).") + # the history the periods come from: this limb's, or (limbs agreeing + # period by period) the first candidate limb's + hlimb = f["limb"] or sel["keys"][0] + if f["tran_candidates"] and f["branch"] != "limb-open": + bound = f.get("_bound_date") if basis == "undetermined" else None + lines.append("OFFENCE DATE NOT GIVEN" + + (f": the {f['_bound']} date ({bound.isoformat()}) does not " + "show which maximum applied" if bound else "") + + f" ({_history_desc(p, hlimb)}), and the periods give different " + "branches for this person, so no band is chosen. Give " + "offence_date -- per Tran the maximum at commission governs:") + for c in f["tran_candidates"]: + lines.append(f"- if committed {c['period']}: maximum {_max_shown(c)}" + + _inchoate_applied(f) + + f" -- {branch_text(c['branch'])}") + elif multi: + pass + elif basis == "undetermined": + bound = f.get("_bound_date") + lines.append(f"OFFENCE DATE NOT GIVEN: the {f['_bound']} date " + f"({bound.isoformat() if bound else '?'}) does not show which " + f"maximum applied ({_history_desc(p, hlimb)}); every period gives the " + "same branch, and the current maximum is used below. Give " + "offence_date -- per Tran the maximum at commission governs.") + elif basis == "current" and f["tran_open"]: + lines.append("No offence date given: the current maximum is used below, " + f"but it changed over time ({_history_desc(p, hlimb)}). Give " + "offence_date (Tran).") + lines += _lenient_lines(f, multi) + for e in f["_nif"]: + lines.append(_not_in_force_line(p, e, f)) + if f["before_pit_floor"]: + lines.append(PIT_FLOOR_NOTE.format( + floor=pit_floor(p).isoformat(), + date=("on " if basis == "offence-date" else "on or before ") + at.isoformat())) + if f["resolved_by"] == "schedule": + lines.append(f"Substance named in the record: CDSA Schedule {f['schedule']} " + f"-- resolved to a maximum of {f['max_years']:g} years.") + elif f["resolved_by"] == "no-offence": + lines.append(f"Substance named in the record: CDSA Schedule {f['schedule']} " + "-- NOT an offence under the section this pairing names; " + "no Canadian equivalent on these facts.") + elif f["schedule"] and p.get("depends_on") == "schedule": + lines.append(f"Substance named in the record: CDSA Schedule {f['schedule']} " + "-- read the schedule line above for the branch.") + if f["resolved_by"] == "value": + lines.append(f"Value {'over' if value_over else 'not over'} CAD 5,000 " + f"-- resolved to a maximum of {f['max_years']:g} years.") + lines.append(f"Inadmissibility branch: {p['branch']}") + if f["branch"] != "unknown": + lines.append(f"Branch for this record ({status}): {branch_text(f['branch'])}") + lines += _resolver_lines(f) + if p.get("status"): + from canlex.commentary import _STATUS_LABEL + lines.append(f"Authority status: {_STATUS_LABEL[p['status']]}") + lines += ["", p["analysis"]] + if p.get("caveats"): + lines.append("") + lines += [f"- CAVEAT: {c}" for c in p["caveats"]] + note = inchoate_note((f.get("inchoate") or {}).get("kind")) + if note: + lines.append(f"- {note}") + s37 = _s37_flags([p], None, offense) + for g, cond, s37_note, _pid in s37: + lines.append(f"- ALSO TO CONSIDER: IRPA s. {g} (organized criminality)" + + (f", {cond}" if cond else "") + + (f" -- {s37_note}" if s37_note else "") + + ". A separate ground the branch above does not decide; " + "canlex_screen_record prints its elements and consequences.") + if f["elements_question"] and not (f.get("resolver_needs") or f.get("resolver_notes")): + lines.append(f"- ELEMENTS QUESTION: {f['elements_question']}") + for n in offence_notes(offense): + lines.append(f"- NOTE: {n}") + if p.get("authorities"): + from canlex.authorities import authority_lines + lines += ["", "Authorities:"] + lines += authority_lines(p["authorities"], index_chunks=_index_chunks()) + fields = {k: v for k, v in f.items() + if k != "tran_note" and not k.startswith("_")} + fields["suggested"] = bool(suggested) + fields["tied_with"] = tied_with + if s37: + fields["also_engages"] = [g + (" if organization" if cond else "") + for g, cond, _, _ in s37] + return "\n".join(lines), fields + + +def _term_notes(data, offense, state): + """The curated term notes (us_equivalency.json 'term_notes', optional) + whose term the record names, for this state: an element split the + curated table reports instead of choosing (e.g. Texas 'assault causing + bodily injury'). Absent on the approved baseline -> [].""" + from canlex.screening import _fold, analyse + a = analyse(offense) + text = f" {a.text} " + st = (state or "").strip().lower() + names = {st, _STATE_NAMES.get(st, st)} - {""} + # a statute code the record names says whose law it is: a code read as + # California's statute reaches the California notes (verifier, 2026-09-15: + # the PC 459.5 caveat printed for an Ohio or Georgia record) + names |= {(r.get("jurisdiction") or "").lower() for r in a.reads + if r["status"] == "applied"} - {""} + out = [] + for n in (data or {}).get("term_notes") or []: + # 'aliases' (optional, held): other spellings of the same term, such as + # the Texas DPS form 'assault causes bodily injury' + terms = [_fold(t).strip() for t in [n.get("term", "")] + list(n.get("aliases") or [])] + states = {s.strip().lower() for s in n.get("states") or []} + if any(t and f" {t} " in text for t in terms) and (not states or states & names): + out.append(n) + return out + + +def _term_note_lines(notes): + return [f"ELEMENT SPLIT ({n.get('id')}; curated): {n['note']}" for n in notes] + + +def _resolver_lines(f): + """The step-2 lines of a pairing with curated element resolvers (roadmap + T2.10): the element that settled the Canadian provision, in the + statute's words with their corpus chunk -- or the deciding question with + every option and the element fragments. [] for any other pairing.""" + from canlex.screening import branch_text + lines = [] + rv = f.get("resolver") + if rv: + how = {"facts-answer": f"the facts answer '{rv['fact']}: {', '.join(rv['answers'])}'", + "offence-words": "the record's offence words ('" + "', '".join(rv["cues"]) + "')", + "facts-words": "the facts ('" + "', '".join(rv["cues"]) + "')"}.get(rv["by"], + str(rv["by"])) + lines.append(f"ELEMENT RESOLVED (curated resolver '{rv['fact']}'): {how} -> " + f"{rv['label']}: {rv['section']}, maximum {_years(rv['max_years'])} " + "(the pairing's verified maximum for that provision).") + for e in rv.get("elements") or []: + lines.append(f" The Canadian element: '{e['fragment']}' ({e['act']} " + f"s. {e['section']}, corpus chunk {e['chunk']}).") + lines.append(" Hill still governs: the foreign statute's elements, or the evidence " + "before the foreign court, must establish this element -- the answer is " + "the officer's reading of the record, not a finding.") + if rv.get("note"): + lines.append(f" NOTE: {rv['note']}.") + for n in f.get("resolver_notes") or []: + lines.append(f"- ELEMENT NOTE: {n}.") + for need in f.get("resolver_needs") or []: + lines.append(f"- DECIDING ELEMENT (curated resolver '{need['fact']}'): " + f"{need['question']}.") + for o in need["options"]: + tail = ("resolves nothing here" if o.get("max_years") is None + else f"maximum {_years(o['max_years'])} -- {branch_text(o['branch'])}") + lines.append(f" - '{o['value']}': {o['label']} -> {o.get('section') or 'none'}" + + (f" (from {o['from']})" if o.get("from") else "") + f"; {tail}") + for e in need.get("elements") or []: + lines.append(f" Canadian element ({e['act']} s. {e['section']}, chunk " + f"{e['chunk']}): '{e['fragment']}'") + lines.append(" Answer it in canlex_screen_record's facts (or a canlex_screen_history " + f"entry's 'facts') as '{need['fact']}: ' and re-run.") + return lines + + +def _equal_second(scored, tie_res): + """The second pairing when it matched at the SAME score as the first but + is_tie did not call it a tie (the longer matching term was preferred) -- + None otherwise. It is an answer the words also support, never 'not a + match' (verifier, 2026-09-12: 'solicitation of murder' demoted homicide).""" + if tie_res["tied"] or tie_res["resolution"] or len(scored) < 2: + return None + return scored[1][1] if scored[1][0] == scored[0][0] else None + + +def _other_candidate_line(scored, offense=None, offence_date=None, + status="foreign-national", completed=None, inc=_AUTO, + convicted=None): + """The second pairing, one line, never a block. Lower-scoring: not a + match (critic, 2026-09-12 -- 'sexual assault' also printed + sexual-offences-child at 0.83 as if it were an answer). Equal-scoring + but not a tie (the longer term was preferred): also matched, with its + branch, so a more serious reading is never hidden.""" + from canlex.screening import branch_text + (s1, p1), (s2, p2) = scored[0], scored[1] + if s2 < s1: + return (f"Other candidate, NOT a match: {p2['id']} ({p2['canadian_offence']}) " + f"scored {s2:.2f} against {s1:.2f} for the pairing above. Query it by " + "name only if the record's elements fit it instead.") + f = _resolve_pairing(p2, offense or "", offence_date, status, completed=completed, + inc=inc, convicted=convicted) + mx = ("" if f["max_years"] is None else f", maximum {_years(f['max_years'])}") + return (f"ALSO MATCHED AT THE SAME SCORE: {p2['id']} ({p2['canadian_offence']}) -- " + f"{branch_text(f['branch'])}{mx}. The record's words fit both " + f"pairings; {p1['id']} was preferred only because its matching term " + f"is longer, which is a reading of the words, not of the elements. " + f"Check whether the record's elements fit {p2['id']} instead (query " + "it by name).") + + +def _tie_resolved_line(res): + """One line saying which supplied fact settled a tie, and how.""" + r = res["resolution"] + line = (f"TIE RESOLVED: the record's words fit {r['chosen']} and {r['other']} " + f"equally; {r['parameter']}={'true' if r['value'] else 'false'} " + f"({r['label']}) selects {r['chosen']}.") + return line + (" " + r["caveat"] if r.get("caveat") else "") + + +def _option_label(need, p): + """The answer that selects pairing p in a tie's 'needs' entry.""" + for opt in (need or {}).get("options") or []: + if opt.get("pairing") == p["id"]: + return opt.get("label") or p["id"] + return p["id"] + + +# Keys that describe one pairing's own resolution; meaningless for a tie. +_TIE_BLANK = ("depends_on", "resolved_by", "if_elements_met_max", + "elements_question", "hybrid", "tran_candidates", "resolver", + "resolver_needs", "resolver_notes", "limb", + "limb_label", "limb_open", "limb_candidates", "limb_readings", + "if_elements_by_limb", "quantity_kg", "less_a_day", + "not_in_force_at_commission", "before_pit_floor", "pit_checked", + "at_commission_higher") + +# Branch codes with several readings and no band (screening.OPEN_BRANCHES). +_OPEN = ("tran-open", "limb-open") + + +def _tran_needs(f): + """The 'needs' entries for a result with several readings: for branch + 'limb-open', the limb (and/or the quantity) that decides, with each + provision as an option; and, where the offence date is open too (branch + 'tran-open', or limb readings carrying periods), the offence date with + each period's branch as an option. [] otherwise.""" + cands = f.get("tran_candidates") or [] + if not cands: + return [] + needs = [] + if f.get("branch") == "limb-open": + opts, seen = [], set() + for c in cands: + if c.get("limb") and c["limb"] not in seen: + seen.add(c["limb"]) + opts.append({"value": c["limb"], "label": c["limb_label"]}) + names = " or ".join(o["label"] for o in opts) + dims = f.get("limb_open") or ["term"] + if "quantity" in dims: + # verification round G1 (Q10/Q13): the EXACT amount the record states + from canlex.screening import quantity_question + qo = f.get("quantity_open") or {} + needs.append({"fact": "quantity", "parameter": None, + "question": quantity_question(names, qo.get("forms") or [], + qo.get("why")), + "options": opts}) + if "term" in dims or "stated" in dims: + needs.append({"fact": "limb", "parameter": None, + "question": ("which Canadian provision does the record " + f"describe: {names}? Its words do not say, and " + "the provisions give different branches -- give " + "the charge more specifically (the foreign " + "statute, or the conduct) and re-run"), + "options": opts}) + if "lenient" in dims: + # wave 2 verifier: a maximum at commission higher than now -- a + # question of law (methodology (b) or Tran read strictly) + from canlex.screening import LENIENT_LABELS, LENIENT_QUESTION + needs.append({"fact": "maximum-at-commission", "parameter": None, + "question": LENIENT_QUESTION, + "options": [{"value": k, "label": v} + for k, v in LENIENT_LABELS.items()]}) + if "inchoate" in dims and _inchoate_decisive(cands): + # roadmap T2.4: a counselled offence committed or not (s. 22(1) or + # s. 464); an accessory before or after the fact + from canlex.screening import INCHOATE_FACT, INCHOATE_READINGS + iopts, iseen = [], set() + for c in cands: + k = c.get("inchoate_reading") + if k and k not in iseen: + iseen.add(k) + iopts.append({"value": k, "label": INCHOATE_READINGS[k]["label"]}) + needs.append({"fact": INCHOATE_FACT, "parameter": "facts", + "question": ("which does the record show: " + + " or ".join(f"'{o['value']}' ({o['label']})" + for o in iopts) + + "? The readings give different branches -- " + f"answer in the facts as '{INCHOATE_FACT}: " + "' and re-run"), + "options": iopts}) + dated = [c for c in cands if c.get("period")] + if dated: + def what(c): + return c["period"] + (f" [{c['limb_label']}]" if c.get("limb_label") else "") + needs.append({"fact": "offence_date", "parameter": "offence_date", + "question": ("when was the offence committed? The maximum changed " + "over time (Tran: the maximum at commission governs): " + + "; ".join(f"{what(c)} -> maximum {_years(c['max_years'])}, " + f"{c['branch']}" for c in dated)), + "options": [dict({"value": c["period"], "label": what(c), + "branch": c["branch"], "max_years": c["max_years"]}, + **({"limb": c["limb"]} if c.get("limb") else {})) + for c in dated]}) + return needs + + +def _tie_fields(cands, res): + """The ONE result for a tie no supplied fact settles (roadmap T1.4): + branch 'tie', no band, each candidate with its own branch, and a 'needs' + entry naming the deciding fact -- never the first pairing's branch.""" + (p1, f1), (p2, _) = cands[0], cands[1] + f = {k: v for k, v in f1.items() if not k.startswith("_") and k != "tran_note"} + f.update({k: None for k in _TIE_BLANK}) + if f1.get("inchoate"): + # the words and the object only: each candidate's readings differ + f["inchoate"] = {k: f1["inchoate"][k] for k in ("kind", "cue", "object")} + f.update(pairing=p1["id"], branch="tie", max_years=None, max_penalty_lt_10=None, + summary_only=False, tied_with=p2["id"], tie_group=res.get("group"), + tie_candidates=[{"pairing": p["id"], "branch": x["branch"], + "max_years": x["max_years"], + "max_penalty_lt_10": x["max_penalty_lt_10"]} + for p, x in cands], + tran_open=any(x.get("tran_open") for _, x in cands), + needs=res["needs"]) + return f + + +# ---- record wording (disposition roadmap T1.6 and the critic's items) --------- +# What each flag of screening.record_order prints: (the memo line, the card +# clause). The question flags (screening.QUESTION_FLAGS) make step 1 NOT +# SETTLED -- fact-specific, both footings, no state row applied -- and name +# the deciding question (DISPOSITION_FLAG_QUESTIONS); the others say how the +# words were read. +DISPOSITION_FLAG_LINES = { + "plea-not-guilty": ( + "RECORD WORDING -- A NOT-GUILTY PLEA: 'not guilty' here is the plea entered, not a " + "verdict. It is not read as an acquittal; what the record says after the plea (a " + "verdict, a dismissal, or nothing) decides step 1.", + "a not-guilty plea (not a verdict)"), + "trial-set": ( + "RECORD WORDING -- SET FOR TRIAL: the record shows the case set for trial and no " + "outcome after it, and is read as a pending charge. (Where the record goes on to report " + "a verdict, a plea, a sentence or a dismissal, that outcome is read instead.)", + "set for trial, no outcome after it: read as a pending charge"), + "new-trial": ( + "RECORD WORDING -- NEW TRIAL ORDERED: the record says a new trial was ordered (after a " + "conviction was set aside, reversed or vacated). That is not an acquittal: the charge is " + "pending again. Step 1 reads the record from the new trial on -- a pending charge, unless " + "the record says how the new trial ended.", + "new trial ordered: the charge is pending again, not an acquittal"), + "no-disposition": ( + "RECORD WORDING -- NO DISPOSITION REPORTED: the record shows an arrest or a charge " + "with no outcome. A missing disposition is not evidence that the charge is still " + "pending: the person may have been convicted or acquitted, or the charge dismissed. " + "These words are not read as a pending charge; unless the record says more, step 1 " + "is UNKNOWN and both footings are carried. Obtain the certified court disposition.", + "no disposition reported: not a pending charge -- obtain the certified court " + "disposition"), + "adult-court": ( + "RECORD WORDING -- TRIED AS AN ADULT: the record says the matter was transferred to, " + "or tried in, adult court. The juvenile-adjudication entry is not applied; the " + "outcome words decide step 1.", + "tried in adult court: the juvenile entry is not applied"), + "refused-relief": ( + "RECORD WORDING -- RELIEF REFUSED: the record names a pardon, expungement, sealing, " + "set-aside, vacatur, programme or motion -- or an out-of-court offer such as a caution " + "or a fiscal fine -- that was denied, refused or not accepted. A refusal grants " + "nothing, so those words are not read as relief, a programme, a disposal or a " + "dismissal; the rest of the record decides step 1.", + "relief, programme or offer refused: not read as granted"), + "stayed-term": ( + "RECORD WORDING -- STAYED TERM: 'stayed' (or 'stayed pending ...') attaches to a jail " + "term or fine in the sentence, not to the charge, so it is not read as a pending " + "charge.", + "a stayed jail term or fine: not a pending charge"), + "revoked-adjudicated": ( + "RECORD WORDING -- PROGRAMME REVOKED, GUILT THEN ADJUDICATED: the record names a " + "deferral, withholding, diversion or probation programme that was revoked or " + "violated, and then an adjudication of guilt or a conviction. The later judgment is " + "read: step 1 reads the words from the adjudication on, not the programme.", + "programme revoked, then guilt adjudicated: the later judgment is read"), + "later-judgment": ( + "RECORD WORDING -- A LATER JUDGMENT: the record names a disposition (a deferral, a " + "suspension, a diversion, a caution, a dismissal) and then, later in the record and on the " + "same matter (brought back, breached, or adjudicated after the deferral), a conviction, an " + "adjudication of guilt or an acquittal. The later judgment is read: step 1 reads the words " + "from it on. If that judgment is on a different charge, screen that charge as its own entry " + "(canlex_screen_history) and this one on its own words.", + "a later judgment on the same matter: the later judgment is read"), + "split-counts": ( + "RECORD WORDING -- COUNTS WITH DIFFERENT OUTCOMES: NOT SETTLED. The record names a " + "conviction on some counts or charges and a dismissal or acquittal on others, and " + "this screening is of ONE offence. Deciding question: which count is the offence " + "entered here? A count of conviction is the conviction footing; a dismissed or " + "acquitted count is the act footing. Both footings are carried until that is " + "answered -- or screen each count as its own entry with canlex_screen_history.", + "counts with different outcomes -- which count is the offence? (both footings)"), + "sentencing-pending": ( + "RECORD WORDING -- GUILT FOUND OR PLEADED, SENTENCE NOT YET IMPOSED: NOT SETTLED. " + "Deciding question: was a judgment of conviction entered at the plea or verdict, or " + "does judgment wait for sentencing (when a deferral or a discharge may still be " + "ordered)? If judgment was entered, the conviction footing; if not, there is no " + "conviction yet (the act footing), and the record should be screened again once " + "sentence is imposed. Both footings are carried.", + "sentence not yet imposed -- was judgment entered? (both footings)"), + "post-conviction-dismissal": ( + "RECORD WORDING -- CONVICTION OR SENTENCE WORDS, THEN A DISMISSAL: NOT SETTLED. " + "Deciding question: was judgment entered before the dismissal? If no judgment was " + "entered (the plea withdrawn and the charge dismissed before judgment), the dismissal " + "entry applies (the act footing). If judgment was entered and the case was later " + "dismissed as relief after the sentence (a deferred sentence, a PC 1203.4-type " + "dismissal), the relief entries apply and the conviction may stand. Both footings " + "are carried; obtain the judgment and the dismissal order.", + "conviction words, then a dismissal -- was judgment entered before the dismissal? " + "(both footings)"), + "set-aside-ambiguous": ( + "RECORD WORDING -- CONVICTION SET ASIDE, GROUND NOT STATED: NOT SETTLED. Deciding " + "question: was the conviction set aside on appeal or for a legal error (the " + "conviction-reversed-on-appeal entry), or as rehabilitative relief after the " + "sentence (for a US record, the set-aside-1203-4 entry and its state row; for another " + "country, that country's rehabilitation law)? The two readings put the record on " + "different footings, so both are carried; obtain the order and the ground it states.", + "set aside, ground not stated -- appellate or legal-error set-aside, or " + "rehabilitative relief? (both footings)"), + "penal-order-not-final": ( + "RECORD WORDING -- PENAL ORDER NOT SHOWN FINAL: NOT SETTLED. The record names a penal " + "order (Strafbefehl) and also an objection, a setting-aside, a discontinuance, a main " + "hearing, an acquittal or that it is not final. The effect of a final judgment attaches " + "only where no objection was lodged in time ('nicht rechtzeitig Einspruch erhoben worden " + "ist', StPO s. 410(3)); while an objection is open the matter is pending, and after one " + "the court's later decision decides. Deciding question: was an objection lodged in time, " + "and how did the matter end? Both footings are carried; obtain the order, its date of " + "service and the court's record of what followed.", + "penal order not shown final -- was an objection lodged in time? (both footings)"), + "reinstituted": ( + "RECORD WORDING -- MATTER BROUGHT BACK: NOT SETTLED. The record names a dismissal, a " + "withdrawal, a decision not to charge, a suspended prosecution or a diversion, and then " + "says the matter was brought back (re-filed, re-indicted, reinstated, restored to the " + "calendar, or later charged), with no outcome after that. The earlier disposition may no " + "longer describe the matter: the charge may be pending again, or may since have ended in " + "a conviction, an acquittal or another dismissal. Deciding question: was the matter " + "brought back, and how did it end? Both footings are carried; obtain the court record of " + "the later proceeding. If the later charge is a different matter, screen it as its own " + "entry (canlex_screen_history) and this one on its own words.", + "matter brought back after it was dropped -- how did it end? (both footings)"), + "later-conviction": ( + "RECORD WORDING -- A LATER CONVICTION, MATTER NOT SHOWN: NOT SETTLED. The record names a " + "disposition and then says the person was later convicted (or pleaded guilty), but not " + "whether that conviction is on this charge -- the matter brought back, the programme " + "revoked -- or on another matter. Deciding question: is the later conviction on this charge " + "or on another matter? If it is this charge's, the conviction footing; if it is another " + "matter's, this record is read on its own disposition and the later conviction is screened " + "as its own entry (canlex_screen_history). Both footings are carried; obtain the court " + "record of the later conviction (its case or cause number).", + "a later conviction, matter not shown -- this charge or another matter? (both footings)"), + # the review of batch 1, round 3 + "relief-then-dismissal": ( + "RECORD WORDING -- A SEALING OR EXPUNGEMENT, THEN A DISMISSAL: NOT SETTLED. The record names " + "a sealing or an expungement and then a dismissal, and no conviction words. Sealing and " + "expungement follow a conviction in some regimes and a charge that ended without one in " + "others; the dismissal may be the relief's effect or the charge's own outcome. Deciding " + "question: was there a conviction before the sealing or expungement? If there was, the " + "relief entry below governs; if there was not, the charge ended without a conviction and " + "relief creates none. Both footings are carried; obtain the court record of the " + "underlying disposition.", + "a sealing or expungement, then a dismissal -- was there a conviction before it? (both " + "footings)"), + # the review of batch 1, round 4 + "appeal-allowed": ( + "RECORD WORDING -- AN APPEAL ALLOWED, OBJECT NOT STATED: NOT SETTLED. The record says an " + "appeal was allowed (succeeded, upheld) without saying whether it was against the " + "conviction or only the sentence, or how it ended. An appeal against conviction allowed " + "quashes the conviction (the conviction-reversed-on-appeal entry: an acquittal, a " + "dismissal or a new trial may follow); an appeal against sentence allowed leaves the " + "conviction standing. Deciding question: was the appeal against the conviction or the " + "sentence, and how did it end? Both footings are carried; obtain the appellate judgment.", + "an appeal allowed, object not stated -- against the conviction or the sentence, and how " + "did it end? (both footings)"), + "place-noted": ( + "RECORD WORDING -- PLACE NOT NAMED: the record's words match a disposition CanLex reads " + "only for particular states or countries, and the record names no state or country. " + "Every reading CanLex has for those places is a non-conviction, so the verdict and the " + "footing below stand; confirm the place -- from another place the same words may name " + "another disposition, or none.", + "place not named -- each reading CanLex has for the listed places is a non-conviction; " + "confirm the place."), + "place-not-named": ( + "RECORD WORDING -- PLACE NOT NAMED: NOT SETTLED. The record's words match a disposition " + "CanLex reads only for particular states or countries, and the record names no state or " + "country. Deciding question: which state or country is the record from? The entry below " + "is one reading; from another place the same words may name another disposition, or " + "none. Both footings are carried until the place is known.", + "place not named -- which state or country is the record from? (both footings)"), +} +PLACE_NOT_NAMED_WHERE = " CanLex reads these words only for a record from: {places}." +STEP1_ONE_READING = ( + "STEP 1 NOT SETTLED ON THE RECORD'S WORDING: the entry below, and any state row it " + "prints, is ONE reading of the record, not the answer. The RECORD WORDING question " + "above decides step 1; until it is answered the verdict is FACT-SPECIFIC and both " + "footings are carried.") +DISPOSITION_FLAG_QUESTIONS = { + "split-counts": "which count is the offence entered?", + "sentencing-pending": "was judgment entered?", + "post-conviction-dismissal": "was judgment entered before the dismissal?", + "set-aside-ambiguous": ("appellate or legal-error set-aside, or rehabilitative " + "relief?"), + "penal-order-not-final": "was an objection lodged in time, and how did the matter end?", + "reinstituted": "was the matter brought back, and how did it end?", + "later-conviction": "is the later conviction on this charge or on another matter?", + "relief-then-dismissal": "was there a conviction before the sealing or expungement?", + "appeal-allowed": "was the appeal against the conviction or the sentence, and how did it end?", + "place-not-named": "which state or country is the record from?", +} +# The step-1 entries each question flag answers for. When the words reach +# another entry (a named programme, relief, or a regime the curated data +# analyses), that entry decides and the flag is not raised. +_FLAG_ENTRIES = { + # (round 5: a count-scoped appeal -- one count quashed, another affirmed -- splits on the + # reversal entry too) + "split-counts": frozenset({None, "dismissed-nolle", "acquittal", "pending-charge", "ngri", + "conviction", "conviction-reversed-on-appeal"}), + "sentencing-pending": frozenset({None, "conviction", "pending-charge"}), + "post-conviction-dismissal": frozenset({"dismissed-nolle"}), + "set-aside-ambiguous": frozenset({None, "conviction", "set-aside-1203-4", + "conviction-reversed-on-appeal"}), + # the review of batch 1 (R6): a penal order the record does not show final + "penal-order-not-final": frozenset({"penal-order-germany", "penal-order"}), + # the review of batch 1: a dropped matter the record says was brought back + "reinstituted": frozenset({"dismissed-nolle", "mexico-dismissal", "korea-suspended-indictment", + "pretrial-diversion", "deferred-prosecution-agreement", "acd-cwof"}), + # the review of batch 1, round 2 (CF-15 option B): a later conviction with + # nothing saying whose matter it is -- whatever entry the earlier words reach + "later-conviction": None, + # the review of batch 1, round 3: a sealing or expungement then a dismissal, where the + # relief entry decides on a conviction reading (_step1_flags checks the verdict) + "relief-then-dismissal": frozenset({"expungement"}), + # the review of batch 1, round 4: an appeal allowed with no object -- the reversal entry + # is one reading (the appeal may have been against the sentence only) + "appeal-allowed": frozenset({None, "conviction", "conviction-reversed-on-appeal", + "conviction-under-appeal"}), + # raised by _resolve_step1 from the place gates, never from record_order + "place-not-named": frozenset(), +} +# The deferral entries a split between a deferred count and an adjudicated count +# answers for (the review of batch 1, round 2, M4: 'DEFERRED ADJUDICATION ON COUNT +# 1; COUNT 2 ADJUDICATED GUILTY'). +_SPLIT_DEFERRAL_ENTRIES = frozenset({"deferred-adjudication", "withheld-adjudication", + "suspended-imposition", "acd-cwof", "pretrial-diversion"}) + + +def _step1_flags(disposition, entry, row, verdict): + """(flags, verdict, row, question_flag) for step 1: the record-order flags + the words raise that apply to the entry reached and, when a question flag + applies, the verdict fact-specific with no state row (the row is one + reading, not the answer). A set-aside whose rehabilitative reading is + itself no / likely-no raises no question (both readings agree).""" + from canlex.screening import QUESTION_FLAGS, deferral_count_split, disposition_flags + eid = (entry or {}).get("id") + flags = [] + for f in disposition_flags(disposition): + if f in QUESTION_FLAGS: + if (_FLAG_ENTRIES[f] is not None and eid not in _FLAG_ENTRIES[f] + and not (f == "split-counts" and eid in _SPLIT_DEFERRAL_ENTRIES + and deferral_count_split(disposition))): + continue + if (f in ("set-aside-ambiguous", "relief-then-dismissal") + and eid in ("set-aside-1203-4", "expungement") + and verdict in ("no", "likely-no")): + continue + flags.append(f) + q = next((f for f in QUESTION_FLAGS if f in flags), None) + if q is None: + return flags, verdict, row, None + return flags, "fact-specific", None, q + + +def _flag_lines(flags, card=False, places=None): + """The memo lines (or card clauses) of step-1 record-wording flags; places: + the gate's places, said with the place-not-named flag.""" + out = [] + for f in flags or (): + if f not in DISPOSITION_FLAG_LINES: + continue + line = DISPOSITION_FLAG_LINES[f][1 if card else 0] + if f in ("place-not-named", "place-noted") and places: + line += PLACE_NOT_NAMED_WHERE.format(places="; ".join(places)) + out.append(line) + return out + + +# ---- the act branch on step 1's footing (disposition roadmap T1.5, T1.10) ---- +# A curated caveat (commentary.CAVEAT_CLASSES, by id) bears on one footing: the +# act-branch caveats print only when the act footing is in play, the +# conviction-branch caveat only when the conviction footing is. After an +# acquittal ('act-not-settled') no act-branch allegation, s. 44(1) skeleton or +# card report is drafted. An ENF 2 s. 3 caveat on a programme the record says +# is still running prints the concluded-trial condition instead of the +# completed-programme readings. +STEP1_FOOTING_LABEL = {"conviction": "conviction footing", "act": "act branch only", + "both": "both footings"} +ENF2_RUNNING_LINE = ( + "ENF 2 s. 3 -- THE PROGRAMME IS STILL RUNNING: the record says it has not been completed. " + "While it runs, 'the person has been charged but the trial has not concluded' -- one of the " + "scenarios in which the committing-an-act provisions 'would generally be applied' (ENF 2 s. 3, " + "enf-2-3-p7, an unofficial ATIP copy; the same list is in the annex to Bankole 2011 FC 373, " + "fc-59255-24). The policy caveat for a COMPLETED programme ('the trial is concluded and no " + "conviction results (for example, acquittal, discharge, deferral)') is reached only once it is " + "completed: re-screen then. The verdict above assesses the completed disposition.") +ACT_NOT_SETTLED_DRAFT = ( + "{prefix}Act-branch allegation NOT SETTLED after an acquittal{tag}: whether the person may be " + "found inadmissible under IRPA paragraph {para} on the same conduct is NOT SETTLED -- POLICY " + "CAVEAT ({label}), both readings in step 1. No act-branch allegation is drafted.") +# the review of batch 1, round 3 (MW-08 option B): a routed programme whose underlying +# termination is not stated (a CPL 160.50 sealing) -- never 'after an acquittal' +ACT_NOT_SETTLED_DRAFT_ROUTED = ( + "{prefix}Act-branch allegation NOT SETTLED{tag}: {question} If the underlying termination was an " + "acquittal, whether the person may be found inadmissible under IRPA paragraph {para} on the same " + "conduct is NOT SETTLED -- POLICY CAVEAT ({label}), both readings in step 1. No act-branch " + "allegation is drafted until that is answered.") +CARD_HISTORY_COUNT_ONE_READING_ROUTED = ( + " -- NOT SETTLED on one point: the count, the IRPR s. 18(2) limbs and the s. 17 clock read " + "entr(ies) {entries} as a committed act, one reading only ({question} -- if it was an acquittal, " + "whether the act branch may be used is NOT SETTLED: POLICY CAVEAT ({label}), both readings in the " + "full memo)") +CARD_HISTORY_ACT_OPEN_ROUTED = (" Entr(ies) {entries}: {question} -- if it was an acquittal, no act-branch " + "report is drafted -- POLICY CAVEAT ({label}): NOT SETTLED, both readings " + "in the full memo.") +CAVEAT_BOTTOM_LINE = { + "act": ("Act branch -- POLICY CAVEAT ({label}): whether it is used for this disposition is NOT " + "SETTLED; both readings in step 1."), + "act-not-settled": ("Act branch -- POLICY CAVEAT ({label}): after an acquittal or a finding of " + "not guilty, whether it may be used on the same conduct is NOT SETTLED; both " + "readings in step 1, and no act-branch allegation is drafted."), + "act-if-acquitted": ("Act branch -- if the charge ends in an acquittal, whether it may still be " + "used is NOT SETTLED ({label}); both readings in step 1."), + "running": ("Act branch -- ENF 2 s. 3: while the programme is still running the trial has not " + "concluded, a scenario in which the provisions 'would generally be applied'; the " + "completed-programme caveat applies only once it is completed (step 1)."), + "conviction": ("Conviction branch -- AUTHORITY CAVEAT ({label}): the record's words say the plea " + "or the act was coerced; whether the conviction must be regarded as a nullity " + "for Canadian purposes is NOT SETTLED beyond duress; both readings in step 1."), +} +ACT_NOT_SETTLED_NEXT = ( + "Removal path and s. 44 note: not printed -- the record is on the act footing alone, and after " + "an acquittal whether the act branch may be used is NOT SETTLED (POLICY CAVEAT ({label}); both " + "readings in step 1).") +BOTTOM_LINE_ACT_NOT_SETTLED = ( + "The disposition is {no} a conviction: the conviction branch (s. 36(1)(b)/(2)(b)) fails. " + "Whether the ACT branch (s. 36(1)(c)/(2)(c)) may still be used after an acquittal or a finding " + "of not guilty is NOT SETTLED -- both readings in step 1; no act-branch allegation is drafted.") +CAVEAT_NOT_IN_PLAY = ("The entry's caveat is not printed: on this record's verdict the {footing} " + "governs, and the caveat does not bear on it (canlex_us_disposition without " + "the state prints it).") +HISTORY_CAVEAT_WHERE = "in canlex_screen_record or canlex_us_disposition" +HISTORY_ACT_COUNTED = ( + "the record-level count, the IRPR s. 18(2) limbs and the s. 17 clock below read this entry as a " + "committed act -- one reading only: after an acquittal whether the act branch may be used is NOT " + "SETTLED, and on the other reading the entry grounds nothing and counts for nothing.") +CARD_HISTORY_COUNT_ONE_READING = ( + " -- NOT SETTLED on one point: the count, the IRPR s. 18(2) limbs and the s. 17 clock read " + "entr(ies) {entries} as a committed act, one reading only (POLICY CAVEAT ({label}): after an " + "acquittal whether the act branch may be used is NOT SETTLED; both readings in the full memo)") +CARD_HISTORY_ACT_OPEN = (" Entr(ies) {entries}: after an acquittal no act-branch report is drafted " + "-- POLICY CAVEAT ({label}): NOT SETTLED, both readings in the full memo.") +IF_ACT_USED = "If the act branch is used (NOT SETTLED after an acquittal): " +CARD_POLICY_CAVEAT = " POLICY CAVEAT ({label}): NOT SETTLED, both readings in the full memo." +# the review of batch 1 (R8): on BOTH footings the card names the conditional +# conviction-branch report, with the act branch's caveat as a clause, and says +# 'after an acquittal' only as the non-conviction reading +CARD_BOTH_ACT_NOT_SETTLED_ACTION = ( + "Action: NOT SETTLED -- {question}: if the disposition is a conviction, a s. 44(1) report on " + "IRPA s. {paras}; if it is not, whether an act-branch report may rest on the conduct is NOT " + "SETTLED -- POLICY CAVEAT ({label}), both readings in the full memo, and no act-branch report " + "is drafted.") +CARD_BOTH_ACT_NOT_SETTLED_WHY = ( + "{question} (both footings: the conviction branch if it is a conviction; on the " + "non-conviction reading the act branch is NOT SETTLED -- POLICY CAVEAT ({label}), both " + "readings in the full memo)") +CARD_BOTH_QUESTION = "the disposition's wording" +# the review of batch 1, round 2: a record-wording question (or a state row's) on both +# footings with no acquittal caveat -- the card names the question and each branch's +# report conditionally, never a plain report on both paragraphs +CARD_QUESTION_RESULT = "Result: NOT SETTLED -- {question} (both footings: {answer})" +CARD_QUESTION_ACTION = ( + "Action: NOT SETTLED -- {question}: if the disposition is a conviction, a s. 44(1) report on " + "IRPA s. {conv}; if it is not, {act}.") +CARD_QUESTION_ACT_CAVEAT = " -- POLICY CAVEAT ({label}): NOT SETTLED, both readings in the full memo" +CARD_QUESTION_ACT_OPEN = ("whether an act-branch s. 44(1) report may rest on the conduct is NOT SETTLED -- " + "POLICY CAVEAT ({label}), both readings in the full memo, and no act-branch " + "report is drafted") +CARD_QUESTION_NO_ACT = "no s. 36 ground on the act branch" +CARD_HISTORY_QUESTION = " Entr(ies) NOT SETTLED on the wording -- {clauses}." +CARD_HISTORY_QUESTION_ACTION = "Action: NOT SETTLED on the wording -- {clauses}." +CARD_HISTORY_QUESTION_CLAUSE = "#{n} {question}: if the disposition is a conviction, {conv}; if it is not, {act}" +# the review of batch 1, round 2: a reversal whose words say neither an acquittal nor a +# new trial -- the card asks, never asserts 'after an acquittal' +REVERSAL_QUESTION = "did the appeal end in an acquittal or dismissal, or was a new trial ordered?" +# the review of batch 1, round 3: step 1 UNKNOWN (no curated entry matches) -- the card's +# Result and Action say so and name each branch conditionally +STEP1_UNKNOWN_QUESTION = "no curated entry matches the disposition's words" +# the review of batch 1, round 4: a curated FACT-SPECIFIC entry whose texts state no +# deciding question -- the card's fallback question +STEP1_FACT_SPECIFIC_QUESTION = "is the completed disposition a conviction on its actual features?" +_REVERSAL_OUTCOME_WORDS = re.compile( + r"\b(?:acquit\w*|not guilty|dismiss\w*|absolu\w*|new trial|retrial|re-trial|remanded)\b") +# a record routed by its state row's programme to an entry that carries the +# acquittal readings from another entry (a CPL 160.50 sealing): the programme's +# question decides whether it was an acquittal at all +CARD_ROUTED_ACT_NOT_SETTLED_ACTION = ( + "Action: NOT SETTLED -- {question}: if it was an acquittal, whether an act-branch s. 44(1) " + "report may rest on the conduct is NOT SETTLED -- POLICY CAVEAT ({label}), both readings in the " + "full memo; no act-branch report is drafted until that is answered.") +CARD_ROUTED_ACT_NOT_SETTLED_WHY = ( + "{question} (on the acquittal reading the act branch is NOT SETTLED -- POLICY CAVEAT ({label}), " + "both readings in the full memo)") +CARD_ACT_NOT_SETTLED_ACTION = ( + "Action: NOT SETTLED -- POLICY CAVEAT ({label}): whether an act-branch s. 44(1) report may rest " + "on the conduct after an acquittal is NOT SETTLED, both readings in the full memo; no act-branch " + "report is drafted.") +CARD_RUNNING = ("programme still running (ENF 2 s. 3: the provisions 'would generally be applied' " + "until the trial concludes)") +CARD_DURESS = "AUTHORITY CAVEAT ({label}): coercion claimed -- NOT SETTLED, both readings in the full memo" +ACT_NOT_SETTLED_WHY = ("the act branch after an acquittal (ENF 6 s. 23.1 against the statute and " + "Magtibay; both readings in the full memo)") + + +def _footing(verdict): + """The step-1 footing a verdict puts the record on: 'conviction' (yes / + likely-yes), 'act' (no / likely-no) or 'both' (fact-specific, unknown).""" + if verdict in ("yes", "likely-yes"): + return "conviction" + if verdict in ("no", "likely-no"): + return "act" + return "both" + + +def _caveat_label(cv): + """A caveat's short source label for a clause: its curated 'source' up to + the first ' (' or ' --' ('ENF 2 s. 3', 'ENF 6 s. 23.1').""" + src = str((cv or {}).get("source") or "").strip() + case = re.match(r"([^(),]+?) v\. [^,]*?, (\d{4} [A-Z]{2,5} \d+)", src) + if case: # a decision: 'Rodriguez Anzola 2026 FCA 90' + return f"{case.group(1)} {case.group(2)}" + return re.split(r" \(| --", src, maxsplit=1)[0].strip() or "policy" + + +def _act_branch(entry, verdict, disposition): + """How step 1's answer leaves the act branch: {footing, act_branch, + caveats, label, conviction_caveat, duress}. act_branch: 'not-in-play' (the + conviction footing), 'not-settled' (an acquittal caveat), 'running' (an ENF 2 + s. 3 caveat while the record says the programme still runs), 'policy-caveat' + (the allegation is drafted with the caveat beside it) or 'open'. caveats: + the ids of the curated caveats in play on the footing; label: the source + label of the act-branch caveat that decided act_branch.""" + from canlex.commentary import caveat_class, caveat_in_play + from canlex.screening import duress_words, programme_running + footing = _footing(verdict) + items = [c for c in (entry or {}).get("policy_caveats") or [] + if isinstance(c, dict) and c.get("id")] + live = [c for c in items if caveat_in_play(c, footing)] + out = {"footing": footing, "act_branch": "not-in-play" if footing == "conviction" else "open", + "caveats": [c["id"] for c in live], "label": None, "conviction_caveat": None, + "duress": False} + conv = next((c for c in live if caveat_class(c) == "conviction"), None) + if conv is not None: + out.update(conviction_caveat=_caveat_label(conv), duress=duress_words(disposition)) + if footing == "conviction": + return out + by_class = {} + for c in live: + by_class.setdefault(caveat_class(c), c) + if "act-not-settled" in by_class: + out.update(act_branch="not-settled", label=_caveat_label(by_class["act-not-settled"])) + elif "act" in by_class and programme_running(disposition): + out.update(act_branch="running", label=_caveat_label(by_class["act"])) + elif "act" in by_class: + out.update(act_branch="policy-caveat", label=_caveat_label(by_class["act"])) + elif "act-if-acquitted" in by_class: + out.update(label=_caveat_label(by_class["act-if-acquitted"])) + return out + + +def _act_not_settled(act): + return (act or {}).get("act_branch") == "not-settled" + + +def _caveat_block(entry, act): + """Step 1's caveat lines for the record's footing: the curated caveats in + play, with the running-programme condition in place of an ENF 2 s. 3 + caveat's completed-programme readings.""" + from canlex.commentary import caveat_class, policy_caveat_lines + act = act or {} + skip = () + lines = [] + if act.get("act_branch") == "running": + skip = tuple(c.get("id") for c in (entry or {}).get("policy_caveats") or [] + if isinstance(c, dict) and caveat_class(c) == "act") + lines.append(ENF2_RUNNING_LINE) + return policy_caveat_lines(entry, footing=act.get("footing"), skip=skip) + lines + + +def _caveat_bottom_lines(entry, act): + """The bottom-line clauses for the caveats in play (CAVEAT_BOTTOM_LINE).""" + from canlex.commentary import caveat_class + act = act or {} + out = [] + if act.get("conviction_caveat") and act.get("duress"): + out.append(CAVEAT_BOTTOM_LINE["conviction"].format(label=act["conviction_caveat"])) + if act.get("act_branch") == "running": + out.append(CAVEAT_BOTTOM_LINE["running"]) + return out + seen = set() + for cv in (entry or {}).get("policy_caveats") or []: + if not (isinstance(cv, dict) and cv.get("id") in act.get("caveats", ())): + continue + cls = caveat_class(cv) + if cls == "conviction" or cls in seen: + continue + seen.add(cls) + out.append(CAVEAT_BOTTOM_LINE[cls].format(label=_caveat_label(cv))) + return out + + +def _row_tier_label(row): + """' row, ' for a governing state row, else None.""" + if not row: + return None + from canlex.commentary import row_tier + return f"{row['state']} row, {row_tier(row)}" + + +def _history_step1_clause(r): + """A history card entry's step-1 clause (disposition roadmap T1.10): + ' -> step 1 LIKELY NO [Texas row, verified] -> act branch only'.""" + if r.get("canada") or not r.get("verdict"): + return "" + mm = r.get("_row_mismatch") + tier = ("record wording leaves it open" if r.get("question_flag") else + # disposition roadmap T1.11: the row selected by mechanism + _row_mismatch_line(mm, card=True) if mm and mm["kind"] != "program" else + r.get("row_tier") or (_row_mismatch_line(mm, card=True) if mm else "no state row")) + return (f" -> step 1 {str(r['verdict']).upper().replace('-', ' ')} [{tier}]" + + _card_step1_clause(r.get("act"))) + + +def _history_step1_result(r): + """One entry's step 1 in canlex_screen_history's CANLEX_RESULT.""" + act = r.get("act") or {} + out = {"entry": r["n"], "disposition_entry": r.get("entry"), + "step1_verdict": r.get("verdict")} + if not r.get("canada"): + out.update(footing=act.get("footing"), act_branch=act.get("act_branch"), + policy_caveats=[c.get("id") for c in (r.get("_entry_obj") or {}) + .get("policy_caveats") or [] if isinstance(c, dict)]) + if r.get("_row_mismatch"): + out["row_mismatch"] = _row_mismatch_result(r["_row_mismatch"]) + return out + + +def _if_act_used(act): + """IF_ACT_USED when the record is on the act footing alone and the act + branch is NOT SETTLED after an acquittal, else ''.""" + act = act or {} + return IF_ACT_USED if _act_not_settled(act) and act.get("footing") == "act" else "" + + +def _card_step1_clause(act): + """The card's step-1 clause: the footing, and a running programme or a + coercion caveat when one applies.""" + act = act or {} + parts = [STEP1_FOOTING_LABEL.get(act.get("footing"), "")] + if act.get("act_branch") == "running": + parts.append(CARD_RUNNING) + if act.get("conviction_caveat") and act.get("duress"): + parts.append(CARD_DURESS.format(label=act["conviction_caveat"])) + return " -> " + "; ".join(p for p in parts if p) + + +def _step1(disposition, state, jurisdiction=None, places=()): + """Step 1 of the chain, shared by canlex_screen_record and + canlex_screen_history (roadmap T1.4): the disposition entry, its state + row and the verdict ('depends' reads as fact-specific). jurisdiction: + _jurisdiction()'s kind (a non-US record's relief entry, roadmap T2.8). + Inside _step_memo (the decisive-facts re-runs, roadmap T4.1) each distinct + call is computed once. 'flags': the record-wording flags (_step1_flags); + 'question_flag': the one that left step 1 NOT SETTLED, with + 'row_not_applied' the state row it set aside. places: a non-US record's + place keys for the place gates (_record_places; disposition roadmap T1.9). + 'row_mismatch': the state row selection by mechanism set the row aside or + routed to another entry (_select_row; disposition roadmap T1.11).""" + places = tuple(places or ()) + memo, key = _STEP_MEMO.get(), ("step1", disposition, state, jurisdiction, places) + if memo is not None and key in memo: + return memo[key] + out = {"available": False, "matches": [], "entry": None, "row": None, + "verdict": "unknown", "held": None, "flags": [], "question_flag": None, + "row_not_applied": None, "row_mismatch": None, "gate_places": None} + ddata = _memo_value(("dispositions",), _load_dispositions) + if ddata is not None: + out["available"] = True + matches = _match_dispositions(ddata, disposition, state, jurisdiction, places) + # wave 2 verifier: words that reach a held entry first are said + out["held"] = _held_disposition(disposition, state, jurisdiction, ddata, places=places) + # disposition roadmap T1.11: the row selected by mechanism; T1.6 flags + out.update(_resolve_step1(ddata, disposition, state, matches)) + out["conviction"] = out["verdict"] in ("yes", "likely-yes") + out["unknown"] = out["verdict"] in ("unknown", "fact-specific") + # disposition roadmap T1.5 / T1.10: the act branch on this footing + out["act"] = _act_branch(out["entry"], out["verdict"], disposition) + # the review of batch 1, round 3 (MW-08): a record routed by its state row's programme + # to an entry carrying the acquittal readings (a CPL 160.50 sealing) -- the memo draft + # line, the skeleton note and the history card ask the programme's question instead of + # saying 'after an acquittal' + mm = out.get("row_mismatch") or {} + if (out["act"].get("act_branch") == "not-settled" and mm.get("kind") == "program" + and mm.get("question")): + out["act"] = dict(out["act"], routed_question=mm["question"]) + if out.get("held"): + # the review of batch 1, round 4 (RP-04 code guard): held disposition content + # reached -- no allegation, skeleton or card report is drafted from the record + out["act"] = dict(out["act"], held=_held_id(out["held"])) + if memo is not None: + memo[key] = out + return out + + +# Roadmap T4.1: the decisive-facts re-runs call step 1 and step 2 many times +# with one input changed. Inside _step_memo() each distinct call (and each +# distinct match of an offence's words) is computed once; outside it nothing +# is cached, so every tool call computes afresh. +_STEP_MEMO = contextvars.ContextVar("canlex_step_memo", default=None) + + +@contextlib.contextmanager +def _step_memo(): + token = _STEP_MEMO.set(_STEP_MEMO.get() if _STEP_MEMO.get() is not None else {}) + try: + yield + finally: + _STEP_MEMO.reset(token) + + +def _memo_run(memo, fn, *args, **kw): + """fn(*args, **kw) with `memo` as the step memo: a tool computes its base + chain and then its decisive-facts re-runs in the same memo, so the + re-runs reuse what the base computed.""" + token = _STEP_MEMO.set(memo) + try: + return fn(*args, **kw) + finally: + _STEP_MEMO.reset(token) + + +def _memo_value(key, compute): + """compute(), once per active step memo (every time outside one).""" + memo = _STEP_MEMO.get() + if memo is None: + return compute() + if key not in memo: + memo[key] = compute() + return memo[key] + + +def _frozen(value): + """A hashable, type-tagged image of plain data (dicts, lists, tuples, + sets, scalars, dates): equal only when the data are equal and of the same + types, so True never stands for 1 nor a date for its ISO string.""" + if isinstance(value, dict): + return ("dict", tuple(sorted(((str(k), _frozen(v)) for k, v in value.items()), + key=lambda kv: kv[0]))) + if isinstance(value, (list, tuple)): + return (type(value).__name__, tuple(_frozen(v) for v in value)) + if isinstance(value, (set, frozenset)): + return ("set", tuple(sorted(repr(_frozen(v)) for v in value))) + return (type(value).__name__, value) + + +def _record_assessment(entries, today_d, person): + """rehab.assess_record(entries, today=today_d, status=person), computed + once per distinct input inside the active step memo (audit follow-up + 2026-09-21, optimization 4): a record's decisive re-runs call the record + step once per trial value, and most trials leave the record as + assess_record reads it unchanged -- on four open-facts 'deferred + sentence' entries it ran 12 times per tool call for 2 distinct inputs, + each run 256 verdict-by-class worlds. Outside a step memo nothing is + cached. The memo keeps a pristine result and every caller gets its own + deep copy, so no caller can change what another reads.""" + from canlex.rehab import assess_record + memo = _STEP_MEMO.get() + if memo is None: + return assess_record(entries, today=today_d, status=person) + key = ("assess_record", _frozen(entries), _frozen(today_d), person) + if key not in memo: + memo[key] = assess_record(copy.deepcopy(entries), today=today_d, status=person) + return copy.deepcopy(memo[key]) + + +def _step2(offense, data, state, facts, offence_date, status, completed=None, + render=False, suggest_text=None, convicted=None): + """Step 2 (see _step2_match), with the record's US state and offence + date in force for the statute aliases it reads (roadmap T3.5, + screening.record_context): each code the record names is read for that + state and date, and 'fields' carries 'aliases_read' and the offence-date + question a date-dependent code leaves open. convicted: the conviction date, + a Tran bound like completed (roadmap T4.1).""" + from canlex.screening import alias_needs, aliases_read, quantity_is_held, record_context + memo = _STEP_MEMO.get() + # quantity_is_held (verification round G1, Q4): a held quantity is part of the answer + key = ("step2", offense, id(data), state, + tuple(sorted((k, str(v)) for k, v in (facts or {}).items())), + str(offence_date), status, str(completed), str(convicted), render, suggest_text, + quantity_is_held()) + if memo is not None and key in memo: + return memo[key] + with record_context(state, offence_date, (facts or {}).get("text")): + # wave 2 verifier: words that reach content held for review first (a + # composed release's manifest) are said -- and never guessed past with + # a 'closest by meaning' suggestion + held = _held_pairing(offense, data, state) or _held_alias(offense) + if held and not held.get("loaded"): + suggest_text = None + out = _step2_match(offense, data, state, facts, offence_date, status, + completed=completed, render=render, suggest_text=suggest_text, + convicted=convicted) + out["held"] = held + if out["fields"] is not None: + reads = aliases_read(offense) + if reads: + out["fields"]["aliases_read"] = reads + extra = alias_needs(offense) + if extra: + out["fields"]["needs"] = list(out["fields"].get("needs") or []) + extra + if memo is not None: + memo[key] = out + return out + + +def _step2_match(offense, data, state, facts, offence_date, status, completed=None, + render=False, suggest_text=None, convicted=None): + """Step 2 of the chain, shared by canlex_us_equivalency, + canlex_screen_record and canlex_screen_history (roadmap T1.4), so one + input gets one answer in all three: match, settle a tie with the fact + that decides it (screening.resolve_tie), resolve each candidate at the + offence date. + + Returns {"scored", "tie", "term_notes", "candidates": [(pairing, fields)], + "blocks" (render=True), "pairing", "fields", "suggested"}. `fields` is + the one result the tool reports: the chosen pairing's, or -- for a tie no + supplied fact settles -- branch 'tie' with no band (_tie_fields). + facts: {"value_over_5000": bool|None, "schedule": str|None, "text": the + officer's facts, read by the curated element resolvers (roadmap T2.10)}. + suggest_text: when nothing matches, the text offered to the embedder for + a labelled 'closest by meaning' suggestion (None = none) -- only when the + retrieval index is already loaded (audit follow-up 2026-09-21, + optimization 12: a no-match offence loaded the whole index inside the + tool call in library and test use, 34-64 s); otherwise no suggestion, and + one stderr line says so. The served processes load the index at start. + + Inchoate and party liability (roadmap T2.4): an attempt, conspiracy, + solicitation, accessory or aiding count is matched by its OBJECT offence + (screening.inchoate_match) and each candidate is resolved with the + Criminal Code rule for that liability; "inchoate" carries the reading + (None when the words name none, or name a complete offence).""" + from canlex.screening import (inchoate_match, match_pairings, resolve_tie, + suggest_by_meaning) + facts = facts or {} + pairings = (data or {}).get("pairings", []) + # the alias reading behind the match depends on the record context + # (state, offence date, facts), so the key carries all of it + memo = _STEP_MEMO.get() + mkey = ("match", offense, state, id(pairings), str(offence_date), + (facts or {}).get("text")) + if memo is not None and mkey in memo: + inc, scored = memo[mkey] + else: + inc = inchoate_match(offense, pairings, state=state) + scored = inc["scored"] if inc else match_pairings(offense, pairings, state=state) + if memo is not None: + memo[mkey] = (inc, scored) + tnotes = _term_notes(data, offense, state) + res = resolve_tie(scored, facts, offense, state, tnotes) + out = {"scored": scored, "tie": res, "term_notes": tnotes, "candidates": [], + "blocks": [], "pairing": None, "fields": None, "suggested": None, + "inchoate": inc} + cands = [] + if scored: + cands = [res["pairing"]] + ([res["other"]] if res["tied"] else []) + elif suggest_text: + hint = None + if _INDEX is None: + print("CanLex MCP: closest-by-meaning suggestion unavailable: index not loaded", + file=sys.stderr) + else: + try: + hint = suggest_by_meaning(suggest_text, _INDEX) + except Exception: + hint = None + p = next((p for p in pairings if hint and p["id"] == hint[0]), None) + if p is not None: + cands, out["suggested"] = [p], hint[1] + question = res["needs"][0]["question"] if res["needs"] else None + value_over, schedule = facts.get("value_over_5000"), facts.get("schedule") + text = facts.get("text") + # the facts and the disposition, read for the quantity only (round H1, GQ-5) + qtext = facts.get("quantity_text") + for i, p in enumerate(cands): + if render: + block, f = _pairing_block( + p, offense, offence_date, status, suggested=out["suggested"], + value_over=value_over, schedule_hint=schedule, + tied_with=cands[1 - i]["id"] if res["tied"] else None, + completed=completed, tie_question=question, facts_text=text, inc=inc, + convicted=convicted, quantity_text=qtext) + out["blocks"].append(block) + else: + f = _resolve_pairing(p, offense, offence_date, status, value_over, + schedule, completed=completed, facts_text=text, inc=inc, + convicted=convicted, quantity_text=qtext) + f = {k: v for k, v in f.items() if not k.startswith("_") and k != "tran_note"} + f["suggested"] = bool(out["suggested"]) + out["candidates"].append((p, f)) + if cands: + out["pairing"] = cands[0] + if res["tied"]: + fields = _tie_fields(out["candidates"], res) + for _, f in out["candidates"]: + if f.get("tran_candidates"): + fields["needs"] = list(fields["needs"]) + _tran_needs(f) + break + else: + fields = dict(out["candidates"][0][1]) + fields.update(tied_with=None, tie_group=res["group"], tie_candidates=None, + needs=(_tran_needs(fields) + list(fields.get("resolver_needs") or []) + + _possession_limit_needs(fields))) + second = _equal_second(scored, res) + fields["also_matched"] = [second["id"]] if second else [] + fields["tie_resolved_by"] = (res["resolution"] or {}).get("parameter") + fields["term_notes"] = [n.get("id") for n in tnotes] + out["fields"] = fields + return out + + +def _possession_limit_needs(f): + """The question a possession record leaves open when its words do not show + the Canadian limits (verification round G1, Q6; screening.possession_limit_open): + one need, answered by the officer's facts -- CanLex does not settle it.""" + if not f.get("possession_limit_open"): + return [] + from canlex.screening import POSSESSION_LIMIT_QUESTION + return [{"fact": "possession-limit", "parameter": None, + "question": POSSESSION_LIMIT_QUESTION, "options": []}] + + +def _element_readings(offense, data, state, facts, offence_date, status, completed=None, + convicted=None): + """Each limb of an element-split statute code the record names and the + facts do not answer (screening status 'element-open'; wave-3 verifier, + 2026-09-15: UCMJ Art. 89(b), the former Art. 90(1) and Art. 91(1) are + assaults on a superior, the other limbs service offences): step 2 run + once per limb with that answer, so the memo prints what each gives and + chooses none (principle 5). + [{code, fact, answer, label, plain, pairing, tied_with, branch}].""" + from canlex.screening import aliases_read + facts = dict(facts or {}) + out = [] + for r in aliases_read(offense, state, offence_date, facts.get("text")): + if r["status"] != "element-open": + continue + for x in r["limbs"] or []: + answer = f"{r['fact']}: {x['answer']}" + text = f"{facts['text']}; {answer}" if facts.get("text") else answer + s2 = _step2(offense, data, state, dict(facts, text=text), offence_date, + status, completed=completed, convicted=convicted) + f = s2["fields"] or {} + out.append({"code": r["key"], "fact": r["fact"], "answer": x["answer"], + "label": x["label"], "plain": x["plain"], + "pairing": f.get("pairing"), "tied_with": f.get("tied_with"), + "branch": f.get("branch", "unknown")}) + return out + + +def _element_reading_lines(readings): + """The EACH READING block of an element-split code (_element_readings).""" + from canlex.screening import branch_text + if not readings: + return [] + lines = ["EACH READING OF THE STATUTE CODE -- the specification decides, and " + "nothing is chosen for you:"] + for x in readings: + if x["branch"] == "tie": + got = (f"{x['pairing']} or {x['tied_with']} (two pairings match equally; " + "the band is not settled)") + elif x["pairing"]: + got = f"{x['pairing']} -- {branch_text(x['branch'])}" + else: + got = "no curated pairing" + lines.append(f"- if {x['label']} (answer '{x['fact']}: {x['answer']}'): " + f"'{x['plain']}' -> {got}") + return lines + + +def _equivalency_report(offense, offence_date=None, status="foreign-national", + state=None, value_over=None, country=None): + """Step 2 of the screening chain: the report canlex_us_equivalency + renders. A string naming several offences ('DUI; possession of + cocaine') is screened offence by offence. A non-US record (roadmap T2.9: + a country other than the US, or a state value that is not a US state or + territory) is matched without US state terms and labelled.""" + from canlex.commentary import BANNER + from canlex.screening import (alias_needs, aliases_read, offence_flags, + offence_notes, parse_date, plain_offence, + quantity_held, result_line, split_offences) + try: + od = parse_date(offence_date) + except ValueError as exc: + return f"Error: dates must be YYYY-MM-DD ({exc})." + offence_date = od.isoformat() if od else None + data = _load_equivalency() + if data is None: + return ("The equivalency dataset is not present in this deployment. " + "Apply Hill v Canada (MEI) (1987), 73 NR 315 (FCA): compare " + "essential elements, or the evidence of what was done, or " + "both; remember IRPA s. 36(3)(a) hybrid deeming.") + j = _jurisdiction(state, country) + state = j["state"] # never a foreign region against US state terms + pairings = data.get("pairings", []) + by_id = {p["id"]: p for p in pairings} + parts = [BANNER] + j["notes"] + results, shown, element_readings = [], 0, [] + from canlex import readnext + read_next, held_ids = [], [] + offences = split_offences(offense, pairings, state) + # verification round G1 (Q4): a part's amount settles a quantity limb only when + # every OTHER part is a plain offence of its own -- 'sale of marijuana 2 kg and + # over' splits into '... 2 kg' and 'over', and the 2 kg is not an exact amount + plain = ([plain_offence(x, pairings, state) for x in offences] + if len(offences) > 1 else [True]) + for n_part, part in enumerate(offences): + hold = not all(ok for i, ok in enumerate(plain) if i != n_part) + if len(offences) > 1: + parts.append(f"## {part}") + # A code whose reading the offence date or the specification decides + # is shown with its readings, never with a 'closest by meaning' guess. + held_open = any(r["status"] in ("date-open", "element-open") + for r in aliases_read(part, state, offence_date)) + # One step-2 rule for all three callers (roadmap T1.4): a tie the + # value settles is settled here too; one it does not stays a tie. + with quantity_held(hold): + s2 = _step2(part, data, state, {"value_over_5000": value_over}, + offence_date, status, render=True, + suggest_text=None if held_open else part) + held_lines = [_held_note(s2["held"])] if s2.get("held") else [] + if s2.get("held"): + held_ids.append(_held_id(s2["held"])) + if s2["scored"]: + res = s2["tie"] + if res["resolution"]: + parts.append(_tie_resolved_line(res)) + parts += s2["blocks"] + if not res["tied"] and not res["resolution"] and len(s2["scored"]) > 1: + parts.append(_other_candidate_line(s2["scored"], part, offence_date, + status, inc=s2["inchoate"])) + parts += _term_note_lines(s2["term_notes"]) + parts += held_lines + # roadmap T3.6: what the matched pairing(s) name to read next + rn_items = _pairing_read_next_items([p for p, _ in s2["candidates"]], data) + rn = readnext.lines(rn_items, _index_chunks()) + if rn: + parts.append("\n".join(rn)) + read_next += [i for i in readnext.chunk_ids(rn_items, _index_chunks()) + if i not in read_next] + results.append(s2["fields"]) + shown += len(s2["candidates"]) + (1 if s2["fields"].get("also_matched") else 0) + continue + parts += [f"NOTE: {n}" for n in offence_notes(part, state, offence_date)] + parts += held_lines + with quantity_held(hold): + er = (_element_readings(part, data, state, {"value_over_5000": value_over}, + offence_date, status) if held_open else []) + if s2["candidates"]: + parts += s2["blocks"] + results.append(s2["fields"]) + shown += 1 + elif er: + parts += _element_reading_lines(er) + element_readings += er + elif held_open: + parts.append("Nothing is applied until the offence date is given: the " + "statute code names a different offence by date (NOTE above).") + else: + parts.append( + f"No curated pairing names '{part}', and nothing in the " + "table is close enough by meaning to suggest. Apply the " + "Hill framework (below) to the actual foreign statute -- " + "compare essential elements, or the evidence of what was " + "done, or both -- and read the Canadian provision with " + "canlex_get_section. Remember the class of foreign offences with " + "NO federal equivalent in Canada (provincial-law matters such " + "as trespass, public intoxication, open container, most " + "traffic offences): those ground no s. 36 inadmissibility on " + "the conviction branch.") + if not read_next: + # no pairing named anything to read: the framework's read-next (T3.6) + fw_items = readnext.items(_framework(data, "equivalency-framework")) + rn = readnext.lines(fw_items, _index_chunks()) + if rn: + parts.append("\n".join(rn)) + read_next = readnext.chunk_ids(fw_items, _index_chunks()) + for m in data.get("methodology", []): + if m["id"] == "equivalency-framework": + parts.append("---\n\nGoverning framework:\n\n" + m["text"]) + break + parts.append("Retrieve the Canadian provisions with canlex_get_section " + "to verify elements and penalties; feed the max-penalty " + "answer into canlex_rehabilitation for step 3 (or run " + "canlex_screen_record for the whole chain).") + if any("\nAuthorities:\n" in x for x in parts): + from canlex.authorities import read_note + parts.append(read_note()) + first = results[0] if results else {"pairing": None, "branch": "unknown", + "max_years": None, + "max_penalty_lt_10": None, + "matcher_flags": offence_flags(offense, state, offence_date)} + if not results: + # roadmap T3.5: the codes the record named, as read, and the offence + # date a date-dependent code needs (only when there are any) + reads = aliases_read(offense, state, offence_date) + extra = alias_needs(offense, state, offence_date) + if reads: + first["aliases_read"] = reads + if extra: + first["needs"] = extra + if element_readings: + first["element_readings"] = element_readings + # this tool has no facts parameter: an element question (roadmap T2.10) + # is answered in canlex_screen_record's facts or a screen_history entry's + if first.get("needs"): + first = dict(first, needs=[dict(n, parameter=None, tool="canlex_screen_record") + if n.get("parameter") == "facts" else n + for n in first["needs"]]) + if read_next: + first = dict(first, read_next=read_next) + if held_ids: + first = dict(first, held=held_ids, assumed=["held-content"]) + # roadmap T4.2: what each pairing the answer rests on carries + from canlex.screening import confidence_block, step2_confidence + conf2 = [] + for f in results: + for pid in [f.get("pairing")] + ([f["tied_with"]] if f.get("tied_with") else []): + if pid in by_id: + conf2.append(step2_confidence(by_id[pid], f if not f.get("tied_with") else None)) + first = dict(first, confidence=confidence_block(step2=conf2)) + parts.append(result_line(tool="canlex_us_equivalency", + matches=shown, offences=len(offences), + jurisdiction=j["kind"], **first)) + return _with_superseded("\n\n".join(parts)) + + +@mcp.tool(name="canlex_rehabilitation", + annotations={"title": "Criminal Rehabilitation Assessment", **_READONLY}) +def canlex_rehabilitation( + basis: Annotated[Literal["foreign-conviction", "foreign-act", + "canada-conviction"], Field( + description="How the inadmissibility arises: 'foreign-conviction' " + "(IRPA s. 36(1)(b)/(2)(b)), 'foreign-act' (s. 36(1)(c)/(2)(c)), or " + "'canada-conviction' (s. 36(1)(a)/(2)(a)).")], + max_penalty_lt_10: Annotated[bool, Field( + description="True if the equivalent Canadian offence's maximum term " + "of imprisonment is LESS THAN 10 years (remember IRPA s. 36(3)(a): " + "hybrid offences count as indictable at their indictable maximum).")], + count: Annotated[Literal["one", "two-plus-summary-only", "multiple"], Field( + description="'one' (a single conviction or act), " + "'two-plus-summary-only' (two or more, all summary-equivalent -- for " + "a Canadian conviction, offences that MAY ONLY be prosecuted " + "summarily: say so with canada_offence_mode), or 'multiple' (more " + "than one indictable-equivalent matter).")], + clean_record: Annotated[bool, Field( + description="True if the person has no other convictions or " + "committed acts anywhere (the IRPR s. 18(2)(i)-(vii) conditions, " + "simplified -- the answer carries a caveat to confirm in detail).")], + sentence_completed: Annotated[Optional[str], Field( + description="PREFERRED: the date (YYYY-MM-DD) the ENTIRE sentence " + "was completed -- probation, fines and driving prohibitions " + "included -- for conviction bases, or the date the act was committed " + "for the act basis. The tool computes the elapsed time and the " + "earliest eligibility dates itself.", max_length=10)] = None, + years_elapsed: Annotated[Optional[int], Field( + ge=0, le=100, + description="Fallback when no date is known: full years since the " + "day after sentence completion (or since the act). Ignored when " + "sentence_completed is given.")] = None, + status: Annotated[Literal["foreign-national", "permanent-resident"], + Field(description="Whose record it is. IRPA s. 36(2) " + "(criminality) reaches foreign nationals only, so " + "a sub-10-year matter grounds nothing against a " + "permanent resident.")] = "foreign-national", + today: Annotated[Optional[str], Field( + description="Optional assessment date (YYYY-MM-DD); defaults to the " + "server's UTC date -- give today= for the port of entry's date.", + max_length=10)] = None, + canada_offence_mode: Annotated[ + Optional[Literal["summary-only", "hybrid", "indictable"]], Field( + description="basis 'canada-conviction' only: how the Canadian " + "offence may be prosecuted. 'summary-only' = the offence MAY ONLY " + "be prosecuted summarily; a hybrid offence the Crown prosecuted " + "summarily is still 'hybrid' -- IRPA s. 36(3)(a) deems it " + "indictable 'even if it has been prosecuted summarily'. Decides " + "IRPR s. 18.1 (two or more summary-only convictions, foreign " + "nationals only) against the record-suspension route under " + "s. 36(2)(a). Omit when unknown: the answer then prints the " + "condition it assumed.")] = None, + custody_imposed_months: Annotated[Optional[float], Field( + ge=0, le=1200, + description="basis 'canada-conviction' only: the term of " + "imprisonment imposed for the offence, in months (0 = no custody). A " + "conditional sentence is not a term of imprisonment (Tran, 2017 SCC " + "50). IRPA s. 36(1)(a) needs MORE than six months (exactly six does " + "not); IRPA s. 64(2), the bar on a permanent resident's appeal to the " + "IAD, needs AT LEAST six. Omit when unknown: the answer then prints " + "the condition it assumed.")] = None, +) -> str: + """Assess whether a person is deemed rehabilitated, eligible to apply for + individual rehabilitation, or neither, under IRPA s. 36(3)(c) and IRPR + ss. 17-18.1 -- the time-based cures for criminal inadmissibility -- or, + for a Canadian conviction, which of IRPA s. 36(1)(a), s. 36(2)(a) and + IRPR s. 18.1 applies. + + Rules verified against the current IRPR consolidation: deemed + rehabilitation (automatic) for a single <10-year-maximum foreign + conviction after 10 clean years, two-plus summary-only convictions after + 5 years, or a single committed act after 10 years; individual + rehabilitation applications after 5 years (available even for serious + criminality, which can never be deemed). A Canadian conviction is never + rehabilitated: it is serious criminality under s. 36(1)(a) for anyone + when the maximum is 10 years or more or MORE than six months' custody was + imposed; otherwise it reaches only a foreign national, under s. 36(2)(a) + (indictable or hybrid) or, for two or more offences that may only be + prosecuted summarily, the IRPR s. 18.1 exemption; the cure is a record + suspension. The canlex://methodology/rehabilitation resource sets out the + clocks and rules with their corpus sources. This is step 3 of a s. 36 + screening: step 1 is whether the disposition is a conviction + (canlex_us_disposition), step 2 is offence equivalency (which also + supplies the max-penalty input here). + + Returns: + str: The assessment with the governing provision, any CONDITION it + assumed, explanation and mandatory caveats. Rule logic from the + statute text -- verify against the cited provisions via + canlex_get_section. + """ + return _rehab_report(basis, max_penalty_lt_10, count, clean_record, + sentence_completed, years_elapsed, status, today, + canada_offence_mode=canada_offence_mode, + custody_imposed_months=custody_imposed_months) + + +def _elapsed_line(r, basis, completed, today_d): + """The Elapsed line, on the clock of the provision reported (roadmap + T1.12): IRPR s. 17 counts from the completion (or commission) itself, + s. 18(2) and s. 18.1 from the day after.""" + n = r["elapsed"] + if r.get("clock") == "s17": + what = ("committing the offence" if basis == "foreign-act" + else "the completion of the sentence") + line = (f"Elapsed: {n} full years from {completed.isoformat()} itself " + f"to {today_d.isoformat()} (the IRPR s. 17 clock: five years " + f"after {what})") + if r.get("earliest_deemed"): + line += (f"; {r['elapsed_by_clock']['day-after']} full years from " + "the day after, on the s. 18(2) deeming clock") + return line + which = "s. 18.1" if r["provision"].startswith("IRPR s. 18.1") else "s. 18(2)" + return (f"Elapsed: {n} full years from {completed.isoformat()} (day after) " + f"to {today_d.isoformat()} (the IRPR {which} clock, which counts " + "from the day after)") + + +def _rehab_report(basis, max_penalty_lt_10, count, clean_record, + sentence_completed=None, years_elapsed=None, + status="foreign-national", today=None, embed=False, + canada_offence_mode=None, custody_imposed_months=None): + """Step 3 of the screening chain, shared with canlex_screen_record. + embed=True drops the banner, the pointer and the result line so the memo + carries one banner and one CANLEX_RESULT; the rehabilitation fields are + returned on the memo's own line instead.""" + import datetime as _dt + from canlex.rehab import BASES, CANADA_MODES, COUNTS, STATUSES, assess + from canlex.commentary import BANNER + from canlex.screening import parse_date, result_line + if basis not in BASES: + return f"Error: basis must be one of {', '.join(BASES)} (got '{basis}')." + if count not in COUNTS: + return f"Error: count must be one of {', '.join(COUNTS)} (got '{count}')." + if status not in STATUSES: + return f"Error: status must be one of {', '.join(STATUSES)} (got '{status}')." + if canada_offence_mode is not None and canada_offence_mode not in CANADA_MODES: + return (f"Error: canada_offence_mode must be one of " + f"{', '.join(CANADA_MODES)} (got '{canada_offence_mode}').") + custody = None + if custody_imposed_months is not None: + try: + custody = float(custody_imposed_months) + except (TypeError, ValueError): + custody = -1.0 + if not custody >= 0: + return ("Error: custody_imposed_months must be a number of months, " + f"0 or more (got '{custody_imposed_months}').") + notes = [] + if basis != "canada-conviction" and (canada_offence_mode is not None + or custody is not None): + notes.append("canada_offence_mode and custody_imposed_months apply " + "only to basis 'canada-conviction' and were ignored.") + canada_offence_mode, custody = None, None + max_penalty_lt_10 = _as_bool(max_penalty_lt_10) + clean_record = _as_bool(clean_record) + try: + completed = parse_date(sentence_completed) + today_d = parse_date(today) + except ValueError as exc: + return f"Error: dates must be YYYY-MM-DD ({exc})." + # A conviction in Canada is never rehabilitated by time (s. 36(3)(c) reaches only + # the foreign grounds): the date feeds nothing unless the IRPR s. 18.1 five-year + # clock (count 'two-plus-summary-only') is in play -- so it is not demanded + # otherwise (the code audit of 2026-09-21: the tool refused to answer the + # s. 36(1)(a) / (2)(a) question without a date it did not use). + date_needed = basis != "canada-conviction" or count == "two-plus-summary-only" + if completed is None and years_elapsed is None and date_needed: + return ("Error: give sentence_completed (YYYY-MM-DD, preferred) or " + "years_elapsed.") + if completed is None and years_elapsed is None: + notes.append("No completion date was given, and none is needed for this " + "basis and count: a conviction in Canada is not rehabilitated " + "by the passage of time (IRPA s. 36(3)(c)); the record " + "suspension is the cure.") + r = assess(basis, max_penalty_lt_10, count, years_elapsed or 0, + clean_record, status=status, completed=completed, + today=today_d, canada_offence_mode=canada_offence_mode, + custody_imposed_months=custody) + lines = [] if embed else [BANNER, ""] + lines += [f"REHABILITATION STATUS: {r['status'].upper().replace('-', ' ')}", + f"Governing provision(s): {r['provision']}"] + lines += [f"CONDITION: {c}" for c in r.get("conditions", [])] + lines += [f"NOTE: {n}" for n in notes] + # Embedded in a memo, the memo prints the date warnings once at the top + # (the same dates; verifier 2026-09-12: they were printed twice). + for w in ([] if embed else r.get("warnings", [])): + lines.append(f"WARNING: {w}.") + if completed is not None and r.get("clock"): + lines.append(_elapsed_line(r, basis, completed, + today_d or _dt.date.today())) + if r.get("earliest_deemed"): + label = ("Earliest date the s. 18.1 exemption applies" + if r["provision"].startswith("IRPR s. 18.1") + else "Earliest date deemed rehabilitation can be made out") + lines.append(f"{label}: {r['earliest_deemed']}") + if r.get("earliest_apply"): + lines.append(f"Earliest date an application for rehabilitation can " + f"be made: {r['earliest_apply']}") + if r.get("appeal_note"): + lines.append(f"Appeal to the IAD (IRPA s. 64): {r['appeal_note']}") + lines += ["", r["explanation"], "", "Caveats:"] + lines += [f"- {c}" for c in r["caveats"]] + if embed: + return "\n".join(lines), r + lines.append("") + lines.append("Retrieve the provisions with canlex_get_section (act " + "'IRPR', sections '17'/'18'/'18.1'; act 'IRPA', sections " + "'36'/'64'). The canlex://methodology/rehabilitation resource " + "sets out the two clocks and the rules with their sources.") + lines.append("") + extra = ({"canada_offence_mode": canada_offence_mode, + "custody_imposed_months": custody, + "iad_appeal": r.get("iad_appeal")} + if basis == "canada-conviction" else {}) + from canlex.screening import confidence_block, step3_confidence + extra["confidence"] = confidence_block(step3=step3_confidence()) + lines.append(result_line(tool="canlex_rehabilitation", status=r["status"], + provision=r["provision"], elapsed=r["elapsed"], + clock=r.get("clock"), + earliest_deemed=r.get("earliest_deemed"), + earliest_apply=r.get("earliest_apply"), + basis=basis, person=status, count=count, + needs=r.get("needs", []), + warnings=r.get("warnings", []), **extra)) + return "\n".join(lines) + + +def _as_bool(value): + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "y") + return bool(value) + + +_SECTION_TEXT_CACHE = {} + + +def _section_text(act_code, section): + """The corpus text of one section -- every English piece, in corpus + order -- or '' when it is absent. Read from the processed file so the + memo's next-steps block can quote a fee from the Regulations as they + stand rather than a number typed into code; reading every piece means a + re-chunked section (s. 298 is already two pieces) cannot silently drop + the amount. The file is parsed once per modification time (audit + follow-up 2026-09-21, optimization 1: every screening memo re-read the + 1.16 MB IRPR file three times for its fee lines); a file that cannot be + read or parsed is never cached and reads as ''.""" + from canlex.config import DATA_DIR + path = DATA_DIR / "processed" / f"{act_code}.json" + try: + stamp = path.stat().st_mtime_ns + except OSError: + return "" + hit = _SECTION_TEXT_CACHE.get(str(path)) + if hit is None or hit[0] != stamp: + try: + chunks = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "" + pieces = {} + for c in chunks: + if c.get("lang", "en") == "en": + pieces.setdefault(str(c.get("section")), []).append(c.get("text") or "") + hit = (stamp, {k: "\n".join(v) for k, v in pieces.items()}) + _SECTION_TEXT_CACHE[str(path)] = hit + return hit[1].get(section, "") + + +# The fees the next-steps block quotes: (act, section, pattern with the +# amount as group 1). tests/test_rehab.py fails if any amount cannot be found +# in the corpus text. +FEE_PATTERNS = { + "trp": ("SOR-2002-227", "298", r"fee of \$([0-9,]+)"), + "rehab-serious": ("SOR-2002-227", "309", + r"serious criminality[^$]*\$([0-9,]+)"), + "rehab-criminality": ("SOR-2002-227", "309", + r"criminality under paragraph 36\(2\)[^$]*\$([0-9,]+)"), +} + + +def _fee(act_code, section, pattern): + m = re.search(pattern, _section_text(act_code, section)) + return m.group(1) if m else None + + +def _fee_amount(key): + return _fee(*FEE_PATTERNS[key]) + + +# What the chain assumes when a decisive fact is not given (critic, interim +# until the Tier 4 decisive-facts block): each code goes into CANLEX_RESULT +# 'assumed' and its line near the top of the memo. +_ASSUMED_TEXT = { + "status-foreign-national": ( + "status: a foreign national (status not given) -- a permanent " + "resident is reached only by s. 36(1), and the removal path, fee and " + "appeal lines differ"), + "application-unknown": ( + "application: not given -- the family-member line shows both readings " + "(s. 42(1) and IRPR s. 23 for a permanent-residence applicant; s. 42(2) " + "for temporary entry)"), + "adult-at-offence": ( + "age: an adult at the offence -- no date of birth or age at the offence " + "settles it (give date_of_birth, or age_at_offence, with the offence " + "date), so s. 36(3)(e) and the youth policy are not assessed"), + "no-foreign-relief": ( + "relief: no pardon, expungement, record suspension or similar relief " + "beyond what the disposition's own words say"), + "current-maximum": ( + "maximum: the one in force today -- no offence date was given and no " + "completion date settles it (Tran: the maximum at commission governs)"), + "clean-record": ( + "record: no other conviction or committed act (other_record not set) " + "-- another matter defeats deemed rehabilitation"), + "record-complete": ( + "record: the entries given are the whole record -- any other " + "conviction changes the count and the s. 17 clock"), + "absentia-sentence-served": ( + "in absentia: the sentence was served -- the in-absentia entry (no " + "rehabilitation clock until it is) is held for review and not in this " + "deployment, so the clock runs from the completion date given"), + "held-content": ( + "curated content: the record's words reach content held for review and not in " + "this deployment (the HELD FOR REVIEW line above) -- the answer is given without " + "it"), +} + +_YOUTH_ENTRIES = ("juvenile-adjudication", "youthful-offender") + +# ---- youth at the offence (roadmap T2.6) ---------------------------------- +# Two labelled parts, never run together: what the statute says (IRPA +# s. 36(3)(e) names only Canada's two youth statutes) and what IRCC policy +# asks about a foreign youth disposition (no verdict). Every quotation is +# pinned by tests/test_canada_youth_finality.py. +YOUTH_STATUTE = ( + "STATUTE -- IRPA s. 36(3)(e): inadmissibility 'may not be based on an " + "offence' for which the person 'is found guilty under the Young Offenders " + "Act' or 'received a youth sentence under the Youth Criminal Justice Act'. " + "Both are Canadian statutes: the paragraph reaches Canadian youth " + "dispositions only, and by its words it does not decide a foreign one. (A " + "'young person' under the YCJA is 'twelve years old or older, but less " + "than eighteen years old', Y-1.5 s. 2.)") +YOUTH_STATUTE_CANADA = ( + "For this Canadian record the statute applies directly: a youth sentence " + "under the YCJA (or a finding of guilt under the Young Offenders Act) " + "grounds nothing; an ADULT sentence imposed on a young person is not a " + "youth sentence and is not excluded. Give canada_sentence 'youth-sentence' " + "if a youth sentence was imposed.") +YOUTH_POLICY = ( + "POLICY -- not statute; CanLex gives no verdict on it. ENF 14 s. 24 and " + "s. 12.1 (IRCC; a withdrawn 2008 ATIP copy that still applies the repealed " + "Young Offenders Act) treat a person who 'was treated as a young offender " + "in a foreign jurisdiction that has young offender provisions, and no " + "conviction was recorded' as having no conviction, but 'if the applicant " + "was transferred to adult court, the applicant does have a conviction'; " + "where the foreign jurisdiction has no young-offender provisions, the " + "officer is to decide 'whether the applicant would have been dealt with in " + "juvenile or adult court in Canada'. ENF 14 frames that with the Young " + "Offenders Act transfer factors, which the YCJA replaced in 2003 (read the " + "YCJA's adult-sentence provisions with canlex_get_section, act 'Y-1.5'). " + "ENF 14 s. 12.1 asks for 'a copy of the foreign statute establishing " + "special measures for young offenders'. The questions: was the person " + "dealt with in a separate youth court or regime; was a conviction " + "recorded, or the case transferred to adult court (or an adult sentence " + "imposed); and what effect does the foreign law give the finding?") +UNDER_12 = ( + "STATUTE -- Criminal Code s. 13: 'No person shall be convicted of an " + "offence in respect of an act or omission on his part while that person " + "was under the age of twelve years.' Had the act been committed in Canada, " + "the person could not have been convicted of it. The equivalency below " + "compares offences and does not apply s. 13: weigh it before any s. 36 " + "finding.") +UNDER_12_CANADA = ( + "STATUTE -- Criminal Code s. 13: 'No person shall be convicted of an " + "offence in respect of an act or omission on his part while that person " + "was under the age of twelve years.' A Canadian conviction for an act " + "committed under 12 cannot exist: check the date of birth and the offence " + "date before relying on anything below.") + + +def _youth_screen(dob, age, od, completed, canada=False, entry_youth=False, + canada_sentence=None): + """(youth_screen or None, lines, warnings, needs) for one entry (roadmap + T2.6). The age at the offence comes from the date of birth and the offence + date; failing the offence date, a sentence-completion date bounds it from + above (the offence precedes the completion); failing both, from the + officer's age_at_offence. Ages 12-17 print the YOUTH block for ANY + disposition, as STATUTE and POLICY kept apart; under 12 prints Criminal + Code s. 13. A youth disposition entry with no age prints the same block.""" + from canlex.screening import years_since + warnings, needs = [], [] + a, basis, bound, bad_dob = None, None, None, False + if dob is not None and od is not None: + if dob > od: + bad_dob = True + warnings.append(f"the date of birth {dob} is after the offence date {od}") + else: + a, basis = years_since(dob, od), "from the date of birth and the offence date" + if age is not None and age != a: + warnings.append(f"age_at_offence {age} disagrees with the date of birth " + f"(age {a} on the offence date); the date of birth is used") + elif dob is not None and completed is not None and dob <= completed: + bound = years_since(dob, completed) + if a is None and age is not None and not (dob is not None and od is not None): + a, basis = age, "age_at_offence, as given" + if a is not None: + band = "under-12" if a < 12 else "youth" if a < 18 else "adult" + elif bound is not None and bound < 18: + band = "under-12" if bound < 12 else "youth" + basis = ("at most this age: the date of birth and the sentence-completion " + "date bound it (the offence precedes the completion)") + elif dob is not None or entry_youth: + band = "unknown" + if bad_dob: + needs.append({"fact": "date_of_birth", "parameter": "date_of_birth", + "question": "the date of birth is after the offence date -- " + "check both"}) + elif dob is not None: + needs.append({"fact": "offence_date", "parameter": "offence_date", + "question": "the offence date -- the age at the offence " + "decides s. 36(3)(e) and the youth policy"}) + else: + return None, [], warnings, needs + ys = {"band": band, "age": a, "age_at_most": bound if a is None else None, + "basis": basis, "youth_disposition": bool(entry_youth)} + lines = [] + if band in ("youth", "under-12") or entry_youth: + if a is not None: + head = f"YOUTH -- age {a} at the offence ({basis}):" + elif bound is not None and band != "adult": + head = f"YOUTH -- at most {bound} at the offence ({basis}):" + else: + head = "YOUTH -- a youth disposition (no date of birth or age at the offence given):" + lines.append(head) + if band == "under-12": + lines.append(UNDER_12_CANADA if canada else UNDER_12) + else: + lines.append(YOUTH_STATUTE) + lines.append(YOUTH_STATUTE_CANADA if canada else YOUTH_POLICY) + if canada and band == "youth" and canada_sentence != "youth-sentence": + needs.append({"fact": "youth-or-adult-sentence", "parameter": "canada_sentence", + "question": "was a youth sentence (YCJA) or an adult " + "sentence imposed? s. 36(3)(e) excludes " + "only a youth sentence"}) + elif band == "unknown" and not bad_dob: + lines.append("AGE AT THE OFFENCE not computed: a date of birth was given but " + "no offence date -- s. 36(3)(e) and the youth policy turn on it.") + return ys, lines, warnings, needs + + +# ---- convictions in absentia (roadmap T2.7) --------------------------------- +# A disposition entry may carry rehab_clock 'not-started-unless-served' (held +# for review); the tools honour it only when the entry carries it. +ABSENTIA_CLOCK = ( + "REHABILITATION CLOCK NOT STARTED: a conviction and sentence in absentia -- " + "ENF 14 s. 16 (IRCC; a withdrawn 2008 ATIP copy): 'Conviction and sentence " + "in absentia: not eligible for rehabilitation because the sentence has not " + "been completed'. IRPR ss. 17 and 18(2) run from the completion of the " + "sentence, and CanLex does not start either clock from a completion date it " + "cannot tell was served, so no deemed-rehabilitation or application date is " + "given. Find out whether, and when, the person actually served the " + "sentence.") +_ABSENTIA_RULE = "not-started-unless-served" +# Wave 2 verifier (principle 2): a record whose words say the conviction was +# in absentia, where the loaded curated data has no entry carrying that rule +# (the approved baseline: the entry is held) -- said, never silent. +_ABSENTIA_WORDS = re.compile(r"\bin absentia\b|\bin (?:his|her|their) absence\b", re.I) +ABSENTIA_HELD = ( + "HELD FOR REVIEW -- not in this deployment: the record says the conviction was in " + "absentia, and CanLex's in-absentia entry (no rehabilitation clock until the sentence " + "is served; ENF 14 s. 16) is held for review. The dates below run from the completion " + "date given, as for a sentence served -- confirm the sentence was served before " + "relying on them.") + +# A Canadian entry in the screening tools (roadmap T2.2). +CANADA_SCREEN_NOTE = ( + "CANADIAN RECORD: screened under IRPA s. 36(1)(a) and (2)(a) (convicted in " + "Canada), not the foreign-record chain -- no US disposition entry, state row " + "or rehabilitation clock applies, and the cure is a record suspension " + "(s. 36(3)(b)).") +CANADA_ALSO_CHECK = ( + "Also check: a previous removal order (s. 52); every other conviction in " + "Canada -- s. 36(2)(a) counts 'two offences under any Act of Parliament not " + "arising out of a single occurrence', and IRPR s. 18.1 needs the person " + "inadmissible SOLELY on convictions for offences that may only be " + "prosecuted summarily (pass the whole record to canlex_screen_history); the " + "youth exclusion and contraventions (s. 36(3)(e)).") +_CANADA_PARAMS_IGNORED = ( + "canada_offence_mode, custody_imposed_months, canada_sentence and " + "canada_max_years apply only to a record from Canada (country 'Canada' or a " + "province) and were ignored.") + + +def _canada_inputs(mode, custody, sentence, max_years, age, where=""): + """Validate the Canadian and age inputs a direct (non-MCP) caller may pass + unchecked. Returns (error or None, custody float|None, max int|None, + age int|None).""" + from canlex.rehab import CANADA_MODES, CANADA_SENTENCES + pre = f"Error: {where}" if where else "Error: " + if mode is not None and mode not in CANADA_MODES: + return (f"{pre}canada_offence_mode must be one of {', '.join(CANADA_MODES)} " + f"(got '{mode}')."), None, None, None + if sentence is not None and sentence not in CANADA_SENTENCES: + return (f"{pre}canada_sentence must be one of {', '.join(CANADA_SENTENCES)} " + f"(got '{sentence}')."), None, None, None + out = [] + for name, value, lo, hi, cast in (("custody_imposed_months", custody, 0, 1200, float), + ("canada_max_years", max_years, 0, 99, int), + ("age_at_offence", age, 0, 120, int)): + if value is None: + out.append(None) + continue + try: + v = cast(value) + ok = lo <= v <= hi and (cast is float or float(value) == v) + except (TypeError, ValueError): + ok = False + if not ok: + return (f"{pre}{name} must be a number from {lo} to {hi} (got '{value}')."), \ + None, None, None + out.append(v) + return (None, *out) + + +def _youth_bottom_line(ys, canada=False, canada_sentence=None): + """The bottom-line bullets for the age at the offence (roadmap T2.6).""" + if not ys: + return [] + if ys["band"] == "under-12": + return ["Age under 12 at the offence: Criminal Code s. 13 bars a conviction " + "in Canada for the act -- the answer above does not apply s. 13; " + "resolve it before relying on it."] + if ys["band"] == "youth" and canada and canada_sentence != "youth-sentence": + return ["A young person at the offence: if a YOUTH sentence was imposed " + "under the YCJA, it grounds nothing (IRPA s. 36(3)(e)); the answer " + "above reads the sentence as an adult sentence -- confirm which."] + if ys["band"] == "youth" and not canada: + return ["A young person at the offence: IRPA s. 36(3)(e) (statute) reaches " + "Canadian youth dispositions only; how a foreign youth disposition " + "is treated is IRCC policy (ENF 14), with no verdict here -- " + "answer the step-1 questions before relying on the answer."] + return [] + + +def _assumed_block(codes, suffix=None): + """The ASSUMED lines for the top of a memo, one per code.""" + if not codes: + return [] + suffix = suffix or {} + return (["", "ASSUMED (not given -- each can change the answer; confirm " + "before relying on the result):"] + + [f"- {_ASSUMED_TEXT[c]}{suffix.get(c, '')}" for c in codes]) + + +def _application_warning(person, application): + """A status/application pair that contradicts itself is reported, never + silently reconciled.""" + if application == "returning-pr" and person != "permanent-resident": + return ("application 'returning-pr' names a permanent resident but the " + "status is foreign-national: re-run with status " + "'permanent-resident' if the person holds that status") + if application in ("temporary-entry", "pr-applicant") and person == "permanent-resident": + return (f"application '{application}' is a foreign national's but the " + "status is permanent-resident: the permanent-resident lines " + "are used") + return None + + +def _rehab_brief(r): + return (f"{r['status'].upper().replace('-', ' ')} ({r['provision']}" + + (f"; earliest deemed {r['earliest_deemed']}" if r.get("earliest_deemed") else "") + + (f"; earliest application {r['earliest_apply']}" if r.get("earliest_apply") else "") + # AI-6 (G3, 2026-09-22): a NOT YET whose date is open says how late it is at least + + (f"; application not before {r['earliest_apply_not_before']}" + if r.get("earliest_apply_not_before") else "") + + ")") + + +@mcp.tool(name="canlex_screen_record", + annotations={"title": "Screen a Foreign Record (all three steps)", + **_READONLY}) +def canlex_screen_record( + offense: Annotated[str, Field( + description="The offence as it appears on the record ('DUI', " + "'PC 647(f)', 'possession of Xanax', 'burglary'). Rap-sheet " + "shorthand ('poss w/ intent', 'agg assault') and statute codes are " + "understood; a drug amount is read as written ('5,000 grams' is 5 kg: " + "a comma followed by three digits is a thousands separator).", + min_length=2, max_length=200)], + disposition: Annotated[str, Field( + description="The disposition in the record's own words ('convicted', " + "'pled guilty, 12 months probation', 'deferred adjudication', 'nolo " + "contendere', 'expunged', 'ACD', 'withheld adjudication', " + "'pending').", min_length=1, max_length=200)], + state: Annotated[Optional[str], Field( + description="US state (name or 2-letter code). A value that is not a " + "US state or territory ('England', 'Jalisco') is read as a non-US " + "record: no US state row or term is applied, and the memo says so.", + max_length=30)] = None, + offence_date: Annotated[Optional[str], Field( + description="Date the offence was committed (YYYY-MM-DD) -- sets the " + "Tran maximum and the act-branch clock.", max_length=10)] = None, + sentence_completed: Annotated[Optional[str], Field( + description="Date the ENTIRE sentence was completed (YYYY-MM-DD), " + "probation, fines and prohibitions included.", max_length=10)] = None, + status: Annotated[Optional[Literal["foreign-national", "permanent-resident"]], + Field(description="Whose record it is. Omit when not " + "known: the chain then assumes a foreign national " + "and says so (CANLEX_RESULT 'assumed').") + ] = None, + other_record: Annotated[bool, Field( + description="True if there is ANY other conviction or committed act " + "on the record (defeats deemed rehabilitation; makes application " + "eligibility conditional).")] = False, + count: Annotated[Literal["one", "two-plus-summary-only", "multiple"], Field( + description="How this matter sits in the record: 'one' (a single " + "conviction or act), 'two-plus-summary-only' (two or more matters, " + "all summary-equivalent -- the s. 36(2)(b) second limb), or " + "'multiple' (more than one indictable-equivalent matter).")] = "one", + value_over_5000: Annotated[Optional[bool], Field( + description="For theft, fraud, mischief and stolen-property matters: " + "True if the value exceeded CAD 5,000, False if not.")] = None, + schedule: Annotated[Optional[Literal["I", "II", "III", "IV", "cannabis"]], + Field(description="For drug matters when the record " + "does not name the substance: the CDSA schedule " + "(or 'cannabis').")] = None, + sentence_imposed: Annotated[Optional[str], Field( + description="The sentence as imposed ('2 years probation, $500 fine'), " + "for the memo's record block.", max_length=200)] = None, + court: Annotated[Optional[str], Field( + description="The convicting court, for the memo.", max_length=120)] = None, + source_document: Annotated[Optional[str], Field( + description="The evidence relied on (certified disposition, FBI " + "III/NCIC, state repository printout, admission at examination).", + max_length=160)] = None, + facts: Annotated[Optional[str], Field( + description="What the person actually did, in a sentence or two -- " + "the Hill evidence branch and the act branch turn on it. When the " + "result's 'needs' names an element fact (parameter 'facts'), answer it " + "here as ': ' (for example 'harm-weapon-choking: none'); " + "a Canadian element's own words in the facts ('weapon', 'choked') are " + "read too, unless negated ('no weapon').", + max_length=600)] = None, + today: Annotated[Optional[str], Field( + description="Optional assessment date (YYYY-MM-DD); defaults to the " + "server's UTC date -- give today= for the port of entry's date.", + max_length=10)] = None, + application: Annotated[Optional[Literal["temporary-entry", "pr-applicant", + "returning-pr"]], Field( + description="What the person is applying for: 'temporary-entry' " + "(visitor, worker, student: s. 36 does not reach family members, " + "IRPA s. 42(2)), 'pr-applicant' (IRPA s. 42(1) and IRPR s. 23 reach " + "family members), or 'returning-pr' (a permanent resident). It keys " + "the family-member line; omit if unknown and both readings are " + "shown.")] = None, + country: Annotated[Optional[str], Field( + description="Optional country of the record, as a name ('United " + "States', 'Mexico', 'United Kingdom'). Omit for a US record. Any " + "other country is read as a non-US record: no US state row or term " + "is applied and the memo is labelled 'non-US record: curated " + "coverage is thin'.", max_length=60)] = None, + organization: Annotated[Optional[bool], Field( + description="True if the facts suggest a criminal organization (a gang, " + "cartel or smuggling network; offences committed for or with a group): " + "the memo then names IRPA s. 37(1)(a) organized criminality as a ground " + "to consider. False if there is no such indication. Omit if unknown. " + "It never changes the s. 36 answer.")] = None, + date_of_birth: Annotated[Optional[str], Field( + description="The person's date of birth (YYYY-MM-DD). With the offence " + "date it gives the age at the offence: 12 to 17 prints a YOUTH block " + "whatever the disposition (IRPA s. 36(3)(e) as STATUTE, the ENF 14 " + "foreign-youth questions as POLICY), under 12 prints Criminal Code " + "s. 13.", max_length=10)] = None, + age_at_offence: Annotated[Optional[int], Field( + ge=0, le=120, + description="The age at the offence, when no date of birth is known " + "(the date of birth wins when both are given).")] = None, + canada_offence_mode: Annotated[ + Optional[Literal["summary-only", "hybrid", "indictable"]], Field( + description="A record FROM CANADA only (country 'Canada' or a " + "province): how the Canadian offence may be prosecuted. A hybrid " + "prosecuted summarily is still 'hybrid' (IRPA s. 36(3)(a)). Omit if " + "unknown: the curated pairing's flags are used, or each reading is " + "shown.")] = None, + custody_imposed_months: Annotated[Optional[float], Field( + ge=0, le=1200, + description="A record FROM CANADA only: the term of imprisonment " + "imposed, in months (0 = none). s. 36(1)(a) needs MORE than six months; " + "s. 64(2) (a permanent resident's appeal) AT LEAST six.")] = None, + canada_sentence: Annotated[ + Optional[Literal["custody", "intermittent", "conditional-sentence", + "suspended-sentence", "probation", "fine", + "absolute-discharge", "conditional-discharge", + "youth-sentence", "contravention"]], Field( + description="A record FROM CANADA only: the sentence or outcome. A " + "discharge is not a conviction (Criminal Code s. 730(3)); a " + "conditional sentence is not a term of imprisonment (Tran); an " + "intermittent sentence is not settled (both readings); a youth " + "sentence and a contravention are excluded (IRPA s. 36(3)(e)). Read " + "from the disposition's words when omitted.")] = None, + canada_max_years: Annotated[Optional[int], Field( + ge=0, le=99, + description="A record FROM CANADA only: the maximum term (years) the " + "Canadian offence carried at the offence date; 99 = life. Omit to use " + "the curated pairing's verified maximum.")] = None, + conviction_date: Annotated[Optional[str], Field( + description="Date of the conviction (YYYY-MM-DD). With no offence date " + "it bounds the Tran maximum: the offence was committed on or before it " + "(the earlier of this and sentence_completed is used).", + max_length=10)] = None, + sentence_date: Annotated[Optional[str], Field( + description="Date the sentence was imposed (YYYY-MM-DD). With " + "sentence_imposed and no sentence_completed, step 3 adds a footing " + "'IF SERVED EXACTLY AS IMPOSED' from Guide 5312's rules -- a projected " + "completion date, never a verdict (early termination, revocation, an " + "extension or late fine payment move it; a fine blocks it).", + max_length=10)] = None, + detail: Annotated[Literal["card", "full"], Field( + description="'full' (default): the whole memo. 'card': a result card " + "of about ten lines -- the result (or NOT SETTLED and the deciding " + "fact), the step-1 tier, the dates, the action, the facts that change " + "the answer -- and the same CANLEX_RESULT line.")] = "full", + language: Annotated[Literal["en", "fr", "both"], Field( + description="The language of the DRAFT s. 44(1) narrative skeleton (ENF 5 " + "s. 13.1) the full memo ends with: 'en' (default), 'fr' or 'both'. The " + "rest of the memo is in English.")] = "en", +) -> str: + """Run the whole IRPA s. 36 screening chain on one foreign record entry + in a single call -- step 1 (is the disposition a conviction), step 2 (the + Canadian equivalent, its maximum at the offence date, and the branch for + this person), step 3 (deemed rehabilitation / eligibility to apply, with + the earliest dates) -- and return a screening memo with the draft + allegation paragraph, the next steps at the port of entry and one + CANLEX_RESULT line. What the chain assumes because a fact was not given + (status, age, relief, the maximum at commission) is listed near the top + and in CANLEX_RESULT 'assumed'. The three step tools remain available for + a piece at a time; this one exists so the values flow between steps + exactly, without transcription. For a record with several entries, run + canlex_screen_history. The curated tables were compiled for US records + (US state rows and terms where they exist); a non-US record is matched + without them and labelled 'non-US record: curated coverage is thin'. A + conviction IN CANADA (country 'Canada' or a province as the state) is + screened on IRPA s. 36(1)(a)/(2)(a) instead: a discharge is not a + conviction, a conditional sentence is not imprisonment, IRPR s. 18.1, the + record suspension as the only cure, the removal path and a permanent + resident's appeal line, with canada_offence_mode, custody_imposed_months, + canada_sentence and canada_max_years. A date_of_birth (or age_at_offence) + prints the youth block, statute and IRCC policy kept apart. + + THE ANSWER CHANGES WITH THESE FACTS (roadmap T4.1): each missing fact the + chain can vary (status, the offence date where no bound settles it, the + value, schedule or tie fact, the limb, quantity or inchoate role, an + element answer, the age, relief, another record) is re-run with every + plausible value, and the facts that change the branch, the allegation + paragraph or the rehabilitation status are listed at the top and in + CANLEX_RESULT 'decisive_missing'. 'confidence' (roadmap T4.2) says what + each step rests on. detail 'card' (roadmap T4.7) returns a short result + card instead of the memo. + + DRAFT s. 44(1) NARRATIVE SKELETON (roadmap T4.6; ENF 5 s. 13.1): the full + memo ends with the report's elements in ENF 5's form, English, French or + both (language) -- the proposed IRPA paragraph with its words, the offence + as recorded, the Canadian provision (the pairing's curated report + provision, by limb and maximum at commission) and the maximum in the + statute's own words. Every bracket is to be verified; what the tool does + not hold stays [CONFIRM: ...], and an open result gets one allegation per + reading. CANLEX_RESULT 'narrative'. + + Returns: + str: The memo -- record block, assumptions, each step's finding, the + allegation, next steps, the bottom line, the narrative skeleton, and + a CANLEX_RESULT line with every structured value. + """ + from canlex.commentary import BANNER, row_notes, row_provenance, row_tier + from canlex.rehab import assess + from canlex.screening import (alias_needs, aliases_read, branch_text, + date_warnings, disposition_cues, + disposition_notes, has_military_cue, + offence_flags, offence_notes, parse_date, + result_line, saini_checklist, split_offences) + from canlex.rehab import CANADA_MODES, CANADA_SENTENCES + # the record's words on one line (the code audit of 2026-09-21): a newline in a + # disposition was echoed into the memo and forged a second CANLEX_RESULT or + # WARNING line for a top-down reader; folding whitespace changes no matching + offense, disposition, state, country, court, facts, sentence_imposed, source_document = ( + _fold_ws(v) for v in (offense, disposition, state, country, court, facts, + sentence_imposed, source_document)) + try: + od, sc_d, td, dob = (parse_date(offence_date), parse_date(sentence_completed), + parse_date(today), parse_date(date_of_birth)) + cd_d, sd_d = parse_date(conviction_date), parse_date(sentence_date) + except ValueError as exc: + return f"Error: dates must be YYYY-MM-DD ({exc})." + if detail not in ("card", "full"): + return f"Error: detail must be 'card' or 'full' (got '{detail}')." + if language not in ("en", "fr", "both"): + return f"Error: language must be 'en', 'fr' or 'both' (got '{language}')." + err, custody, cmax, age = _canada_inputs(canada_offence_mode, custody_imposed_months, + canada_sentence, canada_max_years, + age_at_offence) + if err: + return err + import datetime as _dt + today_d = td or _dt.date.today() + person = status or "foreign-national" + warnings = list(date_warnings(od, sc_d, today_d)) + warnings += _youth_screen(dob, age, od, sc_d)[2] + warnings += _record_date_warnings(od, cd_d, sd_d, sc_d, today_d) + app_warning = _application_warning(person, application) + if app_warning: + warnings.append(app_warning) + # Roadmap T2.9: a non-US state value never reaches the US state rows. + j = _jurisdiction(state, country, disposition) + rstate = j["state"] + edata0 = _load_equivalency() + parts = (split_offences(offense, edata0.get("pairings", []), rstate) + if edata0 is not None else [offense]) + if len(parts) > 1: + from canlex.screening import split_amount_caution + return ("Error: this reads as " + str(len(parts)) + " offences (" + + "; ".join(parts) + "). Screen each entry on its own, or pass " + "them all to canlex_screen_history so the count and the " + "s. 17 clock are computed at record level." + + split_amount_caution(parts, edata0.get("pairings", []), rstate)) + facts_block = [f"Offence: {offense}", f"Disposition: {disposition}"] + if state: + facts_block.append(f"State: {state}" + + ("" if rstate else " (not a US state or territory)")) + if country: + facts_block.append(f"Country: {country}") + if court: + facts_block.append(f"Court: {court}") + if od: + facts_block.append(f"Offence date: {od.isoformat()}") + if sentence_imposed: + facts_block.append(f"Sentence imposed: {sentence_imposed}") + if cd_d: + facts_block.append(f"Conviction date: {cd_d.isoformat()}") + if sd_d: + facts_block.append(f"Sentence date: {sd_d.isoformat()}") + if sc_d: + facts_block.append(f"Sentence completed: {sc_d.isoformat()}") + if source_document: + facts_block.append(f"Evidence relied on: {source_document}") + if facts: + facts_block.append(f"Facts: {facts}") + facts_block.append(f"Person: {person}" + + (" (assumed: status not given)" if status is None else "") + + f"; application: {application or 'not given'}" + + f"; other record: {'yes' if other_record else 'none reported'}" + + f"; count: {count}" + + ("" if organization is None else + f"; criminal organization indicated: {'yes' if organization else 'no'}")) + if dob: # (the code audit of 2026-09-21: a blank string parses to None -- test the date) + facts_block.append(f"Date of birth: {dob.isoformat()}") + elif age is not None: + facts_block.append(f"Age at the offence (as given): {age}") + out = [BANNER, "", "# Record screening memo", "", "\n".join(facts_block)] + for w in warnings: + out.append(f"WARNING: {w}.") + canada = j["kind"] == "canada" + out += [CANADA_SCREEN_NOTE] if canada else j["notes"] + if not canada and any(v is not None for v in (canada_offence_mode, custody, + canada_sentence, cmax)): + out.append(f"NOTE: {_CANADA_PARAMS_IGNORED}") + assumed_at = len(out) # the ASSUMED block goes here once the steps ran + + if canada: + # ---- a conviction IN Canada: s. 36(1)(a)/(2)(a) (roadmap T2.2) + ys, ylines, _, yneeds = _youth_screen(dob, age, od, sc_d, canada=True, + canada_sentence=canada_sentence) + sections, bl, fields, cpairing = _canada_screen( + offense, disposition, person, application, count, od, sc_d, today_d, + canada_sentence, canada_offence_mode, custody, cmax, other_record, + edata0, youth_needs=yneeds, conviction_date=cd_d) + if ylines: + sections[0] = (sections[0][0], sections[0][1] + "\n" + "\n".join(ylines)) + for title, text in sections: + out += ["", f"## {title}", text] + s37_lines, s37 = _s37_block([cpairing] if cpairing else [], organization, + person, offense) + if s37_lines: + out += ["", "## s. 37 also to consider (organized criminality)", + "\n".join(s37_lines)] + bl += _youth_bottom_line(ys, canada=True, canada_sentence=fields["canada_sentence"]) + if s37: + bl.append("Also to consider: IRPA s. " + " and s. ".join(s37["grounds"]) + + " (organized criminality) -- a separate ground the s. 36 " + "answer does not decide (section above).") + bl.append("This is legal information, not legal advice; the officer's " + "determination governs, and every provision cited should be " + "read with canlex_get_section.") + out += ["", "## Bottom line", "\n".join(f"- {b}" for b in bl), ""] + # roadmap T4.6: ENF 5 s. 13.1's sample is for a conviction outside Canada + from canlex import narrative + out += narrative.render([], person, status is not None, language, + [narrative.NO_SKELETON["canada_one"]]) + [""] + assumed = [] + if status is None: + assumed.append("status-foreign-national") + if application is None and person == "foreign-national": + assumed.append("application-unknown") + if ys is None or ys["band"] == "unknown": + assumed.append("adult-at-offence") + if not other_record: + assumed.append("clean-record") + # roadmap T4.1: the facts that change a Canadian answer (status, another record) + decisive, checked = _canada_decisive( + fields, assumed, lambda **o: _canada_screen(**dict(dict( + offense=offense, disposition=disposition, person=person, + application=application, count=count, od=od, sc_d=sc_d, today_d=today_d, + canada_sentence=canada_sentence, canada_offence_mode=canada_offence_mode, + custody=custody, canada_max_years=cmax, other_record=other_record, + edata=edata0, youth_needs=yneeds, conviction_date=cd_d), **o))[2]) + suffix = {d["assumed"]: DECISIVE_SUFFIX for d in decisive if d.get("assumed")} + out[assumed_at:assumed_at] = (_decisive_lines(decisive, checked) + + _assumed_block(assumed, suffix)) + fields.update({"person": person, "application": application, "count": count, + "warnings": list(dict.fromkeys(warnings)), "assumed": assumed, + "jurisdiction": "canada", "organization": organization, + "s37": s37, "youth_screen": ys, + "decisive_missing": decisive, "decisive_checked": checked, + "completion_basis": "sentence_completed" if sc_d else None, + "narrative": narrative.result_field([], language)}) + fields["confidence"] = _canada_confidence(cpairing, fields) + if detail == "card": + card = _canada_card(offense, disposition, fields, od, cd_d, sd_d, sc_d, today_d, + decisive, checked, assumed) + return "\n".join(card + [result_line(tool="canlex_screen_record", detail="card", + **fields)]) + out.append(result_line(tool="canlex_screen_record", **fields)) + return _with_superseded("\n".join(out)) + + # ---- the chain as fields (roadmap T4.1): steps 1-3 without text -- the + # function the decisive-facts re-runs call; the memo below renders it + chain_kw = dict(offense=offense, disposition=disposition, j=j, od=od, sc_d=sc_d, + person=person, today_d=today_d, cd=cd_d, other_record=other_record, + count=count, value_over_5000=value_over_5000, schedule=schedule, + facts=facts, dob=dob, age=age, edata=_load_equivalency()) + memo = {} # the base chain and its decisive re-runs share it + ch = _memo_run(memo, _chain_entry, render=True, **chain_kw) + # ---- step 1: conviction? + s1 = ch["s1"] + verdict, entry, row = s1["verdict"], s1["entry"], s1["row"] + if not s1["available"]: + step1 = "Dispositions dataset not present." + elif entry: + # roadmap T3.5: the row's provenance tier, whatever its verdict + tier = (" (" + row["state"] + " row, " + row_tier(row) + ")" + if row else (" (no row for the state; entry-level verdict)" + if j["kind"] in ("us", "unspecified") + else " (non-US record: no US state row applies; " + "entry-level verdict)")) + if s1.get("question_flag"): + # disposition roadmap T1.6 / critic: the wording leaves step 1 open + skipped = s1.get("row_not_applied") + tier = (" (NOT SETTLED on the record's wording -- the entry is one reading" + + (f"; the {skipped['state']} row is not applied" if skipped else "") + + ")") + elif s1.get("row_mismatch"): + # disposition roadmap T1.11: the row selected by mechanism + mm = s1["row_mismatch"] + tier = (f" ({row['state']} row, {row_tier(row)}; " if row else " (") + ( + ROW_MISMATCH_TIER.format(state=mm["state"]) + ")") + step1 = (f"Entry: {entry['names'][0]} -- verdict " + f"{verdict.upper().replace('-', ' ')}{tier}\n" + f"{entry.get('bottom_line') or entry['analysis']}") + if row: + step1 += "".join(f"\n{row['state']} row -- {x}" for x in row_notes(row)) + if len(s1["matches"]) > 1: + other_m = s1["matches"][1] + step1 += (f"\nAlso possible: {other_m['names'][0]} (verdict " + f"{other_m['is_conviction'].upper().replace('-', ' ')}) " + "-- the record's words fit both; confirm which.") + checklist = saini_checklist(entry) # roadmap T2.8 (held entries only) + if checklist: + step1 += "\n" + "\n".join(checklist) + else: + said = ("its words name a non-conviction, cancelled or undecided " + "outcome (for example 'spent', 'quashed', 'no conviction " + "recorded'), so they are not read as a conviction" + if disposition_cues(disposition) == "non-conviction" + else "its words do not say 'convicted' or 'guilty'") + step1 = (f"No curated entry matches '{disposition}', and {said}. " + "The conviction " + "framework (canlex_us_disposition) applies: was guilt " + "admitted or found, is the disposition final, and does an " + "analogous Canadian non-conviction regime exist? Step 3 " + "below is computed on BOTH footings.") + if s1.get("row_mismatch"): + # disposition roadmap T1.11: why the state row is not applied + step1 = _row_mismatch_line(s1["row_mismatch"]) + "\n" + step1 + if s1.get("flags"): + # disposition roadmap T1.6 / critic: how the record's words were read + step1 = "\n".join(_flag_lines(s1["flags"], places=s1.get("gate_places"))) + "\n" + step1 + for n in disposition_notes(disposition): + step1 += "\n" + n + if entry: + # roadmap T3.6: guidance that pulls against the entry's bottom line, + # both readings (held; absent on the approved baseline) -- only the + # caveats that bear on this record's footing (disposition roadmap T1.10) + caveat = _caveat_block(entry, s1.get("act")) + if caveat: + step1 += "\n" + "\n".join(caveat) + if s1.get("held"): + step1 += "\n" + _held_note(s1["held"]) + # roadmap T2.6: the age at the offence, for ANY disposition; the statute + # and the policy printed apart + youth, ys, yneeds = ch["youth"], ch["ys"], ch["yneeds"] + if ch["ylines"]: + step1 += "\n" + "\n".join(ch["ylines"]) + out += ["", "## Step 1 -- is it a conviction?", step1] + is_conviction, unknown, basis = ch["is_conviction"], ch["unknown"], ch["basis"] + # roadmap T2.7: a conviction in absentia starts no rehabilitation clock + # unless the sentence was served -- only where the (held) entry says so + clock_rule, absentia, absentia_held = ch["clock_rule"], ch["absentia"], ch["absentia_held"] + + # ---- step 2: equivalency (the rule canlex_us_equivalency and + # canlex_screen_history share -- roadmap T1.4; computed in the chain) + edata, moffense, held_open, s2 = ch["edata"], ch["moffense"], ch["held_open"], ch["s2"] + element_readings = ch["element_readings"] + eq, pairing, tied, cands = ch["eq"], ch["pairing"], ch["tied"], ch["cands"] + if eq is not None: + out += ["", "## Step 2 -- Canadian equivalent and branch"] + if s2["tie"]["resolution"]: + out.append(_tie_resolved_line(s2["tie"])) + out.append(s2["blocks"][0]) + if tied: + out += ["", "### The other pairing that matches equally", s2["blocks"][1]] + if _equal_second(s2["scored"], s2["tie"]) is not None: + out.append(_other_candidate_line(s2["scored"], moffense, od, person, sc_d, + inc=s2["inchoate"], convicted=cd_d)) + out += _term_note_lines(s2["term_notes"]) + else: + out += ["", "## Step 2 -- Canadian equivalent and branch"] + if not held_open: + out.append(f"No curated pairing names '{offense}' and none is close by " + "meaning: apply the Hill framework to the foreign statute " + "(canlex_us_equivalency prints it) before going further.") + out += [f"NOTE: {n}" for n in offence_notes(moffense, rstate, od, facts)] + out += _element_reading_lines(element_readings) + if s2 and s2.get("held"): + out.append(_held_note(s2["held"])) + + # ---- step 3: rehabilitation (computed in the chain, rendered here) + rehab, rehab_alt = ch["rehab"], ch["rehab_alt"] + clocks, single_summary, s3 = ch["clocks"], ch["single_summary"], ch["step3"] + step3 = [] + if s3["kind"] == "tie": + step3.append("Not settled: two pairings match the record's words " + "equally, so the band is not known. Each footing:") + for p, f, res in s3["rows"]: + head = f"- if {p['id']} ({p['canadian_offence']}): {branch_text(f['branch'])}" + if res == "nothing": + step3.append(head + " -- nothing to rehabilitate on this footing.") + elif res == "not-computed": + step3.append(head + " -- not computed (it depends on further facts).") + else: + step3.append(head) + for b, r in res: + step3.append(f" - {_basis_label(b)}: {_rehab_brief(r)}") + if not clocks and not absentia: + step3.append("Dates not computed: give sentence_completed (for a " + "conviction) or offence_date (for the act branch).") + elif s3["kind"] == "none": + step3.append("Not reached: no Canadian federal equivalent, so the " + "conviction branch grounds nothing to rehabilitate.") + elif s3["kind"] == "pr": + step3.append("Not reached: a sub-10-year (or summary-only) equivalent " + "grounds nothing against a permanent resident, so there " + "is nothing to rehabilitate on this matter.") + elif s3["kind"] == "single-summary": + # Whatever max_years the curated row carries (the approved baseline + # gives disorderly-conduct 2): one summary-only equivalent grounds + # nothing, so nothing is assessed (verifier, 2026-09-12). + step3.append("Not reached: a single summary-only equivalent grounds " + "nothing -- s. 36(2)(b) reaches two such convictions 'not " + "arising out of a single occurrence', and s. 36(2)(c) only " + "an act that 'would constitute an indictable offence' -- so " + "there is nothing to rehabilitate on this matter. If the " + "record holds another summary-only conviction, re-run with " + "count 'two-plus-summary-only', or pass the whole record to " + "canlex_screen_history.") + elif s3["kind"] == "open": + step3.append(f"Not settled: {_open_reason(eq)}. Each footing:") + for c, res in s3["rows"]: + head = (f"- if {_reading(c)} (maximum {_max_shown(c)}): " + f"{branch_text(c['branch'])}") + if res == "nothing": + step3.append(head + " -- nothing to rehabilitate on this footing.") + elif res == "single-summary": + step3.append(head + " -- a single summary-only equivalent grounds nothing.") + elif res == "not-computed": + step3.append(head + " -- not computed.") + else: + step3.append(head) + for b, r in res: + step3.append(f" - {_basis_label(b)}: {_rehab_brief(r)}") + if not clocks and not absentia: + step3.append("Dates not computed: give sentence_completed (for a " + "conviction) or offence_date (for the act branch).") + elif s3["kind"] == "depends": + step3.append("Not computed: the branch depends on facts the pairing " + "names (value, schedule or elements)." + + ("" if absentia and not clocks else " Both footings:")) + for _lt10, label, res in s3["rows"]: + for b, r in res: + step3.append(f"- {label}, {_basis_label(b)}: {_rehab_brief(r)}") + elif s3["kind"] == "settled": + if not clocks and not absentia: + step3.append("Not computed: give sentence_completed (for a " + "conviction) or offence_date (for the act branch) to " + "get the elapsed time and the earliest eligibility " + "dates.") + # a summary-only equivalent is below 10 years by definition, whatever + # structured maximum the curated row carries (the chain's lt10) + for i, (b, clock, _r) in enumerate(s3["results"]): + text, _ = _rehab_report(b, s3["lt10"], count, + not other_record, clock.isoformat(), None, + person, today_d.isoformat(), embed=True) + if len(clocks) > 1: + step3.append(f"### Footing {i + 1}: {_basis_label(b)}") + step3.append(text) + elif s3["kind"] == "held-open": + step3.append("Not settled: the statute code names more than one offence and " + + ("the specification decides which (step 2) -- re-run with the " + "answer in the facts." if element_readings else + "the offence date decides which (step 2) -- give offence_date.")) + else: + step3.append("Not computed: no Canadian equivalent identified.") + rehab_if_served, absentia_open = ch["rehab_if_served"], ch["absentia_open"] + if absentia_open: + step3.insert(0, ABSENTIA_CLOCK) + if ch["r_if"] is not None: + step3.append(f"If the sentence was in fact served, and completed on " + f"{sc_d.isoformat()}: {_rehab_brief(ch['r_if'])} -- confirm the " + "service before relying on it.") + if absentia_held: + step3.insert(0, ABSENTIA_HELD) + # roadmap T4.5: the completion IF SERVED EXACTLY AS IMPOSED -- no verdict + proj_lines, proj = _projection_lines(ch, sentence_imposed, sd_d) + step3 += proj_lines + out += ["", "## Step 3 -- rehabilitation", "\n".join(step3)] + + # ---- allegation and next steps + out += ["", "## Allegation and next steps", + "\n".join(_next_steps(person, is_conviction, unknown, eq, pairing, + rehab, entry, verdict, application=application, + candidates=cands if tied else None, + count=count, act=s1.get("act")))] + + # ---- s. 37, whatever the s. 36 branch (roadmap T2.5) + s37_pairings = [p for p, _ in cands] if tied else ([pairing] if pairing else []) + s37_lines, s37 = _s37_block(s37_pairings, organization, person, offense) + if s37_lines: + out += ["", "## s. 37 also to consider (organized criminality)", + "\n".join(s37_lines)] + + # ---- bottom line + bl = [] + if is_conviction: + bl.append("The disposition is a conviction (or likely one): the " + "conviction branch, s. 36(1)(b) / (2)(b), is engaged.") + elif unknown: + bl.append("Whether the disposition is a conviction is not settled by " + "its words; the memo carries both footings." + + (f" Deciding question (step 1): " + f"{DISPOSITION_FLAG_QUESTIONS[s1['question_flag']]}" + if s1.get("question_flag") else "")) + elif _act_not_settled(s1.get("act")): + # disposition roadmap T1.5: never 'the act branch remains' after an acquittal + bl.append(BOTTOM_LINE_ACT_NOT_SETTLED.format( + no="NOT" if verdict == "no" else "likely NOT")) + else: + bl.append(f"The disposition is {'NOT' if verdict == 'no' else 'likely NOT'} " + "a conviction: the conviction branch (s. 36(1)(b)/(2)(b)) " + "likely fails, but the ACT branch (s. 36(1)(c)/(2)(c)) " + "remains -- it turns on what the person did, on reasonable " + "grounds to believe (balance of probabilities for a " + "permanent resident, s. 36(3)(d)).") + bl += _caveat_bottom_lines(entry, s1.get("act")) + if eq and tied: + need = (eq.get("needs") or [{}])[0] + opts = "; ".join(f"{_option_label(need, p)} -> {p['canadian_offence']}: " + f"{branch_text(f['branch'])}" for p, f in cands) + bl.append("Equivalency: NOT SETTLED -- two pairings match the record's " + f"words equally, and {need.get('question') or 'the deciding fact'} " + f"decides between them. {opts}.") + elif eq and eq["branch"] in _OPEN: + opts = "; ".join(f"{_reading(c)} -> maximum {_max_shown(c)}: " + f"{branch_text(c['branch'])}" for c in eq["tran_candidates"]) + why = ("the offence date decides which maximum applied (Tran) and was " + "not given" if eq["branch"] == "tran-open" else _open_reason(eq)) + bl.append(f"Equivalency: {pairing['canadian_offence']} -- band NOT SETTLED: " + f"{why}. {opts}.") + elif eq: + bl.append(f"Equivalency: {pairing['canadian_offence']} -- " + f"{branch_text(eq['branch'])}.") + elif element_readings: + bl.append("Equivalency: NOT SETTLED -- the statute code names more than one " + "offence and the specification decides: " + + "; ".join(f"{x['label']} -> " + + (f"{x['pairing']} or {x['tied_with']}" if x["branch"] == "tie" + else f"{x['pairing']}: {branch_text(x['branch'])}" + if x["pairing"] else "no curated pairing") + for x in element_readings) + ".") + elif held_open: + bl.append("Equivalency: NOT SETTLED -- the statute code names a different " + "offence by offence date; give offence_date (step 2).") + if eq and eq.get("also_matched"): + bl.append(f"Also matched at the same score: {', '.join(eq['also_matched'])} " + "(step 2) -- confirm which offence the record is before relying " + "on the band above.") + if rehab: + bl.append(_if_act_used(s1.get("act")) + + f"Rehabilitation: {rehab['status'].upper().replace('-', ' ')} " + f"({rehab['provision']}).") + elif single_summary: + bl.append("Rehabilitation: NOT REACHED -- a single summary-only " + "equivalent grounds nothing on this matter alone.") + elif absentia_open: + bl.append("Rehabilitation: CLOCK NOT STARTED -- a conviction in absentia: " + "no clock runs until the sentence is served (ENF 14 s. 16; " + "step 3).") + bl += _youth_bottom_line(ys) + if s37: + bl.append("Also to consider: IRPA s. " + " and s. ".join(s37["grounds"]) + + " (organized criminality) -- a separate ground the s. 36 " + "answer does not decide; no conviction needed, no " + "rehabilitation, no IAD appeal (section above).") + bl.append("This is legal information from a curated table, not legal " + "advice; the officer's determination governs, and every " + "provision cited should be read with canlex_get_section.") + out += ["", "## Bottom line", "\n".join(f"- {b}" for b in bl)] + # ---- read next (roadmap T3.6): what the matched entry and pairing name + from canlex import readnext + rn_steps = [("Read next (step 1)", readnext.items(entry) if entry else []), + ("Read next (step 2)", + _pairing_read_next_items([p for p, _ in cands] if tied else [pairing], edata) + if pairing else [])] + rn_lines = [x for x in (readnext.compact_line(i, _index_chunks(), prefix=p) + for p, i in rn_steps) if x] + rn_ids = [] + for _, i in rn_steps: + rn_ids += [x for x in readnext.chunk_ids(i, _index_chunks()) if x not in rn_ids] + if rn_lines: + out += ["", "## Read next", "\n".join(f"- {x}" for x in rn_lines)] + out.append("") + + # ---- what the chain assumed (critic: stated, never silent) + assumed = [] + if status is None: + assumed.append("status-foreign-national") + if application is None and person == "foreign-national": + assumed.append("application-unknown") + if ys is None or (ys["band"] == "unknown" and not youth): + assumed.append("adult-at-offence") + assumed.append("no-foreign-relief") + if (eq and od is None and eq.get("assessed_basis") in ("current", "undetermined") + and eq.get("branch") not in _OPEN): + assumed.append("current-maximum") + if not other_record: + assumed.append("clean-record") + if absentia_held: + assumed.append("absentia-sentence-served") + held = [h for h in (s1.get("held"), (s2 or {}).get("held")) if h] + if held: + assumed.append("held-content") + # ---- the facts that change the answer (roadmap T4.1): each missing fact + # the chain can vary, re-run with every plausible value; only those that + # change the branch, the allegation paragraph or the rehabilitation + # status are listed, at the top, and the ASSUMED lines they replace say so + decisive, checked = _memo_run(memo, _record_decisive, ch, chain_kw, assumed) + suffix = {d["assumed"]: DECISIVE_SUFFIX for d in decisive if d.get("assumed")} + whole = _whole_answer_only(checked, _chain_signature(ch)[1] == ("not-settled",)) + out[assumed_at:assumed_at] = (_decisive_lines(decisive, checked, whole=whole) + + _assumed_block(assumed, suffix)) + warnings = list(dict.fromkeys(warnings)) + # ---- the DRAFT s. 44(1) narrative skeleton (roadmap T4.6; ENF 5 s. 13.1): + # one allegation per reading the chain leaves open, never one settled + from canlex import narrative + nar_base = {"entry": None, "offence": offense, + "offence_date": od.isoformat() if od else None, + "conviction_date": cd_d.isoformat() if cd_d else None, + "place": _narrative_place(state, country)} + if eq is None and (element_readings or held_open): + nar_allg, nar_notes = [], [narrative.NO_SKELETON["not-settled"].format( + why="the statute code's specification or offence date decides")] + else: + nar_allg, nar_notes = _narrative_allegations(eq, pairing, cands, tied, is_conviction, + unknown, count, nar_base, act=ch["act"]) + nar_held = _narrative_held(nar_allg) + out += narrative.render(nar_allg, person, status is not None, language, nar_notes, + nar_held) + [""] + + fields = {"disposition_entry": entry["id"] if entry else None, + "verdict": verdict, "conviction": is_conviction, + "conviction_unsettled": unknown, "basis": basis, + "person": person, "application": application, "count": count, + "warnings": warnings, "assumed": assumed, + "jurisdiction": j["kind"], "organization": organization, "s37": s37, + "saini_checklist": bool(entry and saini_checklist(entry)), + "row_provenance": row_provenance(row), + "disposition_flags": list(s1.get("flags") or []), + # disposition roadmap T1.10: the footing and how the act branch stands + "footing": ch["act"]["footing"], "act_branch": ch["act"]["act_branch"], + # the review of batch 1: the allegation paragraphs the chain supports -- + # 'branch' is the offence's equivalency branch, not a finding; after an + # acquittal this reads ['not-settled'] (act_branch governs) + "allegation_paragraphs": list(_chain_signature(ch)[1])} + if s1.get("question_flag"): + fields["question_flag"] = s1["question_flag"] + if s1.get("gate_places"): + fields["gate_places"] = list(s1["gate_places"]) + if s1.get("row_mismatch"): + fields["row_mismatch"] = _row_mismatch_result(s1["row_mismatch"]) + fields.update(eq or {"pairing": None, "branch": "unknown", "max_years": None, + "max_penalty_lt_10": None, + "needs": alias_needs(moffense, rstate, od, facts), + "matcher_flags": offence_flags(moffense, rstate, od, facts)}) + if eq is None and aliases_read(moffense, rstate, od, facts): + fields["aliases_read"] = aliases_read(moffense, rstate, od, facts) + if element_readings: + fields["element_readings"] = element_readings + if rehab: + fields.update({"rehab_status": rehab["status"], + "rehab_provision": rehab["provision"], + "elapsed": rehab["elapsed"], + "earliest_deemed": rehab.get("earliest_deemed"), + "earliest_apply": rehab.get("earliest_apply")}) + if rehab_alt: + fields.update({"rehab_status_alt": rehab_alt["status"], + "rehab_provision_alt": rehab_alt["provision"]}) + if clock_rule: + fields["rehab_clock"] = clock_rule + if absentia_open: + fields.update({"rehab_status": "clock-not-started", "rehab_provision": None, + "earliest_deemed": None, "earliest_apply": None, + "rehab_if_served": rehab_if_served}) + fields["youth_screen"] = ys + if yneeds: + fields["needs"] = list(fields.get("needs") or []) + yneeds + if rn_ids: + fields["read_next"] = rn_ids + if held: + fields["held"] = [_held_id(h) for h in held] + caveats = [c.get("id") for c in (entry or {}).get("policy_caveats") or [] + if isinstance(c, dict)] + if caveats: + fields["policy_caveats"] = caveats + fields.update(decisive_missing=decisive, decisive_checked=checked, + **({"decisive_whole_answer_only": whole} if whole else {}), + narrative=narrative.result_field(nar_allg, language, nar_held), + confidence=_chain_confidence(ch), + completion_basis=("sentence_completed" if sc_d else + None if proj is None else + "if-served-as-imposed" if proj["completion"] else + "not-projected")) + if proj is not None: + fields["if_served_as_imposed"] = proj + if detail == "card": + card = _record_card(ch, offense, disposition, od, cd_d, sd_d, today_d, proj, + decisive, checked, assumed, whole=whole) + return "\n".join(card + [result_line(tool="canlex_screen_record", detail="card", + **fields)]) + out.append(result_line(tool="canlex_screen_record", **fields)) + return _with_superseded("\n".join(out)) + + +def _clocks(basis, unknown, completed, offence_date, rehab_clock=None): + """[(basis, date)] for step 3: the conviction clock from sentence + completion, the act clock from the offence date; both when the + conviction question is unsettled. A disposition entry whose rehab_clock + is 'not-started-unless-served' (a conviction in absentia; roadmap T2.7, + held for review) starts no conviction clock: the memo says why.""" + clocks = [] + if basis == "foreign-conviction" or unknown: + if completed and rehab_clock != _ABSENTIA_RULE: + clocks.append(("foreign-conviction", completed)) + if basis == "foreign-act" or unknown: + if offence_date: + clocks.append(("foreign-act", offence_date)) + return clocks + + +def _basis_label(basis): + return ("conviction branch (s. 36(1)(b)/(2)(b)), clock from sentence completion" + if basis == "foreign-conviction" else + "act branch (s. 36(1)(c)/(2)(c)), clock from the offence date") + + +# ---- the pure chain (roadmap T4.1) ------------------------------------------ +# One foreign record entry through steps 1-3 as fields, without text: the +# memo renders it (canlex_screen_record), and the decisive-facts re-runs call +# it with one missing fact changed. It is built on the shared step functions +# (_step1, _step2 -- the rule canlex_us_equivalency and canlex_screen_history +# apply too) and rehab.assess; it adds no rule of its own. + +def _record_date_warnings(od, cd, sd, completed, today): + """The conviction and sentence dates against the others (roadmap T4.1, + T4.5): out of order or in the future is said, never computed on.""" + out = [] + for bad, why in ((cd and od and cd < od, f"the conviction date {cd} precedes the offence date"), + (cd and completed and cd > completed, + f"the conviction date {cd} is after the sentence completion"), + (cd and cd > today, f"the conviction date {cd} is after the assessment date"), + (sd and od and sd < od, f"the sentence date {sd} precedes the offence date"), + (sd and cd and sd < cd, f"the sentence date {sd} precedes the conviction date"), + (sd and completed and sd > completed, + f"the sentence date {sd} is after the sentence completion"), + (sd and sd > today, f"the sentence date {sd} is after the assessment date")): + if bad: + out.append(why) + return out + + +def _chain_step3(eq, tied, cands, clocks, count, clean, person, today_d, held_open=False): + """Step 3 of the chain as data: (step3, rehab, rehab_alt, single_summary). + step3["kind"]: 'tie' | 'none' | 'pr' | 'single-summary' | 'open' | + 'depends' | 'settled' | 'held-open' | 'no-equivalent', with each footing's + rehab.assess result -- the rules canlex_screen_record has always printed.""" + from canlex.rehab import assess + + def run(b, lt, clock): + return assess(b, lt, count, 0, clean, status=person, completed=clock, today=today_d) + single_summary = bool(eq and not tied and eq["branch"] == "criminality-two-plus-only" + and count != "two-plus-summary-only") + rehab = rehab_alt = None + if eq and tied: + rows = [] + for p, f in cands: + if f["branch"] in ("none", "not-inadmissible-pr"): + res = "nothing" + elif f["max_penalty_lt_10"] is None: + res = "not-computed" + else: + res = [(b, run(b, f["max_penalty_lt_10"], clock)) for b, clock in clocks] + rows.append((p, f, res)) + s3 = {"kind": "tie", "rows": rows} + elif eq and eq["branch"] == "none": + s3 = {"kind": "none"} + elif eq and eq["branch"] == "not-inadmissible-pr": + s3 = {"kind": "pr"} + elif single_summary: + s3 = {"kind": "single-summary"} + elif eq and eq["branch"] in _OPEN: + rows = [] + for c in eq["tran_candidates"]: + if c["branch"] in ("none", "not-inadmissible-pr"): + res = "nothing" + elif c["branch"] == "criminality-two-plus-only" and count != "two-plus-summary-only": + res = "single-summary" + elif c["max_penalty_lt_10"] is None and c["branch"] != "criminality-two-plus-only": + res = "not-computed" + else: + lt = True if c["branch"] == "criminality-two-plus-only" else c["max_penalty_lt_10"] + res = [(b, run(b, lt, clock)) for b, clock in clocks] + rows.append((c, res)) + s3 = {"kind": "open", "rows": rows} + elif eq and eq["max_penalty_lt_10"] is None and eq["branch"] != "criminality-two-plus-only": + s3 = {"kind": "depends", + "rows": [(lt10, label, [(b, run(b, lt10, clock)) for b, clock in clocks]) + for lt10, label in ((True, "if the equivalent carries LESS than 10 years"), + (False, "if the equivalent carries 10 years or more"))]} + elif eq: + # a summary-only equivalent is below 10 years by definition, whatever + # structured maximum the curated row carries + lt10 = True if eq["branch"] == "criminality-two-plus-only" else eq["max_penalty_lt_10"] + results = [(b, clock, run(b, lt10, clock)) for b, clock in clocks] + s3 = {"kind": "settled", "lt10": lt10, "results": results} + rehab = results[0][2] if results else None + rehab_alt = results[1][2] if len(results) > 1 else None + else: + s3 = {"kind": "held-open" if held_open else "no-equivalent"} + return s3, rehab, rehab_alt, single_summary + + +def _quantity_context(facts, disposition): + """The text read for an amount the offence text does not state (round H1, + GQ-5; screening.quantity_context_open): the officer's facts -- where the + entry documentation tells them to write an element answer as ': + ', so 'quantity: over 3 kg' is the natural answer -- and the + disposition's own words ('convicted -- lab weight 10 kg'). It never + settles a quantity; it only opens one the offence text settled.""" + return " ; ".join(str(x) for x in (facts, disposition) if x and str(x).strip()) or None + + +def _chain_entry(offense, disposition, j, od, sc_d, person, today_d, cd=None, + other_record=False, count="one", value_over_5000=None, schedule=None, + facts=None, dob=None, age=None, render=False, tran_od=_AUTO, edata=_AUTO): + """The screening chain for ONE foreign record entry, as fields without + text (roadmap T4.1): match -> resolve -> assess. Step 1 (_step1), step 2 + (_step2: the pairing, the tie, the Tran maximum at the offence date or + its conviction / completion bound), step 3 (rehab.assess on each clock) + and the allegation paragraphs (_allegation_paras). j: _jurisdiction()'s + reading of the record. render=True adds step 2's text blocks (the memo). + tran_od: the date step 2 reads the maximum at, when it differs from od -- + a decisive-facts re-run tries each period of a straddled maximum without + inventing an act-branch clock. edata: the equivalency dataset (loaded + when not given).""" + from canlex.rehab import assess + from canlex.screening import alias_needs, aliases_read, has_military_cue + rstate = j["state"] + s1 = _step1(disposition, rstate, j["kind"], _record_places(j)) + entry, is_conviction, unknown = s1["entry"], s1["conviction"], s1["unknown"] + basis = "foreign-conviction" if is_conviction else "foreign-act" + clock_rule = (entry or {}).get("rehab_clock") if (is_conviction or unknown) else None + absentia = clock_rule == _ABSENTIA_RULE + absentia_held = bool(not absentia and (is_conviction or unknown) + and _ABSENTIA_WORDS.search(disposition or "")) + youth = bool(entry and entry["id"] in _YOUTH_ENTRIES) + ys, ylines, _, yneeds = _youth_screen(dob, age, od, sc_d, entry_youth=youth) + if edata is _AUTO: + edata = _load_equivalency() + # A court-martial disposition is the military cue a bare 'Article N' + # offence needs (critic, 2026-09-12); the offence line is unchanged. + moffense = (f"UCMJ {offense}" if has_military_cue(disposition) + and not has_military_cue(offense) else offense) + s2od = od if tran_od is _AUTO else tran_od + # a code whose reading the offence date or the specification decides is + # never replaced by a 'closest by meaning' guess (wave-3 verifier) + held_open = bool(edata is not None and any( + r["status"] in ("date-open", "element-open") + for r in aliases_read(moffense, rstate, s2od, facts))) + s2_facts = {"value_over_5000": value_over_5000, "schedule": schedule, "text": facts, + "quantity_text": _quantity_context(facts, disposition)} + s2 = (_step2(moffense, edata, rstate, s2_facts, s2od, person, completed=sc_d, + render=render, suggest_text=None if held_open else offense, convicted=cd) + if edata is not None else None) + element_readings = (_element_readings(moffense, edata, rstate, s2_facts, s2od, person, + completed=sc_d, convicted=cd) + if held_open and s2 and s2["fields"] is None else []) + eq = s2["fields"] if s2 else None + tied = bool(s2 and s2["tie"]["tied"]) + cands = s2["candidates"] if s2 else [] + clocks = _clocks(basis, unknown, sc_d, od, clock_rule) + s3, rehab, rehab_alt, single_summary = _chain_step3( + eq, tied, cands, clocks, count, not other_record, person, today_d, held_open) + absentia_open = bool(absentia and eq and not single_summary + and eq["branch"] not in ("none", "not-inadmissible-pr")) + r_if = rehab_if_served = None + if absentia_open: + lt_if = (True if eq["branch"] == "criminality-two-plus-only" + else eq.get("max_penalty_lt_10")) + if sc_d and lt_if is not None: + r_if = assess("foreign-conviction", lt_if, count, 0, not other_record, + status=person, completed=sc_d, today=today_d) + rehab_if_served = {k: r_if.get(k) for k in ("status", "provision", + "earliest_deemed", "earliest_apply")} + needs = (list(eq.get("needs") or []) if eq is not None + else alias_needs(moffense, rstate, s2od, facts)) + return {"j": j, "s1": s1, "entry": entry, "row": s1["row"], "verdict": s1["verdict"], + "is_conviction": is_conviction, "unknown": unknown, "basis": basis, + "act": s1.get("act") or _act_branch(entry, s1["verdict"], disposition), + "clock_rule": clock_rule, "absentia": absentia, "absentia_held": absentia_held, + "youth": youth, "ys": ys, "ylines": ylines, "yneeds": yneeds, "edata": edata, + "moffense": moffense, "held_open": held_open, "s2": s2, + "element_readings": element_readings, "eq": eq, + "pairing": s2["pairing"] if s2 else None, "tied": tied, "cands": cands, + "clocks": clocks, "single_summary": single_summary, "step3": s3, + "rehab": rehab, "rehab_alt": rehab_alt, "absentia_open": absentia_open, + "r_if": r_if, "rehab_if_served": rehab_if_served, "needs": needs, + "count": count, "other_record": other_record, "person": person, + "od": od, "sc_d": sc_d, "cd": cd, "today_d": today_d} + + +def _allegation_paras(branch, is_conviction, unknown, count, act_not_settled=False): + """The IRPA paragraphs the draft allegation names for a settled branch + (the rule _allegation_lines prints; tests/test_decisive_card.py holds + the two together): [(paragraph, 'conviction' | 'act')]. act_not_settled: + the act branch after an acquittal (disposition roadmap T1.5) -- no act + paragraph is drafted.""" + want_conv = is_conviction or unknown + want_act = ((not is_conviction) or unknown) and not act_not_settled + out = [] + if branch in ("serious-criminality", "criminality"): + cp, ap = (("36(1)(b)", "36(1)(c)") if branch == "serious-criminality" + else ("36(2)(b)", "36(2)(c)")) + if want_conv: + out.append((cp, "conviction")) + if want_act: + out.append((ap, "act")) + elif branch == "criminality-two-plus-only" and want_conv and count == "two-plus-summary-only": + out.append(("36(2)(b)", "conviction")) + return out + + +# ---- the DRAFT s. 44(1) narrative skeleton (roadmap T4.6) -------------------- +# The allegations the chain supports, one per reading it leaves open, for +# canlex.narrative to render in ENF 5 s. 13.1's form. Nothing here decides a +# branch: the paragraphs are _allegation_paras', the provision the pairing's +# curated report_provision (canlex.narrative.choose). +_NO_RELIEF = ("none", "no", "no relief", "nil", "n/a") +# A conviction IN Canada and relief (wave-4 verifier, 2026-09-15): IRPA s. +# 36(3)(b) -- inadmissibility 'may not be based on a conviction in respect of +# which a record suspension has been ordered ... under the Criminal Records +# Act'. A Canadian entry reads only its OWN relief, and only words naming that +# relief; canlex_screen's record-level relief (a foreign pardon, an +# expungement) is never copied onto it -- a Texas pardon suspends no Canadian +# record. (G2, 2026-09-22: the words must themselves name the record suspension +# or pardon -- Parole Board or Criminal Records Act words are no longer wrapped +# as 'record suspension (...)', REL-12. _CANADA_RELIEF_WORDS no longer admits a +# relief; it stays defined and registered because the approved registry holds +# it, and a relief key it matches that names no record suspension or pardon +# gets the flag's own wording -- _canada_relief.) +_CANADA_RELIEF_WORDS = re.compile( + r"\brecord suspension|\bcriminal records act\b|\bparole board of canada\b|" + r"\bnational parole board\b", re.I) +CANADA_RELIEF_NOT_APPLIED = ( + "entry #{n} (a conviction in Canada): {what} is not read as a record suspension -- " + "IRPA s. 36(3)(b) reaches a conviction in Canada only through 'a record suspension' " + "ordered 'under the Criminal Records Act'; the conviction is screened as it stands. " + "Give the entry its own relief ('record suspension ordered ', on or after " + "the offence date) if one was ordered for this conviction and stands, and -- where the " + "relief is given in the relief key -- give the disposition as the conviction words alone " + "('convicted', 'convicted 2005'): any other clause there (a sentence, an appeal, a " + "later conviction) leaves the key unread (H2, 2026-09-22)") + + +# (the verification fixes of 2026-09-22, F1: LEGAL-6) a pardon under the royal prerogative +# of mercy on a conviction in Canada: the card says the ground is NOT SETTLED +CANADA_PREROGATIVE_NOTE = ( + "entry #{n} (a conviction in Canada): {what} names a pardon under the royal prerogative of " + "mercy or Criminal Code s. 748, not a record suspension under the Criminal Records Act -- " + "whether the conviction still grounds inadmissibility is NOT SETTLED, and the entry is " + "screened as the conviction it names -- the record's counts and s. 17 clock read it (the " + "full memo quotes IRPA s. 36(3)(b) and Criminal Code s. 748)") +# G2 (2026-09-22): the card notes for the other NOT SETTLED relief readings -- a 'pardon' +# dated after 2012-03-12 (AI-4), and a record suspension a later Canadian conviction on the +# record may have ended (REL-5) +CANADA_RELIEF_OPEN_NOTE = { + "pardon-after-2012": ( + "entry #{n} (a conviction in Canada): {what} names a pardon dated after 2012-03-12 and " + "no record suspension -- it may be a record suspension by the old name or a pardon under " + "the royal prerogative of mercy or Criminal Code s. 748; whether the conviction still " + "grounds inadmissibility is NOT SETTLED: the entry is screened here as the conviction " + "it is, and if the pardon is a record suspension that stands, no s. 36 ground rests on " + "it (the full memo quotes the Criminal Records Act and IRPA s. 36(3)(b))"), + # H2 (2026-09-22): an undated pardon (G2-REL-A), a relief dated before the Criminal + # Records Act as the corpus carries it (G2-REL-B), an undated record suspension the + # entry's own dates cannot place (G2-REL-E) + "pardon-undated": ( + "entry #{n} (a conviction in Canada): {what} names a pardon and gives no date -- every " + "reading of its date may fall after 2012-03-12, so it may be a record suspension by " + "the old name, a pardon under the royal prerogative of mercy or Criminal Code s. 748, " + "or one ordered for another conviction; whether the conviction still grounds " + "inadmissibility is NOT SETTLED: the entry is screened here as the conviction it is, " + "and if the pardon is a record suspension for this conviction that stands, no s. 36 " + "ground rests on it (the full memo quotes the Criminal Records Act and IRPA " + "s. 36(3)(b))"), + "relief-before-cra": ( + "entry #{n} (a conviction in Canada): {what} names a pardon or record suspension dated " + "before the Criminal Records Act as the CanLex corpus carries it -- the corpus gives " + "the Act no enactment date and the earliest date its own history lines carry is " + "'R.S., 1985, c. C-47'; whether the conviction still grounds inadmissibility is NOT " + "SETTLED: the entry is screened here as the conviction it is, and if a record " + "suspension under that Act stands, no s. 36 ground rests on it (the full memo quotes " + "the Act and IRPA s. 36(3)(b))"), + "relief-undated": ( + "entry #{n} (a conviction in Canada): {what} says a record suspension was ordered but " + "gives no date, and this entry carries a date to place one against -- one ordered for " + "an earlier conviction is not this conviction's (Criminal Records Act s. 4(1)); " + "whether the conviction still grounds inadmissibility is NOT SETTLED: the entry is " + "screened here as the conviction it is, and if the record suspension is this " + "conviction's and stands, no s. 36 ground rests on it (the full memo quotes the Act " + "and IRPA s. 36(3)(b))"), + "later-conviction": ( + "entry #{n} (a conviction in Canada): its record suspension ({what}) may have ceased to " + "have effect or been revoked -- {later} on this record may be a later conviction in " + "Canada (Criminal Records Act ss. 7.2(a), 7(a)); whether it still stands is NOT SETTLED: " + "the entry is screened here as the conviction it is, and if the record suspension " + "still stands, no s. 36 ground rests on it (IRPA s. 36(3)(b); the full memo quotes " + "both sections)"), +} + + +def _later_entries_text(later): + """'entry #2' / 'entries #2, #3' for the Canadian entries that may be later + convictions (G2, REL-5).""" + later = list(later or []) + return ("entr" + ("y " if len(later) == 1 else "ies ") + + ", ".join(f"#{n}" for n in later)) + + +def _canada_card_relief_notes(rows, relief_flags): + """The card notes for the Canadian entries' relief that the relief flags do + not already carry: a prerogative pardon in the disposition (LEGAL-6), a + 'pardon' dated after 2012-03-12 (AI-4), a record suspension a later + Canadian conviction may have ended (REL-5), and a relief key the tool + applied that does not stand read with the disposition's own words (REL-3, + REL-11) -- G2, 2026-09-22.""" + notes = [] + for r in rows: + if not r["canada"] or any(f.startswith(f"entry #{r['n']} ") for f in relief_flags): + continue + cd = r["canada"]["cd"] + kind, what = cd.get("relief_open"), f"its disposition ('{r['disposition']}')" + own, key = r["canada"].get("relief_words") or (None, None) + if kind == "prerogative": + notes.append(CANADA_PREROGATIVE_NOTE.format(n=r["n"], what=what)) + elif kind in CANADA_RELIEF_OPEN_NOTE and kind != "later-conviction": + # AI-4, and H2's undated pardon, pre-Act relief and undated record suspension + notes.append(CANADA_RELIEF_OPEN_NOTE[kind].format(n=r["n"], what=what)) + elif kind == "later-conviction": + notes.append(CANADA_RELIEF_OPEN_NOTE[kind].format( + n=r["n"], what=f"'{r['disposition']}'", + later=("another conviction (other_record)" + if cd.get("relief_later") == ["other_record"] + else _later_entries_text(cd.get("relief_later"))))) + elif key is not None and not cd["record_suspension"]: + notes.append(CANADA_RELIEF_NOT_APPLIED.format( + n=r["n"], what=f"its relief ('{key}') read with its disposition ('{own}')")) + return notes + + +def _no_relief(words): + return str(words).strip().lower() in _NO_RELIEF + + +def _canada_relief(n, own, record_level, od=None, cdate=None, today=None, completed=None): + """(the relief words a conviction in Canada reads, flag or None): its own + relief when the words alone are ONE naming clause on the relief reader's + allowlist grammar (rehab.relief_reading: 'record suspension ordered + 2019-03-02', 'pardon granted 2009', 'pardoned 1998', then nothing but 'not + revoked') dated on or after the entry's offence and conviction dates and its + sentence completion and not after today (od, cdate, completed, today), or + when they name a prerogative pardon + (read by the disposition as NOT SETTLED); nothing else -- other words, or a + record-level relief, print the flag instead and the conviction is screened + as it stands. The disposition then reads these words TOGETHER with its own + (rehab.canada_disposition), so a revocation there still ends the relief.""" + from canlex.rehab import prerogative_pardon, relief_dates_stand, relief_reading + if own is not None and not _no_relief(own): + w = str(own).strip() + # (the code audit of 2026-09-21, HELD: a record suspension the words say is + # refused, pending, revoked or ceased is not one in force: the flag prints and the + # conviction is screened as it stands) + # G2 (2026-09-22, HELD): the key stands only on the allowlist grammar, dated on or + # after the offence (REL-11). F1 (BI-5) let the key through whenever the + # disposition's word-list reader said True, so 'pardon granted 1998 - rev. 2004', + # 'Pardon granted - N' and 'pardon 1999-2004' answered 'nothing on this record + # engages s. 36' (REL-2); and words naming no record suspension or pardon were + # wrapped as 'record suspension ()', so 'Parole Board of Canada 2016 - day + # parole' stood as one (REL-12). Now such words print the flag. + # REL-15 (J2, 2026-09-22): a relief key naming a STANDING record suspension beside a + # prerogative pardon ('record suspension ordered 2015; conditional pardon 2010') is + # the record suspension IRPA s. 36(3)(b) turns on, so the reading is taken first and + # the prerogative note prints only where no record suspension stands + reading = relief_reading("", w) + stands = (reading["stands"] and reading["name"] == "record suspension" + and relief_dates_stand(reading["date"], od, cdate, today, completed)) + if prerogative_pardon(w) and not stands: + return w, CANADA_PREROGATIVE_NOTE.format(n=n, what=f"its relief ('{w}')") + if reading["stands"] and relief_dates_stand(reading["date"], od, cdate, today, + completed): + return w, None + board_only = bool(_CANADA_RELIEF_WORDS.search(w)) and not reading["named"] + return None, CANADA_RELIEF_NOT_APPLIED.format( + n=n, what=f"its relief ('{w}')" + (", which names no record suspension or pardon," + if board_only else "")) + if own is None and record_level is not None and not _no_relief(record_level): + return None, CANADA_RELIEF_NOT_APPLIED.format( + n=n, what=f"the record-level relief ('{str(record_level).strip()}')") + return None, None + + +def _is_canada_entry(ent, country): + """A conviction IN Canada -- the test _history_rows applies.""" + j = _jurisdiction(ent.get("state"), ent.get("country") or country) + return (j["kind"] == "canada" + or str(ent.get("jurisdiction") or "").strip().lower() == "canada") +_NARRATIVE_WHY = { + "depends": "the value, the substance schedule or the elements decide", + "unknown": "no Canadian equivalent identified", + "tran-open": "the offence date decides the maximum", + "limb-open": "the Canadian provision is open", + "tie": "two pairings match equally", +} + + +def _narrative_place(state, country): + """The place the record gives (state or province, country), or None.""" + parts = [str(x).strip() for x in (state, country) if x and str(x).strip()] + return ", ".join(parts) or None + + +def _narrative_reading(eq, c=None): + """One reading of a step-2 result as canlex.narrative.choose reads it: the + maximum at commission, the limb, whether an inchoate rule applies, and + whether an element resolver chose the provision. Wave-4 verifier + (2026-09-15): also the last day the offence can have been committed on + this reading ('latest': the reading's own period end, else the offence + date or the date bounding it), the date today's provision came into force + where that is after it ('nif_from' -- per reading, not only for a settled + result: a limb- or period-open reading before the Cannabis Act is not + proposed the Cannabis Act), and the curated history row whose maximum at + commission is HIGHER than the one this reading applies ('higher': + methodology (b) applied the lesser; Tran read strictly gives the row's -- + the skeleton brackets both).""" + src = c if c is not None else eq + limb = (c.get("limb") if c is not None else None) or eq.get("limb") + basis = str(eq.get("assessed_basis") or "") + dated = basis == "offence-date" or basis.startswith("inferred-from-") + latest = ((c.get("until") if c is not None else None) + or (eq.get("assessed_at") if dated else None)) + nif = [n["from"] for n in eq.get("not_in_force_at_commission") or [] + if n.get("from") and (c is None or (latest and str(latest) < n["from"]))] + higher = None + if latest and not (c is not None and c.get("reading_basis")): + mx = src.get("max_years") + higher = next((h for h in eq.get("at_commission_higher") or [] + if h.get("limb") in (limb, None) and h.get("until") + and str(latest) <= h["until"] and h.get("at_commission") is not None + and (mx is None or h["at_commission"] > mx)), None) + return {"max_years": src.get("max_years"), "limb": limb, + "inchoate": (bool(c.get("inchoate_reading")) if c is not None + else bool(eq.get("inchoate"))), + "not_in_force": bool(nif), "nif_from": min(nif) if nif else None, + "latest": str(latest) if latest else None, "higher": higher, + "resolved": bool(eq.get("resolver"))} + + +def _narrative_allegations(eq, pairing, cands, tied, is_conviction, unknown, count, base, + act=None): + """(allegations, notes) for one foreign entry's chain result: one + allegation per IRPA paragraph _allegation_paras names, and per reading a + tie, a period of commission or a limb leaves open (each with its + condition); a note where nothing grounds an allegation. base: the + entry's offence, dates and place. act: step 1's _act_branch -- after an + acquittal the act paragraph gets a note, never a skeleton (disposition + roadmap T1.5).""" + from canlex import narrative + ans = _act_not_settled(act) + if (act or {}).get("held"): + return [], [narrative.NO_SKELETON["held"]] # round 4 (RP-04) + if eq is None: + return [], [narrative.NO_SKELETON["unknown"]] + br = eq.get("branch") + if br == "tie": + if not (tied and cands): + return [], [narrative.NO_SKELETON["not-settled"].format(why=_NARRATIVE_WHY["tie"])] + need = (eq.get("needs") or [{}])[0] + out, notes = [], [] + for p, f in cands: + opt = _option_label(need, p) + lab = f"if {opt}" + ("" if opt == p["id"] else f" ({p['id']})") + sub, sub_notes = _narrative_allegations(f, p, [], False, is_conviction, unknown, + count, base, act=act) + for a in sub: + a["condition"] = lab + (f"; {a['condition']}" if a.get("condition") else "") + out += sub + notes += [f"{lab}: {x}" for x in sub_notes] + return out, notes + readings = ([(f"if {_reading(c, subject=True)}", c) for c in eq.get("tran_candidates") or []] + if br in _OPEN else [(None, None)]) + out, notes = [], [] + for cond, c in readings: + b = c["branch"] if c is not None else br + pre = f"{cond}: " if cond else "" + if b in ("serious-criminality", "criminality"): + rd = _narrative_reading(eq, c) + lc = [] if rd["limb"] else (eq.get("limb_candidates") or []) + for para, kind in _allegation_paras(b, is_conviction, unknown, count): + tag = (("if the disposition is a conviction" if kind == "conviction" else + "if it is NOT a conviction (act branch, on the facts)") if unknown + else None) + if kind == "act" and ans: + rq = (act or {}).get("routed_question") + notes.append(pre + (narrative.NO_SKELETON["act-not-settled-routed"].format( + para=para, label=(act or {}).get("label") or "policy", question=rq) + if rq else narrative.NO_SKELETON["act-not-settled"].format( + para=para, label=(act or {}).get("label") or "policy"))) + continue + out.append(dict(base, paragraph=para, kind=kind, + en_text=_allegation_text(para), + condition="; ".join(x for x in (cond, tag) if x) or None, + pairing=pairing, choice=narrative.choose(pairing, rd, lc), + basis=eq.get("assessed_basis"))) + elif b == "criminality-two-plus-only": + if count == "two-plus-summary-only" and (is_conviction or unknown): + out.append(dict(base, paragraph="36(2)(b)", kind="two", + en_text=_allegation_text("36(2)(b)-two"), condition=cond, + pairing=pairing, choice=None, + entries=[dict(base, pairing=pairing)])) + else: + notes.append(pre + narrative.NO_SKELETON["single-summary"]) + elif b == "not-inadmissible-pr": + notes.append(pre + narrative.NO_SKELETON["pr"]) + elif b == "none": + notes.append(pre + narrative.NO_SKELETON["none"]) + else: + notes.append(pre + narrative.NO_SKELETON["not-settled"].format( + why=_NARRATIVE_WHY.get(b, b))) + return out, notes + + +def _narrative_held(allegations): + """True when an allegation's pairing carries no report_provision in this + deployment (a composed release on the approved curated JSON).""" + from canlex import narrative + pairs = [a.get("pairing") for a in allegations if a["kind"] != "two"] + pairs += [e.get("pairing") for a in allegations if a["kind"] == "two" + for e in a.get("entries") or []] + return any(p and not narrative.provisions(p) for p in pairs) + + +def _history_narrative(rows, ans): + """(allegations, notes, held) for canlex_screen_history's skeleton: each + foreign entry's allegations (_narrative_allegations), one two-offence + allegation over the summary-only convictions when the record-level count + is two-plus-summary-only, and a note for each Canadian entry.""" + from canlex import narrative + out, notes, two = [], [], [] + count = ans["count"] + for r in rows: + if r["canada"]: + notes.append(narrative.NO_SKELETON["canada"].format(n=r["n"])) + continue + eq = r["eq"] + base = {"entry": r["n"], "offence": r["offense"], + "offence_date": r["offence_date"].isoformat() if r["offence_date"] else None, + "conviction_date": (r["conviction_date"].isoformat() + if r.get("conviction_date") else None), + "place": _narrative_place(r.get("state"), r.get("country"))} + if (eq or {}).get("branch") == "criminality-two-plus-only": + if count == "two-plus-summary-only" and (r["conviction"] or r["unknown"]): + two.append(dict(base, pairing=r.get("_pairing_obj"))) + elif not ans["occurrence_unsettled"]: + notes.append(f"Entry #{r['n']}: " + narrative.NO_SKELETON["single-summary"]) + continue + sub, sub_notes = _narrative_allegations( + eq, r.get("_pairing_obj"), r.get("_cands") or [], + (eq or {}).get("branch") == "tie", r["conviction"], r["unknown"], + count or "one", base, act=r.get("act")) + out += sub + notes += [f"Entry #{r['n']}: {x}" for x in sub_notes] + if ans["occurrence_unsettled"]: + notes.append(narrative.NO_SKELETON["occurrence"]) + if two: + out.append({"entry": None, "paragraph": "36(2)(b)", "kind": "two", + "en_text": _allegation_text("36(2)(b)-two"), "condition": None, + "pairing": None, "choice": None, "entries": two, "offence": None}) + return out, notes, _narrative_held(out) + + +_NOT_SETTLED_BRANCHES = ("tie", "tran-open", "limb-open", "depends", "unknown") + + +def _rehab_signature(s3, absentia_open): + """The rehabilitation answer of a chain run as the decisive facts + compare it: each footing's status, or why there is none.""" + if absentia_open: + return ("clock-not-started",) + k = s3["kind"] + if k in ("none", "pr", "single-summary"): + return ("not-reached",) + if k == "settled": + return tuple(r["status"] for _, _, r in s3["results"]) or ("not-computed",) + return ("not-settled",) if k in ("tie", "open", "depends") else ("not-computed",) + + +def _paragraph_signature(eq, is_conviction, unknown, count, act_not_settled=False): + br = (eq or {}).get("branch") + if eq is None or br in _NOT_SETTLED_BRANCHES: + return ("not-settled",) + paras = _allegation_paras(br, is_conviction, unknown, count) + # disposition roadmap T1.5: an act paragraph after an acquittal is not settled + if act_not_settled and any(kind == "act" for _, kind in paras): + return ("not-settled",) + return tuple(p for p, _ in paras) + + +def _chain_signature(ch): + """(branch, allegation paragraphs, rehabilitation statuses): what a + decisive fact must change (roadmap T4.1).""" + eq = ch["eq"] + return ((eq or {}).get("branch", "unknown"), + _paragraph_signature(eq, ch["is_conviction"], ch["unknown"], ch["count"], + _act_not_settled(ch.get("act"))), + _rehab_signature(ch["step3"], ch["absentia_open"])) + + +def _reading_signature(ch, readings): + """The signature the chain gives when step 2 is answered by these + readings (a limb, a period, a tie member) -- the same step-3 and + allegation rules applied to their branch.""" + branches = {r.get("branch") for r in readings} + if len(branches) != 1 or None in branches: + return ("limb-open", ("not-settled",), ("not-settled",)) + branch = branches.pop() + if branch in _NOT_SETTLED_BRANCHES: + return (branch, ("not-settled",), ("not-settled",)) + lts = {r.get("max_penalty_lt_10") for r in readings} + eq = {"branch": branch, "max_penalty_lt_10": lts.pop() if len(lts) == 1 else None, + "tran_candidates": None} + s3, _, _, single = _chain_step3(eq, False, [], ch["clocks"], ch["count"], + not ch["other_record"], ch["person"], ch["today_d"]) + absentia_open = bool(ch["absentia"] and not single + and branch not in ("none", "not-inadmissible-pr")) + return (branch, _paragraph_signature(eq, ch["is_conviction"], ch["unknown"], ch["count"], + _act_not_settled(ch.get("act"))), + _rehab_signature(s3, absentia_open)) + + +def _signature_dict(sig): + return {"branch": sig[0], "paragraphs": list(sig[1]), "rehab_status": list(sig[2])} + + +_ISO = re.compile(r"\d{4}-\d{2}-\d{2}") + + +def _period_date(period, cands=()): + """A date inside a period of commission -- a step-2 reading's + 'on or before U' / 'after A and on or before U' / 'after A', or a + statute code's 'from F' / 'until U' / 'F to U' -- for a re-run at that + date; None when the period names no date.""" + from canlex.screening import parse_date + c = next((c for c in cands if c.get("period") == period), None) + if c is not None: + if c.get("until"): + return parse_date(c["until"]) + if c.get("after"): + return parse_date(c["after"]) + datetime.timedelta(days=1) + return None + dates = [parse_date(d) for d in _ISO.findall(period or "")] + if not dates: + return None + p = (period or "").lower() + if p.startswith("after"): + return dates[0] + datetime.timedelta(days=1) + if p.startswith("from") or " to " in p: + return dates[0] + return dates[-1] # 'until U', 'on or before U' + + +# ---- THE ANSWER CHANGES WITH THESE FACTS (roadmap T4.1) ---------------------- +DECISIVE_HEAD = ("THE ANSWER CHANGES WITH THESE FACTS (not given -- the chain was re-run " + "with each value below and nothing else changed; ask before concluding):") +DECISIVE_NONE = ("THE ANSWER CHANGES WITH THESE FACTS: none found -- re-running the chain " + "with each missing fact CanLex can vary ({checked}) gives the same branch, " + "allegation paragraph and rehabilitation answer.") +DECISIVE_SUFFIX = " -- DECISIVE HERE: see THE ANSWER CHANGES WITH THESE FACTS above" +DECISIVE_QUESTIONS = { + "status": ("is the person a foreign national or a permanent resident? IRPA s. 36(2) " + "reaches foreign nationals only"), + "relief": ("was the conviction pardoned, expunged, sealed or otherwise relieved? Put the " + "relief in the disposition's words and re-run"), + "other_record": "is there any other conviction or committed act, anywhere?", + "age": ("how old was the person at the offence? Give date_of_birth (with the offence " + "date) or age_at_offence"), +} +# the assumed code (ASSUMED block) each re-run replaces +DECISIVE_ASSUMED = {"status": "status-foreign-national", "relief": "no-foreign-relief", + "other_record": "clean-record", "age": "adult-at-offence"} +# relief the chain tries: the disposition's words with these appended +DECISIVE_RELIEF = (("pardoned", "a pardon", "; later pardoned"), + ("expunged", "an expungement or sealing", "; later expunged")) +DECISIVE_AGES = ((16, "16 at the offence (a young person)"), + (10, "10 at the offence (under 12)")) +_DECISIVE_BRANCH = { + "serious-criminality": "SERIOUS CRIMINALITY (s. 36(1))", + "criminality": "CRIMINALITY (s. 36(2))", + "criminality-two-plus-only": "SUMMARY-ONLY EQUIVALENT (s. 36(2)(b) needs two)", + "none": "NO CANADIAN FEDERAL EQUIVALENT", + "not-inadmissible-pr": "NOT REACHED (a permanent resident, below s. 36(1))", + "depends": "NOT SETTLED (value, schedule or elements)", + "tie": "NOT SETTLED (two pairings match)", + "tran-open": "NOT SETTLED (the offence date decides the maximum)", + "limb-open": "NOT SETTLED (the Canadian provision is open)", + "unknown": "NO EQUIVALENT IDENTIFIED", +} +_DECISIVE_REHAB = {"not-reached": "rehabilitation not reached", + "not-settled": "rehabilitation not settled", + "not-computed": "rehabilitation dates not computed", + "clock-not-started": "rehabilitation clock not started (in absentia)"} + + +def _decisive_keep(trials, as_dict=_signature_dict): + """Keep only the facts whose values give different answers. + trials: [(fact, parameter, question, assumed_code, [(value, label, sig)])] + -> (decisive_missing, [every fact re-run]).""" + found, checked = [], [] + for fact, parameter, question, assumed, values in trials: + checked.append(fact) + if len({v[2] for v in values}) < 2: + continue + found.append({"fact": fact, "parameter": parameter, "question": question, + "assumed": assumed, + "values": [dict({"value": v, "label": lab}, **as_dict(sig)) + for v, lab, sig in values]}) + return found, checked + + +def _need_trials(ch, kw, run): + """The open result's own questions (a tie fact, the offence date of a + straddled maximum, the limb or quantity, an element or inchoate-role + answer, a statute code's specification or date), each answered with every + option: [(fact, parameter, question, [(value, label, sig)])].""" + eq = ch["eq"] or {} + cands_all = list(eq.get("tran_candidates") or []) + for _, f in ch["cands"]: + cands_all += list(f.get("tran_candidates") or []) + out, seen = [], set() + for need in ch["needs"]: + fact, par = need.get("fact"), need.get("parameter") + if (fact, par) in seen: + continue + seen.add((fact, par)) + vals = [] + for o in need.get("options") or []: + v, label = o.get("value"), o.get("label") or str(o.get("value")) + if par == "value_over_5000" and v is not None: + vals.append((v, label, run(value_over_5000=bool(v)))) + elif par == "schedule" and v: + vals.append((v, label, run(schedule=v))) + elif par == "facts" and v is not None: + ans = f"{fact}: {v}" + vals.append((ans, label, run(facts=f"{kw['facts']}; {ans}" if kw.get("facts") + else ans))) + elif par == "offence_date": + d = _period_date(v, cands_all) + if d is not None: + vals.append((v, label, run(tran_od=d))) + elif par is None and o.get("pairing"): + f = next((f for p, f in ch["cands"] if p["id"] == o["pairing"]), None) + if f is not None: + vals.append((o["pairing"], label, _reading_signature(ch, [f]))) + elif par is None and fact in ("limb", "quantity"): + sub = [c for c in cands_all if c.get("limb") == v] + if sub: + vals.append((v, label, _reading_signature(ch, sub))) + elif par is None and fact == "maximum-at-commission": + sub = [c for c in cands_all if c.get("reading_basis") in (v, None)] + if sub: + vals.append((v, label, _reading_signature(ch, sub))) + if len(vals) >= 2: + out.append((fact, par, need.get("question"), vals)) + return out + + +def _record_decisive(ch, kw, assumed): + """(decisive_missing, checked) for canlex_screen_record: the open + result's own questions, then each assumed fact (status, relief, another + record, the age) re-run with its plausible values. Kept only where the + values give a different branch, allegation paragraph or rehabilitation + status (_chain_signature).""" + sig0 = _chain_signature(ch) + + def run(**over): + return _chain_signature(_chain_entry(**dict(kw, **over))) + trials = [] + with _step_memo(): + trials += [(f, p, q, None, v) for f, p, q, v in _need_trials(ch, kw, run)] + if "status-foreign-national" in assumed: + trials.append(("status", "status", DECISIVE_QUESTIONS["status"], + DECISIVE_ASSUMED["status"], + [("foreign-national", "a foreign national (what the answer assumes)", + sig0), + ("permanent-resident", "a permanent resident", + run(person="permanent-resident"))])) + if "no-foreign-relief" in assumed: + vals = [("none", "none beyond the disposition's words (what the answer assumes)", + sig0)] + # disposition roadmap T1.7: relief can remove a conviction, never + # create one -- tried only when step 1 (row-aware) is a conviction + # or unsettled, as _history_decisive does; otherwise the fact is + # checked and not applicable (one value, never decisive) + for value, label, add in (DECISIVE_RELIEF if (ch["is_conviction"] or ch["unknown"]) + else ()): + c2 = _chain_entry(**dict(kw, disposition=f"{kw['disposition']}{add}")) + how = ((c2["entry"] or {}).get("id") or "no curated entry") + vals.append((value, f"{label} (step 1 reads '{kw['disposition']}{add}' as " + f"{how}, verdict {c2['verdict'].upper().replace('-', ' ')})", + _chain_signature(c2))) + trials.append(("relief", None, DECISIVE_QUESTIONS["relief"], + DECISIVE_ASSUMED["relief"], vals)) + if "clean-record" in assumed: + trials.append(("other_record", "other_record", DECISIVE_QUESTIONS["other_record"], + DECISIVE_ASSUMED["other_record"], + [(False, "none (what the answer assumes)", sig0), + (True, "another conviction or committed act", + run(other_record=True))])) + if "adult-at-offence" in assumed: + trials.append(("age", "date_of_birth", DECISIVE_QUESTIONS["age"], + DECISIVE_ASSUMED["age"], + [("adult", "an adult at the offence (what the answer assumes)", sig0)] + + [(a, label, run(age=a, dob=None)) for a, label in DECISIVE_AGES])) + return _decisive_keep(trials) + + +def _canada_decisive(fields, assumed, rerun): + """(decisive_missing, checked) for a record FROM CANADA: status and + another record, re-run through _canada_screen (rerun(**overrides) -> + its fields). The paragraph is the Canadian branch (s. 36(1)(a)/(2)(a)).""" + def sig(f): + cb = f.get("canada_branch") + return (f.get("branch"), (cb,) if cb else (), (f.get("rehab_status"),)) + sig0 = sig(fields) + trials = [] + with _step_memo(): + if "status-foreign-national" in assumed: + trials.append(("status", "status", DECISIVE_QUESTIONS["status"], + DECISIVE_ASSUMED["status"], + [("foreign-national", "a foreign national (what the answer assumes)", + sig0), + ("permanent-resident", "a permanent resident", + sig(rerun(person="permanent-resident")))])) + if "clean-record" in assumed: + trials.append(("other_record", "other_record", DECISIVE_QUESTIONS["other_record"], + DECISIVE_ASSUMED["other_record"], + [(False, "none (what the answer assumes)", sig0), + (True, "another conviction or committed act", + sig(rerun(other_record=True)))])) + return _decisive_keep(trials) + + +def _decisive_answer(v): + """One value's answer, as the block prints it.""" + br = _DECISIVE_BRANCH.get(v.get("branch"), str(v.get("branch")).upper()) + paras = v.get("paragraphs") or [] + if paras == ["not-settled"]: + al = "allegation not settled" + elif not paras: + al = "no s. 36 allegation" + else: + al = "allegation s. " + " or s. ".join(paras) + rs = [(_DECISIVE_REHAB.get(s) or f"rehabilitation {str(s).upper().replace('-', ' ')}") + for s in v.get("rehab_status") or []] + return f"{br}; {al}; {' / '.join(rs) or 'rehabilitation not computed'}" + + +def _whole_answer_only(checked, open_): + """The assumed facts (status, relief, another record, the age) re-run on + a result that is NOT SETTLED at step 2: the re-run compares the whole + answer, not each open reading (wave-4 verifier, 2026-09-15: on a tie or + an open limb, status and relief can change one reading's paragraphs and + still be listed as checked) -- [] when the result is settled.""" + if not open_: + return [] + return [f for f in checked + if f in ("status", "relief", "other_record", "age") or f.endswith(" relief")] + + +DECISIVE_OPEN_NOTE = ( + "NOTE -- NOT SETTLED at step 2: the re-runs of {facts} compare the answer as a whole, not " + "each open reading, so finding no change for them does not mean they cannot matter -- on " + "one of the readings they may (IRPA s. 36(2) reaches foreign nationals only; relief can " + "change what a conviction grounds). Re-run once the open fact is answered.") + + +def _decisive_lines(found, checked, answer=_decisive_answer, whole=()): + """The THE ANSWER CHANGES WITH THESE FACTS block for the top of a memo + ([] when nothing was re-run); whole: _whole_answer_only's facts, named in + a NOTE.""" + if not checked: + return [] + note = [DECISIVE_OPEN_NOTE.format(facts=", ".join(whole))] if whole else [] + if not found: + return ["", DECISIVE_NONE.format(checked=", ".join(checked))] + note + lines = ["", DECISIVE_HEAD] + for d in found: + par = (f" [{d['parameter']}]" if d.get("parameter") and d["parameter"] != d["fact"] + else "") + lines.append(f"- {d['fact']}{par}: {d['question']}") + for v in d["values"]: + lines.append(f" - {v['label']}: {answer(v)}") + return lines + note + + +# ---- the confidence block (roadmap T4.2) ------------------------------------- + +def _chain_confidence(ch): + from canlex.screening import (confidence_block, step1_confidence, step2_confidence, + step3_confidence) + s1 = [step1_confidence(ch["entry"], ch["row"])] if ch["s1"]["available"] else None + if ch["tied"]: + s2 = [step2_confidence(p, f) for p, f in ch["cands"]] + else: + s2 = [step2_confidence(ch["pairing"], ch["eq"])] if ch["pairing"] else [] + return confidence_block(step1=s1, step2=s2, step3=step3_confidence()) + + +def _canada_confidence(pairing, fields): + from canlex.screening import confidence_block, step2_confidence, step3_confidence + s2 = [] + if pairing: + c = step2_confidence(pairing, None) + c["maximum_source"] = ("officer" if fields.get("max_source") == "officer" else + "pairing" if fields.get("max_source") else None) + s2 = [c] + return confidence_block(step1=[], step2=s2, step3=step3_confidence()) + + +# ---- completion IF SERVED EXACTLY AS IMPOSED (roadmap T4.5) ------------------ + +def _projection_lines(ch, sentence_imposed, sentence_date): + """(lines, info): the step-3 footing IF SERVED EXACTLY AS IMPOSED -- the + completion date Guide 5312's rules give the sentence imposed, and the + dates that completion would give, never a status (no deemed / eligible + verdict). ([], None) when a completion date is given, no sentence and + date are, or step 3 has nothing to rehabilitate.""" + from canlex.rehab import assess + from canlex.screening import (GUIDE_5312_CHUNK, GUIDE_5312_COURT, GUIDE_5312_RULES, + IF_SERVED_LABEL, MARGIN_MONTHS, projected_completion, + within_margin) + if ch["sc_d"] is not None or not sentence_imposed or sentence_date is None: + return [], None + if not (ch["is_conviction"] or ch["unknown"]) or ch["absentia"]: + return [], None + s3 = ch["step3"] + if s3["kind"] not in ("settled", "tie", "open", "depends"): + return [], None + p = projected_completion(sentence_imposed, sentence_date) + today = ch["today_d"] + info = {"sentence_date": sentence_date.isoformat(), "completion": None, + "if_administrative": None, "rule": p["rule"], "part": p["part"], + "blocked": p["blocked"], "earliest_apply": None, "earliest_deemed": None, + "deemed_if_below_10": None, "margin": False, "source": GUIDE_5312_CHUNK} + if p["blocked"]: + return [f"{IF_SERVED_LABEL}: no completion date projected -- {p['blocked']}. Give " + "sentence_completed."], info + d = p["date"] + info.update(completion=d.isoformat(), + if_administrative=(p["if_administrative"].isoformat() + if p["if_administrative"] else None)) + lines = [f"{IF_SERVED_LABEL}: the sentence imposed on {sentence_date.isoformat()} " + f"('{sentence_imposed}') would be completed on {d.isoformat()} -- '{p['part']}' " + f"ends last (Guide 5312, {GUIDE_5312_CHUNK}: '{GUIDE_5312_RULES[p['rule']]}')."] + if p["if_administrative"]: + lines.append(f" If the licence suspension was administrative and not imposed by the " + f"criminal court (Guide 5312 counts a driving prohibition where 'You are " + f"prohibited by the Criminal Court from driving'), it would be completed on " + f"{p['if_administrative'].isoformat()}.") + lt10 = s3.get("lt10") if s3["kind"] == "settled" else None + near = [] + for when in [d] + ([p["if_administrative"]] if p["if_administrative"] else []): + if when > today: + lines.append(f" Served as imposed, the sentence would not be complete until " + f"{when.isoformat()}: no clock would have started.") + continue + r = assess("foreign-conviction", True if lt10 is None else lt10, ch["count"], 0, + not ch["other_record"], status=ch["person"], completed=when, today=today) + apply_from, deemed = r.get("earliest_apply"), r.get("earliest_deemed") + text = f" If completion on {when.isoformat()} is proved: " + parts = [] + if apply_from: + parts.append(f"an application could be made from {apply_from}") + if deemed: + parts.append(f"deemed rehabilitation would be reached on {deemed}" + + (" (only if the equivalent carries less than 10 years)" + if lt10 is None else "")) + lines.append(text + ("; ".join(parts) or "no date follows") + ". No verdict is " + "given on this footing -- give sentence_completed.") + if when == d: + info.update(earliest_apply=apply_from, earliest_deemed=deemed, + deemed_if_below_10=lt10 is None and bool(deemed)) + near += [x for x in (apply_from, deemed) + if x and within_margin(datetime.date.fromisoformat(x), today)] + if near: + info["margin"] = True + lines.append(f"MARGIN: {', '.join(dict.fromkeys(near))} " + f"{'is' if len(set(near)) == 1 else 'are'} within {MARGIN_MONTHS} months of " + f"the assessment date ({today.isoformat()}) -- a completion a little " + "earlier or later than imposed moves the answer.") + return lines, info + + +# ---- the result card (roadmap T4.7) ----------------------------------------- +CARD_FULL = ("Full memo: re-run with detail 'full' (the default) for every step, the draft " + "allegation, the next steps, the sources and the caveats.") +# the review of batch 1, round 4: a Canadian record whose words are not shown to be a +# conviction -- the card names each branch conditionally +CANADA_UNSETTLED_QUESTION = "is the Canadian disposition a conviction?" +# the verification fixes of 2026-09-22 (F1, LEGAL-6): a pardon under the royal prerogative +# of mercy is no record suspension, and the card asks what it does to the ground +CANADA_PREROGATIVE_QUESTION = ("is the conviction still a ground after the pardon under the royal " + "prerogative of mercy or Criminal Code s. 748?") +CANADA_CARD_UNSETTLED_ACTION = ( + "Action: NOT SETTLED -- {question}: if it is, a s. 44(1) report " + "on IRPA s. {para} (the removal path is in the full memo); if it is not, no s. 36 ground (the act " + "branches reach only acts outside Canada).") +# G2 (2026-09-22): a 'pardon' dated after 2012-03-12 (AI-4) and a record suspension another +# conviction on the record may have ended (CRA ss. 7.2(a), 7(a) -- REL-5) are NOT SETTLED +# too, each with its own question and conditional lines +CANADA_PARDON_AFTER_2012_QUESTION = ( + "is the conviction still a ground after the pardon dated after 2012-03-12 (a record " + "suspension by the old name, or a pardon under the royal prerogative of mercy)?") +CANADA_LATER_CONVICTION_QUESTION = ( + "has the record suspension ceased to have effect or been revoked after a later conviction " + "in Canada (Criminal Records Act ss. 7.2(a), 7(a))?") +# H2 (2026-09-22): the questions for the three readings it opens -- an UNDATED pardon whose +# date may fall after 2012-03-12 like a dated one's (G2-REL-A), a relief dated before the +# Criminal Records Act as the corpus carries it (G2-REL-B), and an undated record suspension +# the entry's own dates cannot place (G2-REL-E) +CANADA_PARDON_UNDATED_QUESTION = ( + "is the conviction still a ground after the pardon, whose date is not given (a record " + "suspension for this conviction, a pardon under the royal prerogative of mercy, or one " + "ordered for another conviction)?") +CANADA_BEFORE_CRA_QUESTION = ( + "is the conviction still a ground after the pardon or record suspension dated before the " + "Criminal Records Act as the corpus carries it (its earliest history line is 'R.S., 1985, " + "c. C-47'; the corpus gives the Act no enactment date)?") +CANADA_RELIEF_UNDATED_QUESTION = ( + "is the conviction still a ground after the record suspension, whose date is not given " + "(was it ordered for this conviction, after its sentence expired -- Criminal Records Act " + "s. 4(1))?") +CANADA_CARD_LATER_CONVICTION_ACTION = ( + "Action: NOT SETTLED -- {question}: if it has, a s. 44(1) report on IRPA s. {para} (the " + "removal path is in the full memo); if it has not, no s. 36 ground (IRPA s. 36(3)(b): the " + "record suspension stands).") +_PARDON_OPEN_LINES = { + "if": "if it is", "action": CANADA_CARD_UNSETTLED_ACTION, + "if_conv": "the conviction still grounds inadmissibility after the pardon", + "if_not": "If it does not: no s. 36 allegation on this conviction (step 1).", + "bottom": ("If the conviction no longer grounds inadmissibility after the pardon: no " + "s. 36 ground on it (step 1).")} +_RELIEF_OPEN_LINES = { + "if": "if it is", "action": CANADA_CARD_UNSETTLED_ACTION, + "if_conv": "the conviction still grounds inadmissibility after the relief", + "if_not": "If it does not: no s. 36 allegation on this conviction (step 1).", + "bottom": ("If the conviction no longer grounds inadmissibility after the relief: no " + "s. 36 ground on it (step 1).")} +_CANADA_RELIEF_OPEN = { + "prerogative": dict(_PARDON_OPEN_LINES, question=CANADA_PREROGATIVE_QUESTION), + "pardon-after-2012": dict(_PARDON_OPEN_LINES, question=CANADA_PARDON_AFTER_2012_QUESTION), + "pardon-undated": dict(_PARDON_OPEN_LINES, question=CANADA_PARDON_UNDATED_QUESTION), + "relief-before-cra": dict(_RELIEF_OPEN_LINES, question=CANADA_BEFORE_CRA_QUESTION), + "relief-undated": dict(_RELIEF_OPEN_LINES, question=CANADA_RELIEF_UNDATED_QUESTION), + "later-conviction": { + "question": CANADA_LATER_CONVICTION_QUESTION, "if": "if it has", + "action": CANADA_CARD_LATER_CONVICTION_ACTION, + "if_conv": "the record suspension has ceased to have effect or been revoked", + "if_not": ("If it still stands: no s. 36 allegation on this conviction (IRPA " + "s. 36(3)(b))."), + "bottom": ("If the record suspension still stands: no s. 36 ground on it (IRPA " + "s. 36(3)(b)).")}, +} +# The H3 fixes of 2026-09-22 (S17-INT-4). The card's Action is computed from the grounds +# that hold on EVERY reading of the record: an open quantity, an unresolved entry or an +# open s. 17 date does not remove a s. 44(1) report that a settled entry grounds and that +# no reading of IRPR s. 18(2) deemed rehabilitation takes away. IRPA s. 36(3)(c) removes +# the s. 36(1)(b)/(c) and (2)(b)/(c) grounds only for a person who 'satisfies the Minister +# that they have been rehabilitated' or who 'is a member of a prescribed class that is +# deemed to have been rehabilitated' -- being eligible to APPLY, or not knowing when the +# application date falls, cures nothing. The open fact is then a NOTE on the Dates line. +CARD_OPEN_FACT_NOTE = ("NOTE: {why} -- not settled, and no reading of it removes the " + "s. 44(1) report, which stands on every reading of this record") +# REG-7: IRPR s. 18(2) deemed rehabilitation reaches only the IRPA s. 36(1)(b) and (c) and +# (2)(b) and (c) matters -- 'convicted outside Canada', 'committed ... an act outside +# Canada'. A conviction IN Canada is cured by a record suspension (s. 36(3)(b)), not by +# time, so the one-line Dates summary says which entries its date reaches. +CARD_DEEMED_FOREIGN_ONLY = (" (the foreign entries only -- a conviction in Canada is cured " + "by a record suspension, IRPA s. 36(3)(b), not by IRPR s. 18(2))") +CARD_NOT_SETTLED_ACTION = ("Action: NOT SETTLED -- settle the deciding fact first; " + "a s. 44(1) report, a TRP and allowing the person to leave all " + "turn on it.") +CARD_NO_GROUND_ACTION = "Action: no s. 36 ground on this matter -- no s. 44(1) report on it." +CARD_DEEMED_ACTION = ("Action: deemed rehabilitated on the dates given (IRPR s. 18) -- no s. 36 " + "ground once the s. 18(2) conditions are confirmed; record them.") +CARD_REPORT_FN = ("Action: a s. 44(1) report on IRPA s. {paras}; or a temporary resident permit " + "(IRPA s. 24, the officer's discretion); or, at the person's request, withdrawal " + "of the application to enter and departure (IRPR s. 42(1): the officer 'shall " + "allow' it -- mandatory unless s. 42(2) applies, once a s. 44(1) report is being " + "prepared or has been prepared).") +CARD_REPORT_PR = ("Action: a s. 44(1) report on IRPA s. {paras} -- if the Minister is of the " + "opinion that the report is well-founded, the Minister may refer it to the " + "Immigration Division (IRPA s. 44(2)).") +# wave-4 verifier (2026-09-15): the record card names content held for review +CARD_HELD = (HELD_PREFIX + "the record's words reach CanLex content held for review " + "({entries}); each such entry is answered without it -- detail 'full' says what to " + "check before relying on it.") +# the review of batch 1, round 4 (RP-04 code guard): a record whose words reach held +# disposition content ahead of what is loaded -- or whose answering entry carries held +# acquittal readings or a held routed row -- gets no drafted allegation, skeleton or card +# report on any dataset; no legal characterisation is copied into code +ALLEGATION_HELD = ( + "Draft allegation: HELD FOR REVIEW -- the record's words reach CanLex content held for review " + "and not in this deployment ({entries}); no allegation paragraph is drafted until that content " + "is approved or the disposition is confirmed from the court record.") +CARD_HELD_ACTION = ( + "Action: HELD FOR REVIEW -- the record's words reach CanLex content held for review and not in " + "this deployment ({entries}); no s. 44(1) report is drafted on this record until that content is " + "approved or the disposition is confirmed from the court record.") +CARD_HISTORY_HELD = (" Entr(ies) {entries}: HELD FOR REVIEW -- no report is drafted on them (content not " + "in this deployment).") +CARD_REHAB_OPEN = " Rehabilitation on application (IRPA s. 36(3)(c)) is open from {date}." + + +def _step1_question(s1, disposition, act=None): + """The deciding question step 1 leaves open, for the cards: the record-wording + flag's, the state row's, or -- for a reversal on appeal whose words say neither + an acquittal nor a new trial -- REVERSAL_QUESTION (the review of batch 1, round 2).""" + from canlex.screening import _accent_fold + s1 = s1 or {} + q = (DISPOSITION_FLAG_QUESTIONS.get(s1.get("question_flag")) if s1.get("question_flag") + else (s1.get("row_mismatch") or {}).get("question")) + if (not q and (s1.get("entry") or {}).get("id") == "conviction-reversed-on-appeal" + and _act_not_settled(act) and not _REVERSAL_OUTCOME_WORDS.search(_accent_fold(disposition or ""))): + q = REVERSAL_QUESTION + if not q and not s1.get("entry") and s1.get("verdict") == "unknown": + # the review of batch 1, round 3: no entry matched -- the card says so and names + # each branch conditionally, as the memo drafts them + q = STEP1_UNKNOWN_QUESTION + if (not q and s1.get("entry") and s1.get("verdict") == "fact-specific" + and not _act_not_settled(act)): + # the review of batch 1, round 4: a curated FACT-SPECIFIC verdict with no wording + # question -- the card names the entry's deciding question and each branch + # conditionally, as the memo drafts them, never a plain report on both paragraphs + e = s1["entry"] + q = next((s for s in (_deciding_sentence(e.get("bottom_line") or ""), + _deciding_sentence(e.get("analysis") or "")) + if s.endswith("?")), STEP1_FACT_SPECIFIC_QUESTION) + q = re.sub(r"^deciding question:\s*", "", q, flags=re.I) + q = q[0].lower() + q[1:] + return q + + +def _question_act_text(act, act_paras, card_prefix="a s. 44(1) report on IRPA s. "): + """The non-conviction side of a conditional card action: the act-branch report + (with its policy caveat), NOT SETTLED after an acquittal, or no ground.""" + act = act or {} + label = act.get("label") or "policy" + if _act_not_settled(act): + return CARD_QUESTION_ACT_OPEN.format(label=label) + if not act_paras: + return CARD_QUESTION_NO_ACT + text = card_prefix + " or s. ".join(act_paras) + if act.get("act_branch") == "policy-caveat": + text += CARD_QUESTION_ACT_CAVEAT.format(label=label) + return text + + +def _card_action(person, sig, rehab, act=None, conv_paras=(), question=None, act_paras=None): + """The card's action line from the chain's answer. act: step 1's + _act_branch -- after an acquittal the act-branch report is NOT SETTLED + (disposition roadmap T1.5); a report resting on the act branch carries + its policy caveat (T1.10). conv_paras: on BOTH footings, the conviction + paragraphs the memo drafts conditionally, named with the deciding question + (the review of batch 1, R8).""" + branch, paras, rstat = sig + act = act or {} + if act.get("held"): + return CARD_HELD_ACTION.format(entries=act["held"]) # round 4 (RP-04) + if (_act_not_settled(act) and paras == ("not-settled",) + and branch not in _NOT_SETTLED_BRANCHES): + if act.get("footing") == "both" and conv_paras: + return CARD_BOTH_ACT_NOT_SETTLED_ACTION.format( + question=question or CARD_BOTH_QUESTION, paras=" or s. ".join(conv_paras), + label=act.get("label") or "policy") + if act.get("footing") == "act" and question: + return CARD_ROUTED_ACT_NOT_SETTLED_ACTION.format(question=question, + label=act.get("label") or "policy") + return CARD_ACT_NOT_SETTLED_ACTION.format(label=act.get("label") or "policy") + if paras == ("not-settled",) or "not-settled" in rstat or branch in _NOT_SETTLED_BRANCHES: + return CARD_NOT_SETTLED_ACTION + if not paras: + return CARD_NO_GROUND_ACTION + if rstat and all(s == "deemed-rehabilitated" for s in rstat): + return CARD_DEEMED_ACTION + if question and act.get("footing") == "both" and conv_paras and act_paras is not None: + # the review of batch 1, round 2: the question, and each branch conditionally + return CARD_QUESTION_ACTION.format(question=question, conv=" or s. ".join(conv_paras), + act=_question_act_text(act, act_paras)) + line = (CARD_REPORT_PR if person == "permanent-resident" else CARD_REPORT_FN).format( + paras=" or s. ".join(paras)) + if rehab and rehab.get("status") in ("eligible-to-apply", "apply-conditional") \ + and rehab.get("earliest_apply"): + line += CARD_REHAB_OPEN.format(date=rehab["earliest_apply"]) + if act.get("act_branch") == "policy-caveat" and act.get("footing") in ("act", "both"): + line += CARD_POLICY_CAVEAT.format(label=act.get("label") or "policy") + return line + + +def _card_changes(decisive, checked, whole=()): + tail = (f" While NOT SETTLED, {', '.join(whole)} {'was' if len(whole) == 1 else 'were'} " + "compared on the whole answer only, not reading by reading -- re-run once settled." + if whole else "") + if decisive: + return ("The answer changes with: " + + "; ".join(f"{d['fact']} (" + + " / ".join(str(v["label"]).split(" (")[0] for v in d["values"]) + + ")" for d in decisive) + + " -- each value's answer is in the full memo's top block." + tail) + if checked: + return f"The answer changes with: none found ({', '.join(checked)} re-run)." + tail + return None + + +def _card_dates(od, cd, sd, sc, today, proj): + comp = sc.isoformat() if sc else "not given" + if not sc and proj and proj.get("completion"): + comp += f" (if served exactly as imposed: {proj['completion']} -- not a finding)" + elif not sc and proj and proj.get("blocked"): + comp += " (not projected from the sentence imposed)" + return (f"Dates: offence {od.isoformat() if od else 'not given'}; conviction " + f"{cd.isoformat() if cd else 'not given'}; sentence " + f"{sd.isoformat() if sd else 'not given'}; completed {comp}; assessed " + f"{today.isoformat()}") + + +def _record_card(ch, offense, disposition, od, cd, sd, today, proj, decisive, checked, + assumed, whole=()): + """The result card (roadmap T4.7): about ten lines from the chain's + fields -- the result, or NOT SETTLED and the deciding fact; the step-1 + tier; the dates; the action; the facts that change the answer.""" + from canlex.commentary import row_tier + from canlex.screening import branch_text + sig = _chain_signature(ch) + entry, row, eq, pairing = ch["entry"], ch["row"], ch["eq"], ch["pairing"] + not_settled = sig[1] == ("not-settled",) or "not-settled" in sig[2] + act = ch.get("act") or {} + question = _step1_question(ch["s1"], disposition, act) + act_open = (_act_not_settled(act) + and (ch["eq"] or {}).get("branch") not in _NOT_SETTLED_BRANCHES) + branch = (ch["eq"] or {}).get("branch") + conv_paras = ([p for p, k in _allegation_paras(branch, True, False, ch["count"]) if k == "conviction"] + if (act_open or question) and act.get("footing") == "both" else []) + # the review of batch 1, round 2: a question on both footings names each branch + both_q = bool(question and conv_paras and act.get("footing") == "both" and not act_open) + act_paras = ([p for p, k in _allegation_paras(branch, False, False, ch["count"]) if k == "act"] + if both_q else None) + if not_settled: + why = ((CARD_BOTH_ACT_NOT_SETTLED_WHY.format(question=question or CARD_BOTH_QUESTION, + label=act.get("label") or "policy") + if act_open and conv_paras else + CARD_ROUTED_ACT_NOT_SETTLED_WHY.format(question=question, + label=act.get("label") or "policy") + if act_open and question and act.get("footing") == "act" else + ACT_NOT_SETTLED_WHY if act_open else None) + or (decisive[0]["question"] if decisive else None) + or next((n.get("question") for n in ch["needs"] if n.get("question")), None) + or "the Canadian equivalent") + result = f"Result: NOT SETTLED -- {why}" + elif both_q: + result = CARD_QUESTION_RESULT.format(question=question, + answer=_decisive_answer(_signature_dict(sig))) + else: + result = "Result: " + _decisive_answer(_signature_dict(sig)) + if entry: + mm = ch["s1"].get("row_mismatch") + tier = (f" ({row['state']} row, {row_tier(row)})" if row and not mm else + " (record wording leaves it open; no state row applied)" + if ch["s1"].get("question_flag") else + # disposition roadmap T1.11: the row selected by mechanism + f" ({_row_mismatch_line(mm, card=True)})" if mm else + " (no state row; entry-level verdict)") + s1 = (f"Step 1: {entry['names'][0]} -- verdict " + f"{ch['verdict'].upper().replace('-', ' ')}{tier}" + + _card_step1_clause(ch.get("act"))) + else: + s1 = ("Step 1: no curated entry matches the disposition's words -- verdict not settled" + + _card_step1_clause(ch.get("act"))) + if ch["s1"].get("flags"): + # disposition roadmap T1.6 / critic: one clause per record-wording flag + s1 += "; wording: " + "; ".join(_flag_lines(ch["s1"]["flags"], card=True, + places=ch["s1"].get("gate_places"))) + if ch["tied"]: + s2 = ("Step 2: TIE -- " + " or ".join(p["canadian_offence"] for p, _ in ch["cands"])) + elif pairing and eq: + mx = "" if eq.get("max_years") is None else f", maximum {_years(eq['max_years'])}" + s2 = f"Step 2: {pairing['canadian_offence']}{mx} -- {branch_text(eq['branch'])}" + else: + s2 = "Step 2: no curated pairing names the offence" + r = ch["rehab"] + if ch["absentia_open"]: + s3 = "Step 3: CLOCK NOT STARTED -- a conviction in absentia (ENF 14 s. 16)" + elif r: + s3 = "Step 3: " + _if_act_used(ch.get("act")) + _rehab_brief(r) + else: + s3 = "Step 3: " + _DECISIVE_REHAB.get(sig[2][0], "rehabilitation not computed") + lines = [f"# Result card -- {offense} / {disposition}", result, s1, + _card_dates(od, cd, sd, ch["sc_d"], today, proj), s2, s3, + _card_action(ch["person"], sig, r, ch.get("act"), conv_paras, question, act_paras)] + ch_line = _card_changes(decisive, checked, whole) + if ch_line: + lines.append(ch_line) + if ch["s1"].get("held"): + # the review of batch 1, round 4 (RP-04): the record card names the held content + lines.append(CARD_HELD.format(entries=_held_id(ch["s1"]["held"]))) + if assumed: + lines.append("Assumed (not given): " + ", ".join(assumed)) + lines.append(CARD_FULL) + return lines + + +def _canada_card(offense, disposition, fields, od, cd, sd, sc, today, decisive, checked, + assumed): + """The result card for a record FROM CANADA.""" + from canlex.rehab import CANADA_BRANCH_TEXT + cb = fields.get("canada_branch") + if cb == "not-settled": + result = "Result: NOT SETTLED -- " + ( + (fields.get("needs") or [{}])[0].get("question") or "the readings in the full memo") + action = CARD_NOT_SETTLED_ACTION + elif fields.get("conviction_unsettled") and fields.get("removal"): + # the review of batch 1, round 4: words not shown to be a conviction -- each branch + # conditionally, never a plain s. 36(1)(a) report + # (a prerogative pardon asks its own question -- LEGAL-6, 2026-09-22; so do a + # 'pardon' dated after 2012-03-12 and a record suspension a later conviction may + # have ended -- G2, AI-4 and REL-5) + ro = _CANADA_RELIEF_OPEN.get(fields.get("relief_open")) + q = ro["question"] if ro else CANADA_UNSETTLED_QUESTION + result = (f"Result: NOT SETTLED -- {q} ({ro['if'] if ro else 'if a conviction'}: " + f"{CANADA_BRANCH_TEXT.get(cb, cb)})") + action = (ro["action"] if ro else CANADA_CARD_UNSETTLED_ACTION).format(para=cb, question=q) + else: + result = f"Result: {CANADA_BRANCH_TEXT.get(cb, cb)}" + action = (f"Action: a s. 44(1) report on IRPA s. {cb} -- the removal path is in the " + "full memo." if fields.get("removal") else CARD_NO_GROUND_ACTION) + lines = [f"# Result card -- {offense} / {disposition} (a conviction in Canada)", result, + f"Step 1: a Canadian record -- verdict {str(fields.get('verdict')).upper()} " + f"({fields.get('canada_sentence') or 'sentence not given'})", + _card_dates(od, cd, sd, sc, today, None), + f"Step 3: {str(fields.get('rehab_status')).upper().replace('-', ' ')}", action] + ch_line = _card_changes(decisive, checked) + if ch_line: + lines.append(ch_line) + if assumed: + lines.append("Assumed (not given): " + ", ".join(assumed)) + lines.append(CARD_FULL) + return lines + + +# ---- Canadian entries (roadmap T2.2) -------------------------------------- + +def _canada_offence(offense, edata, od, person, completed, max_given=None, + mode_given=None): + """The maximum (at the offence date) and the mode of a Canadian entry's + offence: the officer's values first, else the curated pairing the words + match -- its maximum at the offence date (Tran) and its hybrid / + summary_only flags. A pairing that settles no one maximum (a tie, a + straddled date, a limb, depends) or records no federal offence supplies + none; the answer then gives every reading.""" + from canlex.transborder import mode_from_pairing + info = {"max_years": max_given, "max_source": "officer" if max_given is not None else None, + "mode": mode_given, "mode_source": "officer" if mode_given else None, + "summary_only": None, "pairing": None, "fields": None, "open": None, + "no_federal": False} + if edata is None: + return info + s2 = _step2(offense, edata, None, {}, od, person, completed=completed) + p, f = s2["pairing"], s2["fields"] + if p is None or f is None: + return info + info["pairing"], info["fields"] = p, f + if p.get("equivalent") is False: + info["no_federal"] = True + return info + if f["branch"] in ("tie", "depends", "unknown") or f["branch"] in _OPEN: + info["open"] = f["branch"] + else: + if info["max_years"] is None and f.get("max_years") is not None: + info["max_years"], info["max_source"] = f["max_years"], f"pairing {p['id']}" + if f.get("summary_only") or f["branch"] == "criminality-two-plus-only": + info["summary_only"] = True + if info["mode"] is None and info["open"] != "tie": + m = mode_from_pairing(p) + if m: + info["mode"], info["mode_source"] = m, f"pairing {p['id']}" + return info + + +def _canada_limbs_text(limbs): + words = {"10-year": "the 10-year limb", "six-month": "the six-month limb"} + return (" (" + " and ".join(words[l] for l in limbs) + ")") if limbs else "" + + +def _canada_allegation(cb, limbs, name, prefix=""): + """The draft allegation paragraph(s) for a Canadian answer, each in its + own limb's words (_ALLEGATION).""" + def draft(para, key): + return (f"{prefix}Draft allegation paragraph: the person is inadmissible " + f"under IRPA paragraph {para} for {_allegation_text(key)}, namely {name}.") + if cb == "36(1)(a)": + return ([draft("36(1)(a)", "36(1)(a)")] if "10-year" in limbs else []) + \ + ([draft("36(1)(a)", "36(1)(a)-six")] if "six-month" in limbs else []) + if cb == "36(2)(a)": + return [draft("36(2)(a)", "36(2)(a)")] + if cb == "36(2)(a)-two-offences": + return [draft("36(2)(a)", "36(2)(a)-two")[:-1] + + " (name each conviction the count rests on)."] + return [] + + +def _canada_screen(offense, disposition, person, application, count, od, sc_d, + today_d, canada_sentence, canada_offence_mode, custody, + canada_max_years, other_record, edata, youth_needs=(), conviction_date=None): + """Steps 1-3, the allegation and the next steps for a Canadian entry + (roadmap T2.2): s. 36(1)(a) (the 10-year and the six-month limbs, for + anyone), s. 36(2)(a) (foreign nationals), IRPR s. 18.1, the record + suspension as the only cure, the removal path (IRPR s. 228(1)(a) for a + foreign national, s. 229(1)(c) for a permanent resident) and a permanent + resident's s. 64(2) appeal line -- the rules in canlex/rehab.py. + Returns (sections [(title, text)], bottom-line bullets, fields). + G2 (2026-09-22): the record suspension is placed against the offence and + conviction dates, the sentence completion and today (REL-11), and another + conviction on the record (other_record, or a count other than 'one') may be + a later Canadian one that ended it (CRA ss. 7.2(a), 7(a): NOT SETTLED -- + REL-5).""" + from canlex.rehab import (ACT_OF_PARLIAMENT_NOTE, CANADA_BRANCH_TEXT, + CANADA_REMOVAL_FN, CANADA_REMOVAL_PR, NCR_TEXT, NOT_CONVICTED_TEXT, + assess_canadian, canada_disposition) + from canlex.transborder import S36_3_B + cd = canada_disposition(disposition, canada_sentence, offence_date=od, + conviction_date=conviction_date, today=today_d, completed=sc_d, + later=["other_record"] if other_record or count != "one" else None) + info = _canada_offence(offense, edata, od, person, sc_d, canada_max_years, + canada_offence_mode) + pr = person == "permanent-resident" + + def run(conviction): + return assess_canadian(status=person, count=count, max_years=info["max_years"], + mode=info["mode"], sentence=cd["sentence"], + custody_months=custody, + record_suspension=cd["record_suspension"], + conviction=conviction, completed=sc_d, today=today_d, + clean_record=not other_record, + summary_only=info["summary_only"]) + res = run(cd["conviction"] is not False) + # (G2: a 'pardon' after 2012-03-12 or a record suspension a later conviction may have + # ended leaves the conviction a conviction and its relief open -- read the same way) + unsettled_conviction = cd["conviction"] is None or ( + cd["conviction"] is True and cd.get("relief_open") is not None) + cb = res["canada_branch"] + + # ---- step 1 + if cd.get("ncr"): + # the review of batch 1, round 4 (HELD): Criminal Code s. 672.35 + s1 = NCR_TEXT + elif cd["conviction"] is False: + s1 = res["explanation"] + elif unsettled_conviction and cd.get("relief_open"): + # the verification fixes of 2026-09-22 (F1, LEGAL-6): a prerogative pardon is no + # record suspension; what it does to the ground is not settled (G2: nor what a + # 'pardon' dated after 2012-03-12 does, nor whether a later conviction ended it) + s1 = (cd["record_suspension_note"] + " The steps below draft each reading " + "conditionally: if the conviction still grounds inadmissibility, and if it " + "does not (then no s. 36 ground rests on it).") + elif unsettled_conviction: + s1 = ("Whether this Canadian disposition is a conviction is not settled by " + f"its words ('{disposition}'): " + + ("a new trial was ordered, so the charge is pending again -- re-screen when it " + "is decided; " if cd.get("pending") else "") + + "the steps below draft each reading conditionally. " + "If it is NOT one: " + NOT_CONVICTED_TEXT) + else: + s1 = ("A conviction in Canada" + + (f"; the sentence or outcome: {cd['sentence']} (from " + f"{'canada_sentence' if cd['sentence_from'] == 'parameter' else 'the record words'})" + if cd["sentence"] else "") + + (" -- a record suspension is named" if cd["record_suspension"] else "") + + ".") + if cd.get("record_suspension_note"): + s1 += " " + cd["record_suspension_note"] + if cd["under_appeal"]: + s1 += (" An appeal is noted: a conviction stands until it is set aside, " + f"and IRPA s. 36(3)(b) bars inadmissibility only where '{S36_3_B}'. " + "Re-screen when the appeal is decided.") + + # ---- step 2 + p = info["pairing"] + s2 = [] + if cd["conviction"] is not False: + if info["max_source"] == "officer": + s2.append(f"Maximum at the offence date: {_years(info['max_years'])} " + "(given by the officer, canada_max_years).") + elif info["max_years"] is not None and p is not None: + s2.append(f"Canadian offence read from the curated pairing '{p['id']}' " + f"({p['canadian_offence']}): maximum {_years(info['max_years'])} " + "at the offence date (Tran: the maximum at commission governs). " + "The pairing table was compiled for US records: confirm the " + "section of conviction against the CPIC entry, and pass " + "canada_max_years / canada_offence_mode to override.") + elif p is not None and info["no_federal"]: + s2.append(f"The curated pairing '{p['id']}' ({p['canadian_offence']}) " + "records no federal offence. " + ACT_OF_PARLIAMENT_NOTE + + " If the conviction was under an Act of Parliament, give " + "canada_max_years and canada_offence_mode.") + elif p is not None: + s2.append(f"The curated pairing '{p['id']}' ({p['canadian_offence']}) " + f"matches but settles no one maximum here ({info['open']}): " + "give canada_max_years (the maximum at the offence date).") + else: + s2.append("No curated pairing names the offence: give canada_max_years " + "(the maximum at the offence date) and canada_offence_mode. " + + ACT_OF_PARLIAMENT_NOTE) + if info["mode"]: + s2.append(f"Mode: {info['mode']}" + + (" (given by the officer)" if info["mode_source"] == "officer" + else f" (from the curated {info['mode_source']}: its hybrid / " + "summary-only flags)") + + ("" if info["mode"] == "summary-only" else + " -- IRPA s. 36(3)(a) deems a hybrid offence indictable 'even " + "if it has been prosecuted summarily'.")) + if res["custody_counted"] is not None: + s2.append(f"Custody counted for the six-month limb: " + f"{res['custody_counted']:g} months.") + s2 += res["notes"] + else: + s2.append("Not reached: no conviction.") + + # ---- step 3 + s3 = [] + readings = res["readings"] + if cb == "not-settled": + s3.append("NOT SETTLED -- " + + "; ".join(n["question"] for n in res["needs"]) + ". Each reading:") + for rd in readings: + s3.append(f"- if {rd['reading']}: {CANADA_BRANCH_TEXT[rd['canada_branch']]}" + f"{_canada_limbs_text(rd['canada_limbs'])}" + + (f" -- {rd['status'].upper().replace('-', ' ')}" if rd["status"] else "")) + else: + s3.append(CANADA_BRANCH_TEXT[cb] + _canada_limbs_text(res["canada_limbs"])) + if res["rehab"] is not None: + s3.append(f"Status: {res['status'].upper().replace('-', ' ')} " + f"({res['provision']})") + s3 += [f"CONDITION: {c}" for c in res["conditions"]] + if res.get("earliest_deemed"): + s3.append(f"Earliest date the IRPR s. 18.1 exemption applies: " + f"{res['earliest_deemed']}") + if res.get("appeal_note"): + s3.append(f"Appeal to the IAD (IRPA s. 64): {res['appeal_note']}") + s3 += ["", res["explanation"]] + if res["caveats"]: + s3 += ["", "Caveats:"] + [f"- {c}" for c in res["caveats"]] + + # ---- allegation and next steps + name = (p["canadian_offence"] if p is not None and info["max_source"] == f"pairing {p['id']}" + else f"'{offense}' (the Canadian conviction as recorded)") + nx = [] + reportable = False + # a prerogative pardon (LEGAL-6, 2026-09-22): the readings are whether the conviction + # still grounds inadmissibility, not whether the words are a conviction + ro = _CANADA_RELIEF_OPEN.get(cd.get("relief_open")) + if_conv = ro["if_conv"] if ro else "the disposition is a conviction" + if_not = ro["if_not"] if ro else None + if cb == "not-settled": + nx.append("Allegation not settled: it turns on the inputs above. Each draft:") + cond = f" and {if_conv}" if unsettled_conviction else "" + for rd in readings: + drafts = _canada_allegation(rd["canada_branch"], rd["canada_limbs"], name, + prefix=f"If {rd['reading']}{cond}: ") + nx += drafts or [f"If {rd['reading']}{cond}: no s. 36 allegation -- " + f"{CANADA_BRANCH_TEXT[rd['canada_branch']]}."] + reportable = reportable or bool(drafts) + if unsettled_conviction: + nx.append(if_not or "If the disposition is NOT a conviction: no s. 36 allegation -- " + + NOT_CONVICTED_TEXT) + elif unsettled_conviction: + # the review of batch 1, round 4: each reading conditionally, never an unconditional + # s. 36(1)(a) draft on words that may not be a conviction + drafts = _canada_allegation(cb, res["canada_limbs"], name, + prefix=f"If {if_conv}: ") + reportable = bool(drafts) + nx += drafts or [f"If {if_conv}: no s. 36 allegation -- " + f"{CANADA_BRANCH_TEXT[cb]}."] + nx.append(if_not or "If it is NOT a conviction: no s. 36 allegation -- " + NOT_CONVICTED_TEXT) + else: + drafts = _canada_allegation(cb, res["canada_limbs"], name) + reportable = bool(drafts) + nx += drafts or [f"No allegation under s. 36: {CANADA_BRANCH_TEXT[cb]}."] + if reportable: + nx.append(("If the finding is made: " if cb == "not-settled" else "") + + (CANADA_REMOVAL_PR if pr else CANADA_REMOVAL_FN)) + nx.append(_S44_NOTE) + if not pr and (reportable or cb == "s18.1-exempt"): + trp_fee = _fee_amount("trp") + nx.append("Temporary resident permit (IRPA s. 24): available at the " + "officer's discretion where entry is justified in the " + "circumstances, subject to the s. 24(3.1)-(5) bars; the " + "processing fee under IRPR s. 298 is " + + (f"${trp_fee}" if trp_fee else "set out in IRPR s. 298") + ".") + if reportable: + nx.append("Cure: a record suspension under the Criminal Records Act (IRPA " + "s. 36(3)(b)). Rehabilitation (s. 36(3)(c)) and the IMM 1444 " + "application do not reach a Canadian conviction.") + nx.append(_family_line(person, application)) + nx.append(CANADA_ALSO_CHECK) + + # ---- bottom line + bl = [] + if cd["conviction"] is False: + bl.append(f"Canadian record: {CANADA_BRANCH_TEXT[cb]}.") + elif cb == "not-settled": + opts = "; ".join(f"{rd['reading']} -> {CANADA_BRANCH_TEXT[rd['canada_branch']]}" + f"{_canada_limbs_text(rd['canada_limbs'])}" for rd in readings) + bl.append(f"Canadian record: NOT SETTLED -- {opts}.") + else: + bl.append(f"Canadian record: {CANADA_BRANCH_TEXT[cb]}" + f"{_canada_limbs_text(res['canada_limbs'])}.") + if reportable: + bl.append("Cure: a record suspension only (s. 36(3)(b)); rehabilitation " + "does not apply to a Canadian conviction.") + if res.get("appeal_note"): + bl.append(f"IAD appeal: {res['appeal_note']}") + if unsettled_conviction: + bl.append(ro["bottom"] if ro else + "If the disposition is NOT a conviction: no s. 36 ground (the act " + "branches reach only acts outside Canada).") + + verdict = ("no" if cd["conviction"] is False else "yes" if cd["conviction"] + else "unknown") + lt10 = (None if info["max_years"] is None else info["max_years"] < 10) + fields = { + "disposition_entry": None, "verdict": verdict, + "conviction": cd["conviction"] is True, "conviction_unsettled": unsettled_conviction, + "basis": "canada-conviction", "pairing": p["id"] if p else None, + "branch": res["branch"], "max_years": info["max_years"], + "max_penalty_lt_10": lt10 if res["branch"] not in ("depends", "none") else None, + "max_source": info["max_source"], "canada_branch": cb, + "canada_limbs": res["canada_limbs"], + "canada_readings": readings, "canada_sentence": cd["sentence"], + "canada_offence_mode": info["mode"], "mode_source": info["mode_source"], + "custody_imposed_months": custody, "custody_counted": res["custody_counted"], + "record_suspension": cd["record_suspension"], "under_appeal": cd["under_appeal"], + "removal": res["removal"], "removal_provision": res["removal_provision"], + "iad_appeal": res["iad_appeal"], + "needs": list(res["needs"]) + list(youth_needs), + "rehab_status": res["status"], "rehab_provision": res["provision"], + "earliest_deemed": res.get("earliest_deemed"), "earliest_apply": None} + if cd.get("prerogative_pardon"): + fields["prerogative_pardon"] = True # LEGAL-6: the card asks its own question + if cd.get("relief_open"): + fields["relief_open"] = cd["relief_open"] # G2: which question it asks + sections = [("Step 1 -- is it a conviction? (Canadian record)", s1), + ("Step 2 -- the Canadian offence: maximum and mode", "\n".join(s2)), + ("Step 3 -- IRPA s. 36(1)(a) / (2)(a)", "\n".join(s3)), + ("Allegation and next steps", "\n".join(nx))] + return sections, bl, fields, p + + +# ---- the whole record, IRPR s. 18(2) and s. 18.1 limb by limb (T2.3) ------ + +def _pairing_classes(p): + """How a curated pairing's Canadian offence may be prosecuted, as the + IRPR s. 18(2) limbs read it: 'none' (no federal equivalent), + 'summary-only', 'hybrid' or 'indictable-only' -- both of the last two when + the pairing does not record it; every class when there is no pairing.""" + if p is None: + return ["none", "summary-only", "hybrid", "indictable-only"] + if p.get("equivalent") is False: + return ["none"] + if p.get("summary_only"): + return ["summary-only"] + h = p.get("hybrid") + return (["hybrid"] if h is True else ["indictable-only"] if h is False + else ["hybrid", "indictable-only"]) + + +def _limb_classes(eq, edata): + """The classes a foreign entry's equivalent can be (roadmap T2.3): one when + step 2 settled it; each candidate's for a tie, an open offence date or an + open limb; 'none' as well where the elements or the substance schedule may + leave no Canadian offence.""" + if eq is None: + return _pairing_classes(None) + pairs = {p["id"]: p for p in (edata or {}).get("pairings", [])} + br = eq.get("branch") + if br == "none" or eq.get("equivalent") is False: + return ["none"] + + def own(summary_only): + if summary_only: + return ["summary-only"] + h = eq.get("hybrid") + return (["hybrid"] if h is True else ["indictable-only"] if h is False + else ["hybrid", "indictable-only"]) + if br == "tie": + groups = [_pairing_classes(pairs.get(pid)) + for pid in (eq.get("pairing"), eq.get("tied_with"))] + elif br in _OPEN: + groups = [own(c.get("summary_only")) for c in eq.get("tran_candidates") or [{}]] + elif br == "depends" and eq.get("depends_on") != "value": + groups = [["none"], own(eq.get("summary_only"))] + else: + groups = [own(eq.get("summary_only"))] + return list(dict.fromkeys(c for g in groups for c in g)) + + +def _record_entries(rows, edata): + """The record as rehab.assess_record reads it (roadmap T2.3): per row, a + conviction, a committed act or a verdict step 1 did not settle; the + classes its equivalent can be; its dates. A Canadian discharge or a + Canadian charge without a conviction is not on it; a Canadian conviction + the words do not settle is run both ways. Each entry carries its IRPR s. 17 + question (_s17_open_question) and, for a prerogative pardon, its kind + (rehab.prerogative_kind) -- the G3 fixes of 2026-09-22. + + H3 (2026-09-22): 's17_open' says the entry's conviction status is open for + the IRPR s. 17 clock although step 1 placed it -- a verdict the curated + disposition itself calls likely ('likely-no': a deferred or withheld + adjudication, an ACD/CWOF), a juvenile or youthful-offender disposition, or + a YCJA youth sentence. s. 17 excepts only 'an offence designated as a + contravention under the Contraventions Act or an offence under the Young + Offenders Act', so on the reading that such an entry is a conviction it + restarts the five years. rehab._s17 reads it; no limb does (S17-INT-5, + S17-INT-6, S17-INT-7).""" + from canlex.rehab import prerogative_kind + out = [] + for r in rows: + base = {"n": r["n"], "offence_date": r["offence_date"], + "conviction_date": r.get("conviction_date"), "completed": r["completed"], + "convicted_by": r["completed_given"], "absentia": r["absentia"], + "occurrence": r["group"]} + c = r["canada"] + if c: + cd, info, res = c["cd"], c["info"], c["res"] + if cd["conviction"] is False: + continue + serious = (True if res["canada_branch"] == "36(1)(a)" else + None if any(rd["canada_branch"] == "36(1)(a)" for rd in res["readings"]) + else False) + kw = ({"class": "contravention"} if cd["sentence"] == "contravention" else + {"classes": {"summary-only": ["summary-only"], "hybrid": ["hybrid"], + "indictable": ["indictable-only"]}.get( + info["mode"], ["summary-only", "hybrid"])}) + out.append(dict(base, jurisdiction="canada", kind="conviction", + conviction_open=cd["conviction"] is None, + youth=cd["sentence"] == "youth-sentence", + record_suspended=cd["record_suspension"], serious=serious, + prosecuted=r.get("prosecuted"), + # the G3 fixes of 2026-09-22 (read by rehab._s17 only) + s17_question=_s17_open_question(r), + # H3: a YCJA youth sentence is on the clock as an open entry + s17_open=cd["sentence"] == "youth-sentence", + prerogative=(prerogative_kind(r["disposition"]) + if cd.get("prerogative_pardon") else None), **kw)) + continue + eq = r["eq"] + out.append(dict(base, jurisdiction="foreign", + kind="conviction" if r["conviction"] else + "unknown" if r["unknown"] else "act", + classes=_limb_classes(eq, edata), youth=r["youth"], + max_years=(eq or {}).get("max_years"), + # H3 (S17-INT-5/6/7): step 1 placed it but did not settle it + s17_open=bool(r.get("verdict") == "likely-no" or r["youth"]), + s17_question=(_s17_open_question(r) + if r["conviction"] or r["unknown"] else None))) + return out + + +_LIMB_HOLDS = {"pass": "yes", "fail": "NO", "unknown": "UNKNOWN", "n/a": "--"} +# canada_prosecuted as an officer may write it (the Crown's election on a +# Canadian hybrid offence) +_PROSECUTED_WORDS = {"summarily": "summarily", "summary": "summarily", + "summary conviction": "summarily", "on summary conviction": "summarily", + "indictment": "indictment", "by indictment": "indictment", + "on indictment": "indictment", "indictable": "indictment"} + + +def _limb_when(l): + """When a limb starts or stops holding, as the table prints it.""" + if l["never"]: + return "never" + # an open fact moves the date: a start is shown at its latest, a stop at its + # earliest + parts = ([f"from {l['from']}" + ("" if l["exact"] else " at the latest")] + if l["from"] else []) + \ + ([("until at least " if not l["exact"] else "until ") + l["until"]] + if l["until"] else []) + return ", ".join(parts) or "--" + + +def _gives(needs): + return ", ".join(dict.fromkeys(f"{n['parameter'] or n['fact']} (#{n['entry']})" + for n in needs)) + + +def _para_outcome(p): + """One paragraph's answer, as the heading of its limb table.""" + word = ("EXEMPT from IRPA s. 36(2)(a)" if p["key"] == "18.1" + else "DEEMED REHABILITATED") + late = "" if p["exact"] else " at the latest" + if p["outcome"] == "deemed": + return f"{word} from {p['deemed_on']}{late}" if p["deemed_on"] else word + if p["outcome"] == "open": + return "NOT SETTLED" + (f" -- give {_gives(p['needs'])}" if p["needs"] else "") + if p["deemed_on"]: + return (f"not yet -- every limb holds from {p['deemed_on']}{late} if nothing " + "is added to the record") + if p["projection_open"]: + return "not today; whether every limb can hold later turns on an open fact" + return ("does not apply on this record" if p["key"] == "18.1" else + "deemed rehabilitation NOT AVAILABLE on this record") + + +def _limb_table(paragraphs, today): + """Each paragraph's limbs: the condition, whether it holds on the + assessment date, the entries that decide it, and when it starts or stops + holding (roadmap T2.3).""" + out = [] + for p in paragraphs: + if not p["limbs"]: + # too many open facts to run every way: no table, only the answer + out += ["", f"{p['paragraph']} -- {_para_outcome(p)}", + "(no limb table: the record has too many open facts to run every way " + "-- resolve the entries named and re-run)"] + continue + chunks = ", ".join(dict.fromkeys(l["chunk"] for l in p["limbs"])) + out += ["", f"{p['paragraph']} ({chunks}) -- {_para_outcome(p)}", + f"| limb | condition | holds on {today.isoformat()}? | deciding entries " + "| starts / stops holding |", "|---|---|---|---|---|"] + for l in p["limbs"]: + lab = (p["key"] if l["limb"] == "chapeau" else + l["limb"] if p["key"] == "18.1" else f"({l['limb']})") + text = (l["text"] if l["limb"] == "chapeau" or p["key"] == "18.1" + else l["text"].split(") ", 1)[1]) + holds = _LIMB_HOLDS[l["status"]] + if l["status"] == "unknown" and l["needs"]: + holds += f" -- give {_gives(l['needs'])}" + out.append(f"| {lab} | {text} | {holds} | " + f"{', '.join(f'#{n}' for n in l['entries']) or '--'} | " + f"{_limb_when(l)} |") + return out + + +def _limb_section(rec, today): + """The 'IRPR s. 18(2) limb by limb' section of canlex_screen_history: one + table per paragraph that can apply, or one set per reading when a reading + CanLex does not settle decides the answer (roadmap DECISIONS (a), (b)).""" + out = [f"WARNING: {w}." for w in rec["warnings"]] + if rec["readings"]: + out.append("NOT SETTLED -- the answer turns on how IRPR s. 18(2) is read, which " + "CanLex does not settle (roadmap DECISIONS: whether a hybrid equivalent " + "is also a 'summary conviction offence' in (iv)/(vi), and a Canadian " + "hybrid conviction entered summarily is 'an indictable offence' in (iii); " + "the anchor of 'the last N years'). Each reading follows; the officer's " + "determination governs.") + for k, x in enumerate(rec["readings"], 1): + out += ["", f"### Reading {k} -- {x['reading']}: " + f"{_rehab_brief(x)}"] + out += _limb_table(x["paragraphs"], today) + return out + if not rec["paragraphs"]: + out.append("No IRPR s. 18(2) paragraph can apply to this record: (a) and (c) " + "reach no more than one indictable-equivalent conviction or committed " + "act, and (b) only summary-equivalent convictions -- only an " + "application under IRPR s. 17 is open (record-level answer above).") + return out + out += _limb_table(rec["paragraphs"], today) + for d in rec["dates_differ"] or []: + out.append(f"Other reading -- {d['reading']}: earliest deemed " + f"{d['earliest_deemed'] or 'none'} (roadmap DECISIONS; not settled in " + "CanLex).") + return out + + +def _s18_compact(paragraphs): + """The limb results for CANLEX_RESULT (the condition text is in the memo).""" + keys = ("limb", "status", "entries", "from", "until", "never", "exact", "chunk") + return [{"paragraph": p["paragraph"], "outcome": p["outcome"], + "deemed_on": p["deemed_on"], "exact": p["exact"], + "limbs": [{k: l[k] for k in keys} for l in p["limbs"]]} + for p in paragraphs] + + +def _deem_clock_text(rec): + """Which limb the deeming clock is and which entry sets it.""" + p = next((p for p in rec["paragraphs"] if p["paragraph"] == rec.get("paragraph")), None) + if not p or not rec.get("deem_clock"): + return "" + lim = next((l for l in p["limbs"] if l["limb"] == ("i" if p["key"] == "(b)" else "ii")), + None) + if not lim or not lim["entries"]: + return "" + what = "offence date" if p["key"] == "(c)" else "sentence completion" + return (f"; {p['paragraph']}({lim['limb']}) runs from entry #{lim['entries'][0]}'s " + f"{what} {rec['deem_clock']}") + + +def _canada_entry_disposition(ent, od, cdate, cin, later=None, completed=None): + """rehab.canada_disposition for a Canadian entry of canlex_screen / + canlex_screen_history: its words (with the relief key the tool applied), + and -- G2, 2026-09-22 -- its own words and relief key read together, its + offence and conviction dates, its sentence completion, the assessment date + and the record's later Canadian convictions (later) that the relief is + placed against.""" + from canlex.rehab import canada_disposition + own, key, today = ent.get("_canada_relief_ctx") or (None, None, None) + return canada_disposition(ent["disposition"], cin[2], relief=key, own=own, + offence_date=od, conviction_date=cdate, today=today, later=later, + completed=completed) + + +def _canada_later_convictions(parsed, country): + """{entry: [the record's other Canadian entries that may be later + convictions]} for each Canadian entry whose record suspension stands (G2, + REL-5). Criminal Records Act s. 7.2(a): a record suspension 'ceases to have + effect if ... the person to whom it relates is subsequently convicted of' the + offences it lists; s. 7(a) lets the Board revoke it on a later summary + conviction. Another Canadian entry that is (or may be) a conviction counts + unless its conviction date -- or, without one, its sentence completion, + which the conviction precedes -- falls before the record suspension's + earliest date; and it counts anyway when its OFFENCE date falls on or after + that day, a conviction not being able to precede its own offence (H2, + 2026-09-22, G2-REL-G: an offence of 2012 typed beside a completion of 2009 + was invisible). The offence date only ever ADDS an entry: it bounds the + conviction from below, so it can never show one to be earlier. An undated + record suspension no longer reaches here (canada_disposition opens it -- + G2-REL-E).""" + info = {} + for i, ent, od, completed, _dob, _age, cin, cdate, _pros in parsed: + j = _jurisdiction(ent.get("state"), ent.get("country") or country, ent.get("disposition")) + if not (j["kind"] == "canada" + or str(ent.get("jurisdiction") or "").strip().lower() == "canada"): + continue + info[i] = (_canada_entry_disposition(ent, od, cdate, cin, completed=completed), + (cdate or completed, od)) + out = {} + for i, (cd, _) in info.items(): + if not cd["record_suspension"]: + continue + earliest = cd["relief_date"][0] if cd["relief_date"] else None + later = [n for n, (other, (bound, od)) in info.items() + if n != i and other["conviction"] is not False + and (earliest is None or bound is None or bound >= earliest + or (od is not None and od >= earliest))] + if later: + out[i] = later + return out + + +def _canada_history_row(i, ent, od, completed, dob, age, cin, edata, person, cdate=None, + later=None): + """One Canadian entry of canlex_screen_history (roadmap T2.2): its + disposition on the Canadian rules and its offence's maximum and mode. The + count and the assessment follow at record level, once every entry is + read.""" + mode, custody, sentence, cmax = cin + cd = _canada_entry_disposition(ent, od, cdate, cin, later, completed) + info = _canada_offence(ent["offense"], edata, od, person, completed, cmax, mode) + ys, _, _, yneeds = _youth_screen(dob, age, od, completed, canada=True, + canada_sentence=cd["sentence"]) + p = info["pairing"] + group = ent.get("occurrence_group") + return {"n": i, "offense": ent["offense"], "disposition": ent["disposition"], + "state": ent.get("state"), "entry": None, + "verdict": {True: "yes", False: "no"}.get(cd["conviction"], "unknown"), + "conviction": cd["conviction"] is True, "unknown": cd["conviction"] is None, + "eq": None, "display": f"Canada: {p['id']}" if p else "Canada", + "completed": completed, "completed_given": completed, "offence_date": od, + "group": None if group is None else str(group), + "youth": cd["sentence"] == "youth-sentence", + "s17_excepted": cd["sentence"] in ("youth-sentence", "contravention"), + "absentia": False, "note": "Canada: s. 36(1)(a)/(2)(a)", + "jurisdiction": "canada", + "s37": _s37_flags([p] if p else [], None, ent["offense"]), + "youth_screen": ys, "youth_needs": yneeds, + "canada": {"cd": cd, "info": info, "custody": custody, + "relief_words": (ent.get("_canada_relief_ctx") or (None, None, None))[:2]}} + + +def _canada_history_notes(canada_rows, person): + """The record-level lines for the Canadian entries of canlex_screen_history + (roadmap T2.2).""" + from canlex.rehab import CANADA_BRANCH_TEXT + notes = [] + for r in canada_rows: + res, alt = r["canada"]["res"], r["canada"]["alt"] + cb = res["canada_branch"] + if cb == "not-settled": + opts = "; ".join(f"{rd['reading']} -> {CANADA_BRANCH_TEXT[rd['canada_branch']]}" + f"{_canada_limbs_text(rd['canada_limbs'])}" + for rd in res["readings"]) + text = (f"NOT SETTLED -- {opts}. Give " + + ", ".join(n["parameter"] or n["fact"] for n in res["needs"]) + ".") + else: + text = (CANADA_BRANCH_TEXT[cb] + _canada_limbs_text(res["canada_limbs"]) + + (f" -- {res['status'].upper().replace('-', ' ')}" if res["status"] else "") + + (f"; IRPR s. 18.1 exemption from {res['earliest_deemed']}" + if res.get("earliest_deemed") else "") + + (f"; removal: {res['removal_provision']}" if res["removal_provision"] else "") + + (f"; IAD appeal: {res['iad_appeal']}" if res.get("iad_appeal") else "") + + ".") + notes.append(f"#{r['n']} (Canada): {text}") + if alt is not None: + notes.append(f"#{r['n']} (Canada): COUNT NOT SETTLED -- s. 36(2)(a) counts two " + "offences 'not arising out of a single occurrence', and the " + "Canadian summary-only convictions share an offence date or have " + f"none. Separate occurrences: {CANADA_BRANCH_TEXT[alt['canada_branch']]}" + f"; one occurrence: {CANADA_BRANCH_TEXT[cb]}. Give occurrence_group.") + if any(r["canada"]["res"]["removal"] or any(rd["removal"] for rd in + r["canada"]["res"]["readings"]) + for r in canada_rows): + notes.append("Canadian convictions: the cure is a record suspension (IRPA " + "s. 36(3)(b)); rehabilitation (s. 36(3)(c)) does not reach them. " + "Run canlex_screen_record on an entry (country 'Canada') for its " + "allegation, removal path and appeal lines.") + return notes + + +def _youth_history_notes(rows): + """The YOUTH lines for canlex_screen_history (roadmap T2.6): the statute and + the policy once for the record, with the entries they concern.""" + def ids(rs): + return ", ".join(f"#{r['n']}" for r in rs) + young = [r for r in rows if r["youth_screen"] and ( + r["youth_screen"]["band"] == "youth" or r["youth_screen"]["youth_disposition"])] + foreign = [r for r in young if not r["canada"]] + canada = [r for r in young if r["canada"]] + under = [r for r in rows if r["youth_screen"] and r["youth_screen"]["band"] == "under-12"] + notes = [] + if young: + notes.append(f"YOUTH (entr{'y' if len(young) == 1 else 'ies'} {ids(young)}): " + + YOUTH_STATUTE + + (f" For {ids(canada)}: " + YOUTH_STATUTE_CANADA if canada else "") + + (f" For {ids(foreign)}: " + YOUTH_POLICY if foreign else "")) + for group, text in (([r for r in under if not r["canada"]], UNDER_12), + ([r for r in under if r["canada"]], UNDER_12_CANADA)): + if group: + notes.append(f"UNDER 12 AT THE OFFENCE ({ids(group)}): {text}") + return notes + + +# The allegation paragraphs, each in its own paragraph's words (IRPA s. 36, +# I-2.5-s36-p1). A tuple is quoted piece by piece; tests/test_screening_chain.py +# finds every piece in the corpus text, so an amendment is caught. +_ALLEGATION = { + "36(1)(b)": ("having been convicted of an offence outside Canada that, if " + "committed in Canada, would constitute an offence under an Act " + "of Parliament punishable by a maximum term of imprisonment of " + "at least 10 years",), + "36(1)(c)": ("committing an act outside Canada that is an offence in the " + "place where it was committed and that, if committed in Canada, " + "would constitute an offence under an Act of Parliament " + "punishable by a maximum term of imprisonment of at least 10 " + "years",), + "36(2)(b)": ("having been convicted outside Canada of an offence that, if " + "committed in Canada, would constitute an indictable offence " + "under an Act of Parliament",), + "36(2)(b)-two": ("having been convicted outside Canada", + "of two offences not arising out of a single occurrence " + "that, if committed in Canada, would constitute offences " + "under an Act of Parliament"), + "36(2)(c)": ("committing an act outside Canada that is an offence in the " + "place where it was committed and that, if committed in Canada, " + "would constitute an indictable offence under an Act of " + "Parliament",), + # Canadian convictions (roadmap T2.2): each limb of s. 36(1)(a) and (2)(a) + "36(1)(a)": ("having been convicted in Canada of an offence under an Act " + "of Parliament punishable by a maximum term of imprisonment of " + "at least 10 years",), + "36(1)(a)-six": ("having been convicted in Canada of an offence under an Act " + "of Parliament", + "for which a term of imprisonment of more than six months " + "has been imposed"), + "36(2)(a)": ("having been convicted in Canada of an offence under an Act " + "of Parliament punishable by way of indictment",), + "36(2)(a)-two": ("having been convicted in Canada", + "of two offences under any Act of Parliament not arising " + "out of a single occurrence"), +} + +# The removal path by paragraph (roadmap T1.7(a)), from IRPA s. 44(2) and +# IRPR ss. 228(1), 229(1) and 42 -- every phrase pinned in the corpus by +# tests/test_screening_chain.py. +_REMOVAL_FN = ( + "Removal path, if the person is inadmissible (s. 44(1) report; ENF 5 sets " + "out the report and narrative): on these grounds -- s. 36(1)(b)/(c) and " + "s. 36(2)(b)/(c) -- the Minister's delegate cannot make the removal order. " + "IRPR s. 228(1) keeps a foreign national's report from the Immigration " + "Division only for s. 36(1)(a)/(2)(a) (Canadian convictions) and the " + "s. 36(2.1) offences it lists, so a well-founded report on a foreign " + "conviction or act is referred to the Immigration Division (IRPA " + "s. 44(2)), which makes a deportation order: IRPR s. 229(1)(c) for " + "s. 36(1)(b)/(c), s. 229(1)(d) for s. 36(2)(b)/(c). No s. 36 ground " + "carries an exclusion order. At a port of entry the alternatives are " + "allowing the person to withdraw the application to enter and leave " + "(IRPR s. 42 -- once a s. 44(1) report is being prepared, only if the " + "Minister decides not to make a removal order or not to refer the report, " + "s. 42(2)) or a TRP (below).") +_REMOVAL_PR = ( + "Removal path, if the person is inadmissible: a permanent resident's " + "s. 36(1) report is referred to the Immigration Division (IRPA s. 44(2) " + "lets the Minister make the order against a permanent resident only where " + "the sole ground is the residency obligation), which makes a deportation " + "order (IRPR s. 229(1)(c)).") + +# The s. 44 discretion note (roadmap T1.7(b)); both quotations are pinned to +# their chunks by tests/test_screening_chain.py. +_S44_NOTE = ( + "Discretion at s. 44: s. 44(1) says an officer 'may' prepare a report, but " + "the scope of the discretion 'may end up varying depending on the grounds " + "alleged, on whether the person concerned is a permanent resident or a " + "foreign national' (Cha, 2006 FCA 126, para 22, caselaw chunk " + "fca-33278-27; where the delegate makes the order in the prescribed " + "cases, 'may' 'does not attract discretion', para 38, fca-33278-35). Cha " + "concerned a Canadian conviction (s. 36(2)(a)); ENF 5 (2025-02-20) s. 8.1 " + "reads the later case law as leaving officers 'very limited discretion' " + "(enf-5-8-1-p2). Read ENF 5 ss. 8 and 10 before relying on any discretion.") + +_PARDON_LINE = ( + "Also check: a previous removal order (s. 52 -- authorization to return, " + "ENF 28 not needed for s. 36 alone); a foreign pardon, expungement or " + "similar relief -- ENF 3 s. 10.9 (official, 2022-03-03; enf-3-10-9: whether " + "the foreign measure erases the conviction or only recognises " + "rehabilitation, and the three elements a foreign discharge or pardon must " + "meet), IRB Legal Services, Removal Order Appeals ch. 8 s. 18 'Effect of a " + "Pardon' (archived 2009 paper collecting Burgon and Saini; " + "irb-ls-roa8-18-p1) and ENF 14 s. 27 (a withdrawn 2008 ATIP copy; " + "enf-14-27) -- a record suspension under s. 36(3)(b) exists only for " + "Canadian convictions; the youth exclusion in s. 36(3)(e); and any " + "Canadian conviction, which takes the record-suspension route rather than " + "rehabilitation.") + + +# Organized criminality, IRPA s. 37 (roadmap T2.5). Printed whatever the s. 36 +# branch -- a permanent resident included -- as a ground to CONSIDER, never as +# engaged: the curated 'also_engages' flags (held for review) and the +# officer's 'organization' fact decide which paragraph is named. Every +# statutory phrase and case quotation is pinned by tests/test_transborder.py. +_S37_GROUNDS = { + "37(1)(a)": ( + "s. 37(1)(a): 'being a member of an organization that is believed on " + "reasonable grounds to be or to have been engaged in activity that is part " + "of a pattern of criminal activity planned and organized by a number of " + "persons acting in concert in furtherance of the commission of an offence " + "punishable under an Act of Parliament by way of indictment', or 'engaging " + "in activity that is part of such a pattern'. To establish: the " + "organization (read broadly -- Sittampalam, 2006 FCA 326: 'the same " + "\"unrestricted and broad\" interpretation should be given to the word " + "\"organization\"', caselaw fca-35313-16), the person's membership or " + "activity (a present finding 'may be based on a conclusion of fact as to " + "an individual's past membership in an organization', fca-35313-8, " + "applying s. 33), and the pattern of indictable offences. Entering Canada " + "with the help of a person involved in organized criminal activity is not " + "enough by itself (s. 37(2))."), + "37(1)(b)": ( + "s. 37(1)(b): 'engaging, in the context of transnational crime, in " + "activities such as people smuggling, trafficking in persons or laundering " + "of money or other proceeds of crime'. To establish: the conduct AND its " + "transnational-crime context -- a conviction for the conduct does not show " + "the context by itself. B010, 2015 SCC 58: the phrase 'refers to organized " + "transnational crime' (caselaw scc-15647-10), and for people smuggling the " + "provision 'targets procuring illegal entry in order to obtain, directly or " + "indirectly, a financial or other material benefit in the context of " + "transnational organized crime' (scc-15647-28)."), +} +_S37_AUTHORITIES = ( + "Authorities (in the corpus): Sittampalam v Canada (MCI), 2006 FCA 326 " + "(caselaw fca-35313) and B010 v Canada (MCI), 2015 SCC 58 (caselaw " + "scc-15647) -- read them with canlex_search_legislation (doc_type " + "'caselaw').") +_S37_ORG_CONDITION = ("if a criminal organization is involved (canlex_screen_record's " + "'organization' fact)") + + +def _s37_consequences(person): + """What follows if s. 37 is established, for this person.""" + pr = person == "permanent-resident" + return [ + "No conviction is needed: the facts 'include facts for which there are " + "reasonable grounds to believe that they have occurred, are occurring or " + "may occur' (s. 33; ENF 5 s. 7.4, enf-5-7-4).", + "It reaches 'A permanent resident or a foreign national' (s. 37(1)) -- a " + "permanent resident is reached even where s. 36 is not.", + "No rehabilitation: s. 36(3)(c) cures only s. 36 matters. " + + ("A permanent resident has no relief: s. 42.1 declarations are made 'on " + "application by a foreign national' or 'in respect of a foreign national'." + if pr else + "The only relief is a ministerial declaration under s. 42.1 ('on " + "application by a foreign national', or on the Minister's own " + "initiative), on 'national security and public safety considerations' " + "only (s. 42.1(3))."), + "No appeal to the Immigration Appeal Division: s. 64(1) bars it for " + "'organized criminality'.", + "Family members: " + ( + "not engaged for a permanent resident (s. 42 reaches foreign nationals)." + if pr else + "s. 37 is one of the four grounds that reach a temporary resident's " + "family members (s. 42(2): 'section 34, 35, 35.1 or 37')."), + "Removal: a s. 44(1) report goes to the Immigration Division, which makes " + "a deportation order (IRPR s. 229(1)(e)); IRPR s. 228(1) gives the " + "Minister's delegate no s. 37 case.", + ] + + +def _s37_flags(pairings, organization=None, offense=""): + """[(ground, condition or None, curated note, pairing id)] -- the s. 37 + paragraphs to consider for these pairings and the officer's organization + fact. A flag with 'terms' needs one of them in the record's words; one + 'when: organization' drops out when organization is False and is + conditional when it is not given.""" + text = " ".join((offense or "").lower().split()) + flags = {} + for p in pairings or []: + for e in (p or {}).get("also_engages") or []: + if e.get("terms") and not any(t.lower() in text for t in e["terms"]): + continue + cond = None + if e.get("when") == "organization": + if organization is False: + continue + if not organization: + cond = _S37_ORG_CONDITION + g = e.get("ground") + if g not in _S37_GROUNDS: + continue + if g not in flags or (flags[g][0] and not cond): + flags[g] = (cond, e.get("note"), p.get("id")) + if organization and (flags.get("37(1)(a)") or (True,))[0]: + flags["37(1)(a)"] = (None, "organization facts given by the officer", None) + return [(g,) + flags[g] for g in sorted(flags)] + + +def _s37_text(flags, person, labels=None): + """The block's lines for these flags; labels: {ground: '#1, #3'} (the + entries of a record that raise it).""" + lines = ["Consider -- not engaged: the s. 36 answer stands on its own, and s. 37 " + "is a separate ground with its own elements; nothing here is found for " + "you."] + for g, cond, note, pid in flags: + lines.append(f"- {_S37_GROUNDS[g]}" + + (f" [{cond}]" if cond else "") + + (f" (entries {labels[g]})" if labels and labels.get(g) else "") + + (f" (pairing {pid}: {note})" if note and pid else "")) + lines.append("If established:") + lines += [f"- {c}" for c in _s37_consequences(person)] + lines.append(_S37_AUTHORITIES) + return lines + + +def _s37_block(pairings, organization, person, offense): + """(lines, CANLEX_RESULT value) for the 's. 37 also to consider' block; + ([], None) when nothing flags s. 37.""" + flags = _s37_flags(pairings, organization, offense) + if not flags: + return [], None + return _s37_text(flags, person), { + "grounds": [g for g, _, _, _ in flags], + "conditional": {g: c for g, c, _, _ in flags if c}, + "organization": organization, + "from_pairings": sorted({pid for _, _, _, pid in flags if pid})} + + +def _allegation_text(para): + return " ".join(_ALLEGATION[para]) + + +def _allegation_lines(eq, pairing, is_conviction, unknown, count, prefix="", + act_not_settled=False, label=None, routed_question=None): + """The draft allegation paragraph(s) for one pairing's result: the + conviction paragraph, the act paragraph, or -- when the conviction + question is unsettled -- both, each labelled (roadmap T1.7(e)). + act_not_settled: after an acquittal the act paragraph is NOT SETTLED and + not drafted (disposition roadmap T1.5); label: its caveat's source.""" + branch = eq.get("branch") + name = pairing["canadian_offence"] + want_conv, want_act = is_conviction or unknown, (not is_conviction) or unknown + tag_c = " -- if the disposition is a conviction" if unknown else "" + tag_a = (" -- if it is NOT a conviction (act branch, on the facts)" + if unknown else "") + lines = [] + + def draft(para, tag, key=None): + return (f"{prefix}Draft allegation paragraph{tag}: the person is " + f"inadmissible under IRPA paragraph {para} for " + f"{_allegation_text(key or para)}, namely {name}.") + if branch in ("serious-criminality", "criminality"): + cp, ap = (("36(1)(b)", "36(1)(c)") if branch == "serious-criminality" + else ("36(2)(b)", "36(2)(c)")) + if want_conv: + lines.append(draft(cp, tag_c)) + if want_act and act_not_settled and routed_question: + lines.append(ACT_NOT_SETTLED_DRAFT_ROUTED.format( + prefix=prefix, tag=tag_a, para=ap, label=label or "policy", + question=routed_question[0].upper() + routed_question[1:])) + elif want_act and act_not_settled: + lines.append(ACT_NOT_SETTLED_DRAFT.format(prefix=prefix, tag=tag_a, para=ap, + label=label or "policy")) + elif want_act: + lines.append(draft(ap, tag_a)) + elif branch == "criminality-two-plus-only": + if want_conv: + if count == "two-plus-summary-only": + lines.append(draft("36(2)(b)", tag_c, "36(2)(b)-two")[:-1] + + " (name each conviction the count rests on).") + else: + lines.append(f"{prefix}No conviction-branch allegation{tag_c}: a " + "single summary-only equivalent grounds nothing -- " + "s. 36(2)(b) reaches two such offences 'not arising " + "out of a single occurrence'.") + if want_act: + lines.append(f"{prefix}No act-branch allegation{tag_a}: s. 36(2)(c) " + "needs an act that 'would constitute an indictable " + "offence'; a summary-only equivalent does not.") + return lines + + +def _family_line(person, application): + """The s. 42 family-member line keyed by the application (roadmap + T1.7(c)): s. 42(1) with IRPR s. 23 only for a permanent-residence + applicant; for temporary entry s. 42(2) keeps s. 36 away from family + members; with the application unknown, both readings.""" + if person == "permanent-resident": + return ("Family members: IRPA s. 42 (inadmissible family member) " + "reaches foreign nationals only; it is not engaged for a " + "permanent resident.") + if application == "pr-applicant": + return ("Family members (IRPA s. 42(1)): an applicant for permanent " + "residence is inadmissible if an accompanying family member is " + "inadmissible or, in the circumstances IRPR s. 23 prescribes (a " + "spouse, common-law partner or dependent child, on the " + "conditions set out there), a non-accompanying one; and an " + "accompanying family member of an inadmissible person is " + "inadmissible too (s. 42(1)(b)). Check the family members on " + "the application.") + if application == "temporary-entry": + return ("Family members: s. 36 does not reach family members (s. 42(2)) " + "-- for a temporary resident or an applicant for temporary " + "status, family-member inadmissibility arises 'only if the " + "family member is inadmissible under section 34, 35, 35.1 or " + "37'.") + return ("Family members (s. 42) turn on the application, which was not " + "given: for an applicant for permanent residence, s. 42(1) and IRPR " + "s. 23 reach accompanying (and some non-accompanying) family " + "members; for temporary entry, s. 36 does not reach family members " + "(s. 42(2): only ss. 34, 35, 35.1 and 37). Give application to " + "settle it.") + + +def _next_steps(status, is_conviction, unknown, eq, pairing, rehab, entry, + verdict, application=None, candidates=None, count="one", act=None): + """The draft allegation and the instruments an officer acts with next, + keyed off the result -- the removal path by paragraph, the s. 44 + discretion note, the family-member line by application, and the fees read + from the Regulations text (roadmap T1.7). candidates: [(pairing, fields)] + for a tie no supplied fact settles -- both allegations are drafted. act: + step 1's _act_branch -- after an acquittal no act-branch allegation is + drafted, and on the act footing alone no removal path is printed as if + one were (disposition roadmap T1.5).""" + from canlex.screening import branch_text + lines = [] + if (act or {}).get("held"): + # the review of batch 1, round 4 (RP-04): nothing is drafted from held content + return [ALLEGATION_HELD.format(entries=act["held"]), _family_line(status, application)] + ans = _act_not_settled(act) + akw = {"act_not_settled": ans, "label": (act or {}).get("label"), + "routed_question": (act or {}).get("routed_question")} + branch = (eq or {}).get("branch") + # One summary-only equivalent grounds nothing (s. 36(2)(b) needs two not + # arising out of a single occurrence): no removal path, s. 44 note, TRP or + # fee line for it (verifier, 2026-09-12). + single_summary = (branch == "criminality-two-plus-only" + and count != "two-plus-summary-only") + tran = (eq or {}).get("tran_candidates") or [] + if branch == "tie" and candidates: + need = (eq.get("needs") or [{}])[0] + lines.append("Allegation not settled: two pairings match equally, and " + f"{need.get('question') or 'the deciding fact'} decides which " + "paragraph applies. Both drafts:") + for p, f in candidates: + pre = f"If {_option_label(need, p)} ({p['id']}): " + if f["branch"] in _OPEN: + lines.append(f"{pre}the offence date decides the maximum and was not " + "given (step 2 lists each period) -- give offence_date " + "before drafting." if f["branch"] == "tran-open" else + f"{pre}the Canadian provision is not settled (step 2 " + "lists each reading) -- settle it before drafting.") + continue + lines += (_allegation_lines(f, p, is_conviction, unknown, count, pre, **akw) + or [f"{pre}no s. 36 allegation -- {branch_text(f['branch'])}."]) + elif branch in _OPEN and pairing and tran: + lines.append("Allegation not settled: the offence date decides which " + "maximum applied (Tran v Canada (Public Safety and Emergency " + "Preparedness), 2017 SCC 50: the maximum at commission " + "governs), and it was not given. Each draft:" + if branch == "tran-open" else + f"Allegation not settled: {_open_reason(eq)}. Each draft:") + for c in tran: + pre = f"If {_reading(c, subject=True)} (maximum {_max_shown(c)}): " + if c["branch"] == "not-inadmissible-pr": + lines.append(f"{pre}not inadmissible under s. 36 -- a permanent " + "resident is not reached by s. 36(2), and this maximum " + "is below the s. 36(1) threshold.") + continue + lines += (_allegation_lines({"branch": c["branch"]}, pairing, is_conviction, + unknown, count, pre, **akw) + or [f"{pre}no s. 36 allegation -- {branch_text(c['branch'])}."]) + elif branch in ("serious-criminality", "criminality", + "criminality-two-plus-only") and pairing: + lines += _allegation_lines(eq, pairing, is_conviction, unknown, count, **akw) + elif branch == "none": + lines.append("No allegation under s. 36 on the conviction branch: no " + "Canadian federal equivalent. Consider the act branch only " + "if the facts disclose conduct that IS an offence in Canada.") + elif branch == "not-inadmissible-pr": + lines.append("No allegation under s. 36: a permanent resident is not " + "reached by s. 36(2), and this matter is below the " + "s. 36(1) threshold.") + elif branch == "depends": + lines.append("Allegation not settled: the value, the substance schedule " + "or the elements decide between s. 36(1) and s. 36(2); " + "supply them and re-run.") + else: + lines.append("No allegation drafted: the Canadian equivalent is not " + "settled.") + lines.append("Hill comparison: read the Canadian provision named above with " + "canlex_get_section and set its essential elements beside the " + "foreign statute's (obtain the foreign statute text); where the " + "foreign offence is broader, the facts must supply the missing element " + "(Hill's evidence branch). Record the sentence imposed, the " + "court and the source document in the s. 44 narrative. The " + "IRB Legal Services paper on criminal equivalency (Removal " + "Order Appeals ch. 8) is in the corpus and collects the " + "Federal Court line: search it for the offence family.") + reportable = branch in ("serious-criminality", "criminality", + "criminality-two-plus-only", "depends", "unknown", "tie", + "tran-open", "limb-open") and not single_summary + if ans and not (is_conviction or unknown): + # disposition roadmap T1.5: only the act branch, and it is not settled + reportable = False + lines.append(ACT_NOT_SETTLED_NEXT.format(label=(act or {}).get("label") or "policy")) + pr = status == "permanent-resident" + if reportable: + lines.append(_REMOVAL_PR if pr else _REMOVAL_FN) + lines.append(_S44_NOTE) + trp_fee = _fee_amount("trp") + r_fee_serious = _fee_amount("rehab-serious") + r_fee_crim = _fee_amount("rehab-criminality") + if_used = _if_act_used(act) if not (is_conviction or unknown) else "" + if (status == "foreign-national" and branch not in ("none", "not-inadmissible-pr") + and not single_summary): + lines.append(if_used + "Temporary resident permit (IRPA s. 24): available at the " + "officer's discretion where entry is justified in the " + "circumstances, subject to the s. 24(3.1)-(5) bars; the " + "processing fee under IRPR s. 298 is " + + (f"${trp_fee}" if trp_fee else "set out in IRPR s. 298") + ".") + if rehab and rehab["status"] in ("eligible-to-apply", "apply-conditional", + "not-yet"): + if pr: + # s. 309 prescribes both fees 'in the case of a foreign national' + # (roadmap T1.7(d)): no amount is quoted for a permanent resident. + fee_text = ("IRPR s. 309 prescribes its processing fees 'in the case " + "of a foreign national' only, so no amount is quoted " + "here for a permanent resident (confirm with IRCC)") + else: + fee = (r_fee_serious if branch == "serious-criminality" else r_fee_crim) + fee_text = ("the IRPR s. 309 fee is " + + (f"${fee}" if fee else "set out in IRPR s. 309") + + f" for {'serious criminality' if branch == 'serious-criminality' else 'criminality'}") + lines.append(if_used + "Individual rehabilitation (IRPA s. 36(3)(c)): application " + "on IRCC form IMM 1444, guide IMM 5312 (in the corpus as " + "'Guide 5312' -- eligibility, the five-year calculation " + "with worked examples, fees and where to apply); " + + fee_text + "; ENF 14 / OP 19 describes processing.") + if rehab and rehab["status"] == "deemed-rehabilitated": + lines.append("Deemed rehabilitation needs no application; record the " + "s. 18(2) conditions checked in the notes.") + if pr and reportable: + serious = branch == "serious-criminality" + serious_reads = [c for c in tran if c["branch"] == "serious-criminality"] + if branch == "tie" and candidates: + maybe = any(f["branch"] == "serious-criminality" + or any(c["branch"] == "serious-criminality" + for c in f.get("tran_candidates") or []) + for _, f in candidates) + elif branch in _OPEN: + maybe = bool(serious_reads) + else: + maybe = branch in ("depends", "unknown") + if serious or maybe: + if serious_reads and all(not c.get("limb_label") for c in serious_reads): + when = ("the offence was committed " + + " or ".join(c["period"] for c in serious_reads)) + else: + when = " or ".join(_reading(c, subject=True) for c in serious_reads) + lead = ("No appeal" if serious else + f"If {when} (serious criminality, s. 36(1)(b)/(c)): no appeal" + if branch in _OPEN else + "If the finding is serious criminality (s. 36(1)(b)/(c)): no appeal") + lines.append(lead + " to the Immigration Appeal Division -- " + "s. 64(1) bars an appeal by a permanent resident found " + "inadmissible on grounds of serious criminality, and " + "s. 64(2) counts every crime 'described in paragraph " + "36(1)(b) or (c)'.") + lines.append(_family_line(status, application)) + lines.append(_PARDON_LINE) + return lines + + +@mcp.tool(name="canlex_screen_history", + annotations={"title": "Screen a Multi-entry Foreign Record", + **_READONLY}) +def canlex_screen_history( + entries_json: Annotated[str, Field( + description="A JSON list of record entries, each an object with " + "offense (required), disposition (required), and optional state, " + "country (overrides the top-level country), " + "offence_date, conviction_date (the date of conviction: the IRPR " + "s. 18(2) 'within / before the last 10 years' limbs read it; omitted, " + "they read it between the offence date and the sentence completion), " + "sentence_completed, sentence_imposed, court, facts (an element answer " + "from the result's needs goes here as ': '), " + "value_over_5000, schedule, occurrence_group (entries sharing a value " + "arose out of ONE occurrence; different values, separate ones -- " + "IRPA s. 36(2)(b) counts two offences only if 'not arising out of a " + "single occurrence'), date_of_birth or age_at_offence (the age at the " + "offence), and for a conviction IN CANADA jurisdiction 'canada' (or " + "country 'Canada', or a province as the state) with " + "canada_offence_mode, custody_imposed_months, canada_sentence and " + "canada_max_years as canlex_screen_record takes them, and " + "canada_prosecuted ('summarily' or 'indictment': how a hybrid offence " + "was prosecuted -- one reading of the IRPR s. 18(2) limbs turns on it, " + "Interpretation Act s. 34(1)(c)), and relief (any pardon, expungement, " + "sealing or record suspension of the entry, in the record's words, or " + "'none': read as the disposition's later history). Example: " + "[{\"offense\": \"DUI\", \"disposition\": \"convicted\", \"state\": \"TX\", " + "\"offence_date\": \"2016-03-01\", \"sentence_completed\": \"2017-03-01\"}, " + "{\"offense\": \"disorderly conduct\", \"disposition\": \"pled guilty\", " + "\"state\": \"TX\", \"offence_date\": \"2019-05-05\", " + "\"sentence_completed\": \"2019-06-01\"}]. At most 20 entries and 24,000 " + "characters of JSON (canlex_screen takes the same entries typed). Unknown " + "keys are refused, so a misspelt key never silently changes an answer.", + min_length=2, max_length=24000)], + status: Annotated[Optional[Literal["foreign-national", "permanent-resident"]], + Field(description="Whose record it is. Omit when not " + "known: a foreign national is assumed and the " + "result says so (CANLEX_RESULT 'assumed').") + ] = None, + today: Annotated[Optional[str], Field( + description="Optional assessment date (YYYY-MM-DD); defaults to the " + "server's UTC date -- give today= for the port of entry's date.", + max_length=10)] = None, + application: Annotated[Optional[Literal["temporary-entry", "pr-applicant", + "returning-pr"]], Field( + description="What the person is applying for: 'temporary-entry', " + "'pr-applicant' or 'returning-pr' (a permanent resident). It keys the " + "family-member line (IRPA s. 42); omit if unknown and both readings " + "are shown.")] = None, + country: Annotated[Optional[str], Field( + description="Optional country of the whole record, as a name. Omit " + "for a US record; any other country is read as a non-US record (no " + "US state row or term applied; labelled 'non-US record: curated " + "coverage is thin'). An entry's own 'country' key wins over this.", + max_length=60)] = None, + date_of_birth: Annotated[Optional[str], Field( + description="The person's date of birth (YYYY-MM-DD), for the age at " + "each entry's offence (an entry's own date_of_birth wins): 12 to 17 " + "prints the YOUTH block, under 12 Criminal Code s. 13.", + max_length=10)] = None, + detail: Annotated[Literal["card", "full"], Field( + description="'full' (default): the whole screening. 'card': a result " + "card of about ten lines -- the record-level result (or NOT SETTLED " + "and the deciding fact), each entry's branch, the dates, the action, " + "the facts that change the answer -- and the same CANLEX_RESULT " + "line.")] = "full", + language: Annotated[Literal["en", "fr", "both"], Field( + description="The language of the DRAFT s. 44(1) narrative skeleton (ENF 5 " + "s. 13.1) the full screening ends with: 'en' (default), 'fr' or " + "'both'.")] = "en", +) -> str: + """Screen a whole foreign record: each entry through the chain (the same step-1 + and step-2 rules as canlex_screen_record), then the record-level answer + the single-entry memo cannot give -- the count for IRPR s. 18(2) (one + indictable-equivalent, two-or-more summary-only not arising out of a + single occurrence, or multiple), the s. 17 application clock from the + MOST RECENT conviction of any kind except contraventions and Young + Offenders Act matters (and which entry set it), and one consolidated + bottom line. Equal or missing offence dates on a two-offence count leave + the single-occurrence question open, and both readings are given. + Non-US entries (a country other than the US, or a state value that is + not a US state or territory) are matched without US state rows or + terms, and labelled 'non-US record: curated coverage is thin'. An entry + from CANADA is screened on s. 36(1)(a)/(2)(a) (roadmap T2.2): its + two-offence count is taken over the Canadian convictions, its cure is a + record suspension, and a Canadian conviction still counts as a + 'subsequent offence' for the s. 17 clock and against deemed + rehabilitation. The age at each offence prints the YOUTH block (roadmap + T2.6), and a conviction in absentia starts no clock unless its sentence + was served (roadmap T2.7, where the held entry says so). Deemed + rehabilitation is tested limb by limb (roadmap T2.3): every limb of IRPR + s. 18(2)(a)-(c) and s. 18.1 against the entries, each with the entry that + decides it and the date it starts or stops holding; where an unsettled + reading (a hybrid equivalent in (iv)/(vi); the anchor of 'the last N + years') or a missing fact decides it, every reading is given. As in + canlex_screen_record (roadmap T4.1-T4.2, T4.7): THE ANSWER CHANGES WITH + THESE FACTS at the top ('decisive_missing'), 'confidence', and detail + 'card'. An entry's conviction_date also bounds the Tran maximum when its + offence date is not given. An entry's 'relief' (a pardon, expungement, + sealing or record suspension, in the record's words; 'none' confirms + none) is read as the disposition's later history. The full screening ends + with the DRAFT s. 44(1) narrative skeleton (roadmap T4.6; ENF 5 s. 13.1) + for every allegation the record supports, in language 'en', 'fr' or + 'both'. canlex_screen takes the same entries as typed objects. + + Returns: + str: A per-entry table, the record-level rehabilitation answer, the + limb tables, the narrative skeleton, and one CANLEX_RESULT line. + """ + try: + entries = json.loads(entries_json) + assert isinstance(entries, list) and entries + except (ValueError, AssertionError): + return "Error: entries_json must be a non-empty JSON list of objects." + return _screen_entries(entries, status=status, today=today, application=application, + country=country, date_of_birth=date_of_birth, detail=detail, + language=language, tool="canlex_screen_history") + + +class ScreenEntry(BaseModel): + """One entry of a record for canlex_screen: one conviction, disposition or + charge, in the record's own words -- the keys canlex_screen_history's + entries_json takes, typed. Unknown keys are refused, so a misspelt key + never silently changes an answer.""" + model_config = ConfigDict(extra="forbid") + offense: Annotated[str, Field( + description="The offence as it appears on the record ('DUI', 'PC 647(f)', " + "'possession of Xanax').", min_length=2, max_length=200)] + disposition: Annotated[str, Field( + description="The disposition in the record's own words ('convicted', 'deferred " + "adjudication', 'nolo', 'expunged', 'pending').", min_length=1, max_length=200)] + state: Annotated[Optional[str], Field( + description="US state (name or 2-letter code), or a Canadian province for a " + "conviction in Canada. Another value is read as a non-US record.", + max_length=30)] = None + country: Annotated[Optional[str], Field( + description="Country of this entry, as a name. Omit for a US record; 'Canada' " + "screens a conviction in Canada.", max_length=60)] = None + jurisdiction: Annotated[Optional[Literal["canada"]], Field( + description="'canada' for a conviction in Canada (as country 'Canada').")] = None + offence_date: Annotated[Optional[str], Field( + description="Date the offence was committed (YYYY-MM-DD): the Tran maximum, the " + "act-branch clock, the single-occurrence question.", max_length=10)] = None + conviction_date: Annotated[Optional[str], Field( + description="Date of the conviction (YYYY-MM-DD): the IRPR s. 18(2) 'last 10 " + "years' limbs read it, and it bounds the Tran maximum when the offence date is " + "not given.", max_length=10)] = None + sentence_completed: Annotated[Optional[str], Field( + description="Date the ENTIRE sentence was completed (YYYY-MM-DD).", + max_length=10)] = None + sentence_imposed: Annotated[Optional[str], Field( + description="The sentence as imposed, kept with the entry (the record-level " + "chain does not read it; canlex_screen_record projects a completion from it).", + max_length=200)] = None + court: Annotated[Optional[str], Field( + description="The convicting court, kept with the entry.", max_length=120)] = None + facts: Annotated[Optional[str], Field( + description="What the person did; an element answer from the result's 'needs' " + "goes here as ': '.", max_length=600)] = None + value_over_5000: Annotated[Optional[bool], Field( + description="Theft, fraud, mischief, stolen property: True if the value " + "exceeded CAD 5,000.")] = None + schedule: Annotated[Optional[Literal["I", "II", "III", "IV", "cannabis"]], Field( + description="Drug matters where the record does not name the substance: the " + "CDSA schedule (or 'cannabis').")] = None + occurrence_group: Annotated[Optional[str | int], Field( + description="Entries sharing a value arose out of ONE occurrence; different " + "values, separate ones (IRPA s. 36(2)(b)).")] = None + date_of_birth: Annotated[Optional[str], Field( + description="The person's date of birth (YYYY-MM-DD), for the age at this " + "offence; the record-level date_of_birth applies otherwise. The age_at_offence " + "avoids giving a date of birth.", max_length=10)] = None + age_at_offence: Annotated[Optional[int], Field( + ge=0, le=120, description="The age at this offence, when no date of birth is " + "given.")] = None + relief: Annotated[Optional[str], Field( + description="Any pardon, expungement, sealing or record suspension of this " + "entry, in the record's words ('pardoned 2019'), or 'none'. Read as the " + "disposition's later history; wins over the record-level relief. A conviction " + "in Canada reads only its own relief, and only a record suspension under the " + "Criminal Records Act ('record suspension ordered 2019').", + max_length=200)] = None + canada_offence_mode: Annotated[Optional[Literal["summary-only", "hybrid", "indictable"]], + Field(description="A conviction in Canada: how the " + "offence may be prosecuted.")] = None + custody_imposed_months: Annotated[Optional[float], Field( + ge=0, le=1200, description="A conviction in Canada: the term of imprisonment " + "imposed, in months (0 = none).")] = None + canada_sentence: Annotated[ + Optional[Literal["custody", "intermittent", "conditional-sentence", + "suspended-sentence", "probation", "fine", "absolute-discharge", + "conditional-discharge", "youth-sentence", "contravention"]], + Field(description="A conviction in Canada: the sentence or outcome.")] = None + canada_max_years: Annotated[Optional[int], Field( + ge=0, le=99, description="A conviction in Canada: the maximum term (years) at " + "the offence date; 99 = life.")] = None + canada_prosecuted: Annotated[Optional[Literal["summarily", "indictment"]], Field( + description="A conviction in Canada: how a hybrid offence was prosecuted (one " + "reading of the IRPR s. 18(2) limbs turns on it).")] = None + + @field_validator("offense", "disposition", "state", "country", "sentence_imposed", + "court", "facts", "relief", mode="after") + @classmethod + def _one_line(cls, value): + """The record's words on one line (the code audit of 2026-09-21): a newline + in a disposition was echoed into the memo, where it forged a second + CANLEX_RESULT or WARNING line for a top-down reader. Whitespace, newlines + included, folds to single spaces; any other control character is refused; + a value that is blank once folded is refused.""" + if value is None: + return value + if any(ord(ch) < 32 and ch not in "\t\n\r\x0b\x0c" or ord(ch) == 127 for ch in value): + raise ValueError("control characters are not allowed") + folded = " ".join(value.split()) + if not folded: + raise ValueError("must not be blank") + return folded + + +class _HistoryEntry(ScreenEntry): + """canlex_screen_history's entry: ScreenEntry's keys and checks, with + canada_prosecuted left as text so the tool's own error line for it stands. + Unknown keys are refused, as canlex_screen refuses them (the audit follow-up + of 2026-09-21: the tool used to drop a misspelt key silently, so + 'offense_date' ran the entry with no offence date and the memo changed -- + 'maximum inferred from the completion date' -- without a word).""" + model_config = ConfigDict(extra="forbid") + canada_prosecuted: Annotated[Optional[str], Field(max_length=30)] = None + + +@mcp.tool(name="canlex_screen", + annotations={"title": "Screen a Foreign Record (typed entries)", **_READONLY}) +def canlex_screen( + entries: Annotated[list[ScreenEntry], Field( + description="The record, one object per conviction, disposition or charge, " + "each in the record's own words (offense and disposition required). One entry " + "is fine. No free-text rap-sheet parsing: split the record into entries.", + min_length=1, max_length=20)], + status: Annotated[Optional[Literal["foreign-national", "permanent-resident"]], + Field(description="Whose record it is. Omit when not known: a " + "foreign national is assumed and the result says so.")] = None, + application: Annotated[Optional[Literal["temporary-entry", "pr-applicant", + "returning-pr"]], Field( + description="What the person is applying for; it keys the family-member line " + "(IRPA s. 42). Omit if unknown: both readings are shown.")] = None, + country: Annotated[Optional[str], Field( + description="Optional country of the whole record, as a name. Omit for a US " + "record; any other country is read as a non-US record (labelled 'non-US " + "record: curated coverage is thin'). An entry's own country wins.", + max_length=60)] = None, + date_of_birth: Annotated[Optional[str], Field( + description="The person's date of birth (YYYY-MM-DD), for the age at each " + "offence (an entry's own date_of_birth or age_at_offence wins).", + max_length=10)] = None, + relief: Annotated[Optional[str], Field( + description="Any pardon, expungement, sealing or record suspension on the " + "record, in its own words, applied to every foreign entry without its own " + "'relief' (never to a conviction in Canada: give its record suspension as the " + "entry's relief); 'none' confirms there is none. Omit when unknown: the tool " + "assumes none and tests whether relief would change the answer.", + max_length=200)] = None, + today: Annotated[Optional[str], Field( + description="Optional assessment date (YYYY-MM-DD); defaults to the " + "server's UTC date -- give today= for the port of entry's date.", + max_length=10)] = None, + detail: Annotated[Literal["card", "full"], Field( + description="'card' (default): a result card of about ten lines -- the result " + "or NOT SETTLED with the deciding fact, each entry's branch, the dates, the " + "action, the facts that change the answer -- and the CANLEX_RESULT line. " + "'full': the whole screening, with the per-entry table, the limb tables and " + "the DRAFT s. 44(1) narrative skeleton.")] = "card", + language: Annotated[Literal["en", "fr", "both"], Field( + description="The language of the narrative skeleton in the full screening: " + "'en' (default), 'fr' or 'both'.")] = "en", +) -> str: + """Screen a foreign record -- one entry or several -- given as typed + entries (roadmap T4.4): each entry through the s. 36 chain (is the + disposition a conviction; the Canadian equivalent and its maximum at the + offence date; the branch for this person), then the record-level answer + (the IRPR s. 18(2) count, deemed rehabilitation limb by limb, the s. 17 + clock from the most recent conviction). The same pipeline as + canlex_screen_history, which takes the entries as JSON in a string. There + is no free-text rap-sheet parsing: each conviction, disposition or charge + is one entry, in the record's own words. 'relief' records a pardon, + expungement, sealing or record suspension; 'none' confirms there is none. + The result card is the default: THE ANSWER CHANGES WITH THESE FACTS lists + what to ask before concluding, and a result that is not settled says NOT + SETTLED. detail 'full' adds the per-entry table, the limb tables and the + DRAFT s. 44(1) narrative skeleton (ENF 5 s. 13.1; language 'en', 'fr' or + 'both'). The curated tables were compiled for US records; a non-US entry + is labelled 'non-US record: curated coverage is thin'. + + Returns: + str: The result card (or the full screening) and one CANLEX_RESULT line. + """ + ents = [] + for i, e in enumerate(entries or [], 1): + try: + model = e if isinstance(e, ScreenEntry) else ScreenEntry.model_validate(e) + except ValidationError as exc: + return f"Error: entry {i}: " + "; ".join( + f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}" + for err in exc.errors()) + ents.append(model.model_dump(exclude_none=True)) + return _screen_entries(ents, status=status, today=today, application=application, + country=country, date_of_birth=date_of_birth, detail=detail, + language=language, relief=relief, tool="canlex_screen") + + +def _fold_ws(value): + """A text argument with its whitespace, newlines included, folded to single + spaces; anything that is not text is returned as it came.""" + return " ".join(value.split()) if isinstance(value, str) else value + + +def _md_cell(value): + """A Markdown table cell: the value on one line with '|' escaped, so a pipe + in an entry's words cannot shift the columns (the code audit of 2026-09-21).""" + return " ".join(str("" if value is None else value).split()).replace("|", "\\|") + + +def _screen_entries(entries, status=None, today=None, application=None, country=None, + date_of_birth=None, detail="full", language="en", relief=None, + tool="canlex_screen_history"): + """The record pipeline canlex_screen_history and canlex_screen share + (roadmap T4.4): validation, the per-entry chain (_history_rows), the + record step (_history_answer), the decisive facts, the narrative skeleton, + the card or the full screening, and the CANLEX_RESULT line (tool names + the calling tool). relief: canlex_screen's record-level relief.""" + from canlex import narrative + from canlex.commentary import BANNER + from canlex.rehab import _dedupe as _dedupe_needs, assess_canadian, assess_record + from canlex.screening import (add_years, alias_needs, disposition_notes, + has_military_cue, occurrence_range, offence_notes, + parse_date, result_line, split_offences) + if not isinstance(entries, list) or not entries: + return "Error: the record needs at least one entry." + if len(entries) > 20: + return "Error: at most 20 entries per call." + import datetime as _dt + try: + today_d = parse_date(today) or _dt.date.today() + dob_top = parse_date(date_of_birth) + except ValueError as exc: + return f"Error: dates must be YYYY-MM-DD ({exc})." + if detail not in ("card", "full"): + return f"Error: detail must be 'card' or 'full' (got '{detail}')." + if language not in ("en", "fr", "both"): + return f"Error: language must be 'en', 'fr' or 'both' (got '{language}')." + person = status or "foreign-national" + warnings = [] + app_warning = _application_warning(person, application) + if app_warning: + warnings.append(app_warning) + edata = _load_equivalency() + # Every entry and every date is validated before anything is resolved + # (roadmap T1.1: an unparsed date used to reach the Tran lookup first). + parsed = [] + relief_known, relief_flags = [], [] + for i, ent in enumerate(entries, 1): + if not isinstance(ent, dict) or not ent.get("offense") or not ent.get("disposition"): + return f"Error: entry {i} needs offense and disposition." + # every value typed as canlex_screen types it (the code audit of 2026-09-21: an + # int offence or a list of facts raised out of the matcher to the client, and a + # blank offence got a memo); canlex_screen_history's unknown keys stay ignored + try: + ent = _HistoryEntry.model_validate(ent).model_dump(exclude_none=True) + except ValidationError as exc: + return f"Error: entry {i}: " + "; ".join( + f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}" for err in exc.errors()) + # one offence per entry (the code audit of 2026-09-21): canlex_screen_record + # refuses 'DUI; theft' and sends the officer here, where the same words matched + # the first offence alone and dropped the second from the count, the s. 18(2) + # limbs and the s. 17 clock + if edata is not None: + estate = _jurisdiction(ent.get("state"), ent.get("country") or country)["state"] + parts = split_offences(ent["offense"], edata.get("pairings", []), estate) + if len(parts) > 1: + from canlex.screening import split_amount_caution + return (f"Error: entry {i} reads as {len(parts)} offences ({'; '.join(parts)}): " + "give each offence as its own entry (the same occurrence_group where " + "they arose out of one occurrence), so the count and the s. 17 clock " + "are computed on every offence." + + split_amount_caution(parts, edata.get("pairings", []), estate)) + try: + completed = parse_date(ent.get("sentence_completed")) + od = parse_date(ent.get("offence_date")) + cdate = parse_date(ent.get("conviction_date")) + dob_e = parse_date(ent.get("date_of_birth")) or dob_top + except ValueError as exc: + return f"Error: entry {i}: dates must be YYYY-MM-DD ({exc})." + # the conviction date (roadmap T2.3): the s. 18(2) 'last N years' limbs read it + for bad, why in ((cdate and od and cdate < od, "precedes the offence date"), + (cdate and completed and cdate > completed, + "is after the sentence completion"), + (cdate and cdate > today_d, "is after the assessment date")): + if bad: + warnings.append(f"entry {i}: the conviction date {cdate} {why}") + err, ccust, cmax, age_e = _canada_inputs( + ent.get("canada_offence_mode"), ent.get("custody_imposed_months"), + ent.get("canada_sentence"), ent.get("canada_max_years"), + ent.get("age_at_offence"), where=f"entry {i}: ") + if err: + return err + # how a Canadian hybrid offence was prosecuted: read by the s. 18(2) + # limbs on one reading of roadmap DECISION (a) (Interpretation Act + # s. 34(1)(c)); IRPA s. 36 itself does not turn on it (s. 36(3)(a)) + pros = ent.get("canada_prosecuted") + if pros is not None: + pros = _PROSECUTED_WORDS.get(str(pros).strip().lower()) + if pros is None: + return (f"Error: entry {i}: canada_prosecuted must be 'summarily' or " + "'indictment'.") + # relief (roadmap T4.4): the entry's own, else canlex_screen's record-level + # relief, read as the disposition's later history -- the words step 1 + # matches the relief entries on; 'none' confirms there is none. A + # conviction in Canada reads only its own relief, and only a record + # suspension (wave-4 verifier: _canada_relief) + own = ent.get("relief") + canada_ctx = None + if _is_canada_entry(ent, country): + rel, flag = _canada_relief(i, own, relief, od, cdate, today_d, completed) + if flag: + relief_flags.append(flag) + warnings.append(flag) + relief_known.append(rel is not None or (own is not None and _no_relief(own))) + # G2 (2026-09-22): the disposition reads its own words and the relief key + # TOGETHER, whether or not the key was applied (REL-3) -- a key the flag + # refused still takes away a relief the disposition names + canada_ctx = (ent["disposition"], + None if own is None or _no_relief(own) else str(own).strip(), + today_d) + else: + rel = own if own is not None else relief + relief_known.append(rel is not None) + if rel is not None and not _no_relief(rel): + # the entry's relief is the words read (the record-level relief for + # a foreign entry without its own): the decisive re-run skips it + ent = dict(ent, disposition=f"{ent['disposition']}; {str(rel).strip()}", + relief=str(rel).strip()) + if canada_ctx is not None: + ent = dict(ent, _canada_relief_ctx=canada_ctx) + parsed.append((i, ent, od, completed, dob_e, age_e, + (ent.get("canada_offence_mode"), ccust, ent.get("canada_sentence"), cmax), + cdate, pros)) + memo = {} # the base rows and the decisive re-runs share it + rows, entry_notes, alias_entry_needs, held_rows = _memo_run( + memo, _history_rows, parsed, person, country, edata) + lines = [BANNER, "", "# Record screening -- all entries"] + for w in warnings: + lines.append(f"WARNING: {w}.") + non_us = [r["n"] for r in rows if r["jurisdiction"] == "non-us"] + if non_us: + lines.append(f"NOTE -- {NON_US_LABEL} (entr{'y' if len(non_us) == 1 else 'ies'} " + + ", ".join(f"#{n}" for n in non_us) + "): the disposition " + "entries, offence pairings and state rows CanLex curates were " + "compiled for US records, so for these entries they are a " + "starting point only -- apply the governing framework to the " + "foreign law itself. US state rows and terms are not applied " + "to them.") + assumed_at = len(lines) + # the record step (roadmap T4.1: the chain's record-level fields), in the memo the + # decisive re-runs share, so a re-run that leaves the record unchanged reuses it + ans = _memo_run(memo, _history_answer, rows, person, today_d, edata, application, + entry_notes) + (engaged, serious, summary_only, unresolved, canada_entries, rec, notes, count, + count_readings, rehab, s17_row, not_before, needs, occurrence_unsettled) = ( + ans[k] for k in ("engaged", "serious", "summary_only", "unresolved", + "canada_entries", "rec", "notes", "count", "count_readings", + "rehab", "s17_row", "not_before", "needs", + "occurrence_unsettled")) + lines += ["", + "| # | offence | disposition | conviction? | equivalent | branch | max | note |", + "|---|---|---|---|---|---|---|---|"] + for r in rows: + # (the code audit of 2026-09-21: a '|' in an entry's words shifted the columns) + if r["canada"]: + mx = r["canada"]["info"]["max_years"] + lines.append(f"| {r['n']} | {_md_cell(r['offense'])} | {_md_cell(r['disposition'])} | " + f"{r['verdict']} | {_md_cell(r['display'])} | " + f"{r['canada']['res']['canada_branch']} | " + f"{_years(mx) if mx is not None else '--'} | {_md_cell(r['note'])} |") + continue + eq = r["eq"] or {} + lines.append(f"| {r['n']} | {_md_cell(r['offense'])} | {_md_cell(r['disposition'])} | " + f"{r['verdict']} | {_md_cell(r['display'] or '--')} | " + f"{eq.get('branch') or '--'} | " + f"{'summary' if eq.get('summary_only') else (eq.get('max_years') if eq.get('max_years') is not None else '--')} | " + f"{_md_cell(r['note'] or '')} |") + lines += ["", "## Record-level answer", "\n".join(f"- {n}" for n in notes) if notes + else "- Nothing on this record engages s. 36 on the curated pairings."] + # the limb tables (roadmap T2.3) + limbs_shown = bool(rehab or count_readings) + if limbs_shown: + lines += ["", "## IRPR s. 18(2) limb by limb (deemed rehabilitation)"] + if occurrence_unsettled: + lines.append("Reading 1 of the count (separate occurrences) -- one occurrence " + "grounds nothing, and nothing is left to rehabilitate.") + lines += _limb_section(rec, today_d) + if rec["s18_1"]: + lines += ["", "## IRPR s. 18.1 limb by limb (two or more summary-only " + "convictions in Canada)"] + _limb_table([rec["s18_1"]], today_d) + # s. 37 (roadmap T2.5): named whatever the s. 36 answer, once for the record + s37_entries, merged, labels = [], {}, {} + for r in rows: + for g, cond, s37_note, pid in r["s37"]: + s37_entries.append({"entry": r["n"], "ground": g, "conditional": bool(cond)}) + labels[g] = (labels[g] + ", " if g in labels else "") + f"#{r['n']}" + if g not in merged or (merged[g][0] and not cond): + merged[g] = (cond, s37_note, pid) + if merged: + lines += ["", "## s. 37 also to consider (organized criminality)", + "\n".join(_s37_text([(g,) + merged[g] for g in sorted(merged)], + person, labels))] + # the DRAFT s. 44(1) narrative skeleton (roadmap T4.6; ENF 5 s. 13.1) + nar_allg, nar_notes, nar_held = _history_narrative(rows, ans) + lines += [""] + narrative.render(nar_allg, person, status is not None, language, + nar_notes, nar_held) + lines += ["", "Run canlex_screen_record on any single entry for its full memo, " + "allegation wording and next steps.", ""] + assumed, suffix = [], {} + if status is None: + assumed.append("status-foreign-national") + if application is None and person == "foreign-national": + assumed.append("application-unknown") + no_age = [r for r in rows if not r["youth"] and ( + r["youth_screen"] is None or r["youth_screen"]["band"] == "unknown")] + if no_age and not any(r["youth"] for r in rows): + assumed.append("adult-at-offence") + if len(no_age) < len(rows): + suffix["adult-at-offence"] = (" (entr" + ("y" if len(no_age) == 1 else "ies") + + " " + ", ".join(f"#{r['n']}" for r in no_age) + ")") + # relief given for the whole record, or for every entry: nothing assumed (a + # conviction in Canada counts as given only by its own relief) + if not all(relief_known): + assumed.append("no-foreign-relief") + at_current = [r for r in rows if r["offence_date"] is None and r["eq"] + and r["eq"].get("assessed_basis") in ("current", "undetermined") + and r["eq"].get("branch") not in _OPEN] + if at_current: + assumed.append("current-maximum") + suffix["current-maximum"] = (" (entr" + ("y" if len(at_current) == 1 else "ies") + + " " + ", ".join(f"#{r['n']}" for r in at_current) + ")") + assumed.append("record-complete") + if held_rows: + assumed.append("held-content") + suffix["held-content"] = (" (entr" + ("y" if len(held_rows) == 1 else "ies") + " " + + ", ".join(f"#{n}" for n in held_rows) + ")") + # roadmap T4.1: the facts that change the answer, re-run over the record + decisive, checked = _memo_run(memo, _history_decisive, parsed, person, country, edata, + today_d, application, rows, ans, assumed, alias_entry_needs) + for d in decisive: + if d.get("assumed"): + suffix[d["assumed"]] = suffix.get(d["assumed"], "") + DECISIVE_SUFFIX + whole = _whole_answer_only(checked, bool(unresolved)) + lines[assumed_at:assumed_at] = (_decisive_lines(decisive, checked, _history_decisive_answer, + whole=whole) + + _assumed_block(assumed, suffix)) + res_line = (result_line(tool=tool, entries=len(rows), + engaged=len(engaged), serious=len(serious), + summary_only=len(summary_only), unresolved=len(unresolved), + count=count, person=person, application=application, + occurrence_unsettled=occurrence_unsettled, + count_readings=count_readings, + s17_clock_entry=(s17_row or ans["s17_act_row"] or {}).get("n"), + s17_clock_from=(s17_row["completed"].isoformat() if s17_row else + ans["s17_act_row"]["offence_date"].isoformat() + if ans["s17_act_row"] else None), + # G3 (S17-1): the clock runs from an act's offence date + **({"s17_clock_kind": "offence-date"} + if ans["s17_act_row"] else {}), + # engine-s17 (audit follow-up 2026-09-21): both readings' dates + **({"s17_readings": ans["s17_readings"]} + if ans.get("s17_readings") else {}), + rehab_status=rehab["status"] if rehab else None, + rehab_provision=rehab["provision"] if rehab else None, + earliest_deemed=(rehab or {}).get("earliest_deemed"), + earliest_apply=(rehab or {}).get("earliest_apply"), + earliest_apply_not_before=not_before, + earliest_deemed_exact=(rec["earliest_deemed_exact"] + if (rehab or {}).get("earliest_deemed") + else None), + needs=_dedupe_needs( + alias_entry_needs + + ((needs + rec["needs"]) if (rehab or count_readings) + else [])), + s18=(_s18_compact(rec["paragraphs"]) + if limbs_shown and not rec["readings"] else None), + s18_readings=([dict({k: x[k] for k in ( + "reading", "hybrid", "anchor", "status", "provision", + "earliest_deemed", "earliest_apply")}, + paragraphs=_s18_compact(x["paragraphs"])) + for x in rec["readings"]] + if limbs_shown and rec["readings"] else None), + s18_dates_differ=rec["dates_differ"] if limbs_shown else None, + s18_1=_s18_compact([rec["s18_1"]])[0] if rec["s18_1"] else None, + assumed=assumed, warnings=warnings, + jurisdiction=_jurisdiction_summary( + [r["jurisdiction"] for r in rows]), + non_us_entries=non_us, s37_entries=s37_entries, + canada_entries=canada_entries, + youth_entries=[{"entry": r["n"], **r["youth_screen"]} + for r in rows if r["youth_screen"]], + absentia_entries=[r["n"] for r in rows if r["absentia"]], + **({"held_entries": held_rows} if held_rows else {}), + disposition_flags=[{"entry": r["n"], + "flags": r["disposition_flags"]} + for r in rows if r.get("disposition_flags")], + # disposition roadmap T1.10: each entry's step 1 + step1=[_history_step1_result(r) for r in rows], + **({"detail": "card"} if detail == "card" else {}), + decisive_missing=decisive, decisive_checked=checked, + **({"decisive_whole_answer_only": whole} if whole else {}), + narrative=narrative.result_field(nar_allg, language, nar_held), + confidence=_history_confidence(rows, rec, limbs_shown))) + if detail == "card": + # LEGAL-6 (2026-09-22): a prerogative pardon in a Canadian entry's own words; G2: + # the other NOT SETTLED relief readings and a relief key that does not stand + pp_notes = _canada_card_relief_notes(rows, relief_flags) + # (round H1, GQ-6: the possession-limit question is on the card too) + return "\n".join(_history_card(rows, ans, today_d, person, decisive, checked, + assumed, + notes=relief_flags + pp_notes + + _possession_limit_notes(rows), whole=whole) + + [res_line]) + lines.append(res_line) + return _with_superseded("\n".join(lines)) + + +# ---- canlex_screen_history on the pure chain (roadmap T4.1) ------------------ + +def _history_rows(parsed, person, country, edata, tran_od=None): + """Steps 1 and 2 for every entry of a record (canlex_screen_history's + per-entry chain, moved here unchanged so the decisive-facts re-runs call + it too): (rows, entry_notes, alias_entry_needs, held_rows). parsed: the + tool's validated entries. tran_od: {entry number: date} -- the date step + 2 reads the maximum at for that entry, when a re-run tries one period of + a straddled maximum (the entry's own offence date, clocks and occurrence + are unchanged). The conviction date bounds the Tran maximum as the + completion date does (roadmap T4.1).""" + from canlex.screening import (alias_needs, disposition_notes, has_military_cue, + offence_notes) + rows, entry_notes = [], [] + # what a statute code an entry names leaves open (the offence date of a + # date-dependent code, the limb of an element-split one), per entry -- + # carried in CANLEX_RESULT 'needs' whatever else is settled (verifier, + # 2026-09-15: the text asked for offence_date, the result did not) + alias_entry_needs = [] + held_rows = [] # entries whose words reach content held for review first + # G2 (REL-5): a record suspension a later Canadian conviction may have ended + later_map = _canada_later_convictions(parsed, country) + for i, ent, od, completed, dob_e, age_e, cin, cdate, pros in parsed: + # step 1 and step 2: the rules canlex_screen_record uses (roadmap T1.4) + j = _jurisdiction(ent.get("state"), ent.get("country") or country, ent.get("disposition")) + if j["kind"] == "canada" or str(ent.get("jurisdiction") or "").strip().lower() == "canada": + # a conviction IN Canada (roadmap T2.2): assessed at record level below + rows.append(_canada_history_row(i, ent, od, completed, dob_e, age_e, cin, + edata, person, cdate, later_map.get(i))) + rows[-1]["conviction_date"] = cdate + rows[-1]["prosecuted"] = pros + rs_note = rows[-1]["canada"]["cd"].get("record_suspension_note") + if rs_note: + later = rows[-1]["canada"]["cd"].get("relief_later") + entry_notes.append(f"#{i}: {rs_note}" + ( + f" (The other Canadian conviction{'s' if len(later) > 1 else ''} on this " + f"record: {_later_entries_text(later)}.)" if later else "")) + continue + s1 = _step1(ent["disposition"], j["state"], j["kind"], _record_places(j)) + verdict, conv = s1["verdict"], s1["conviction"] + entry_id = (s1["entry"] or {}).get("id") + # roadmap T2.7: a conviction in absentia starts no clock unless served + absentia = ((s1["entry"] or {}).get("rehab_clock") == _ABSENTIA_RULE + and (conv or s1["unknown"])) + absentia_held = bool(not absentia and (conv or s1["unknown"]) + and _ABSENTIA_WORDS.search(ent["disposition"] or "")) + ys, _, _, yneeds = _youth_screen(dob_e, age_e, od, completed, + entry_youth=entry_id in _YOUTH_ENTRIES) + offense = ent["offense"] + if has_military_cue(ent["disposition"]) and not has_military_cue(offense): + offense = f"UCMJ {offense}" + eq, note, display, s37f, s2 = None, [], None, [], None + if edata is not None: + value = ent.get("value_over_5000") + s2 = _step2(offense, edata, j["state"], + {"value_over_5000": None if value is None else _as_bool(value), + "schedule": ent.get("schedule"), "text": ent.get("facts"), + "quantity_text": _quantity_context(ent.get("facts"), + ent.get("disposition"))}, + (tran_od or {}).get(i, od), person, completed=completed, + convicted=cdate) + eq = s2["fields"] + # s. 37 (roadmap T2.5): no organization fact per entry, so a flag + # that needs one stays conditional + s37f = _s37_flags([p for p, _ in s2["candidates"]], None, offense) + if s2["tie"]["tied"]: + need = s2["tie"]["needs"][0] + display = f"tie: {eq['pairing']} / {eq['tied_with']}" + note.append(f"tie: {need['parameter'] or need['fact']} decides") + entry_notes.append(f"#{i}: tie between {eq['pairing']} and " + f"{eq['tied_with']} -- {need['question']}") + elif s2["tie"]["resolution"]: + rr = s2["tie"]["resolution"] + note.append(f"tie resolved on {rr['parameter']} ({rr['label']})") + if eq is not None: + if eq.get("elements_question"): + note.append("elements decide (question below)") + if eq.get("resolver"): + rv = eq["resolver"] + note.append(f"element resolved: {rv['section']} " + f"({rv['fact']}: {rv['answer']})") + for need in eq.get("resolver_needs") or []: + entry_notes.append(f"#{i}: deciding element -- {need['question']}") + for rn in eq.get("resolver_notes") or []: + entry_notes.append(f"#{i}: {rn}") + if eq.get("if_elements_met_max") is not None: + note.append("if the elements make it the Canadian offence: " + + _years(eq["if_elements_met_max"])) + if eq.get("branch") in _OPEN: + from canlex.screening import INCHOATE_FACT + inc_only = ((eq.get("inchoate") or {}).get("open") + and not {"term", "quantity"} & set(eq.get("limb_open") or [])) + note.append("the offence date decides the band: give offence_date" + if eq["branch"] == "tran-open" else + "the inchoate reading decides the band: answer " + f"'{INCHOATE_FACT}' in the entry's facts" if inc_only else + "the Canadian provision (limb) decides the band") + for need in _tran_needs(eq): + entry_notes.append(f"#{i}: band not settled -- {need['question']}") + elif eq.get("assessed_basis", "").startswith("inferred-from-"): + # the bound that set it: the completion or (roadmap T4.1) the + # conviction date + note.append("maximum inferred from the " + f"{eq['assessed_basis'][len('inferred-from-'):]} date " + f"({eq['assessed_at']})") + elif eq.get("assessed_basis") == "undetermined": + note.append("the offence date decides the maximum: give offence_date") + for nf in eq.get("not_in_force_at_commission") or []: + note.append(f"{nf['provision']} not in force at commission " + f"(from {nf['from']})") + entry_notes.append( + f"#{i}: {nf['provision']} came into force on {nf['from']}, " + "after the offence date: whether it (or a provision then in " + "force) is the equivalent is not settled -- canlex_screen_record " + "prints both readings.") + if eq.get("before_pit_floor"): + note.append("maximum before the point-in-time floor not verified") + if eq.get("also_matched"): + note.append("also matched at the same score: " + + ", ".join(eq["also_matched"])) + entry_notes.append( + f"#{i}: '{offense}' also matched {', '.join(eq['also_matched'])} " + f"at the same score ({eq['pairing']} was preferred only for " + "its longer matching term) -- confirm which offence the " + "record is.") + for tn in s2["term_notes"]: + note.append(f"element split: {tn.get('id')}") + entry_notes.append(f"#{i}: {tn['note']}") + if eq is None: + note.append("no curated pairing") + held_ids = [] + for h in (s1.get("held"), (s2 or {}).get("held")): + if h: + note.append(f"HELD FOR REVIEW: {_held_id(h)} not in this deployment") + entry_notes.append(f"#{i}: {_held_note(h)}") + held_ids.append(_held_id(h)) + if i not in held_rows: + held_rows.append(i) + # disposition roadmap T1.5 / T1.10: only the caveats that bear on the + # entry's footing, each in its own words + for line in _caveat_bottom_lines(s1["entry"], s1.get("act")): + entry_notes.append(f"#{i}: {line.replace('in step 1', HISTORY_CAVEAT_WHERE)}") + if _act_not_settled(s1.get("act")): + note.append("act branch NOT SETTLED after an acquittal") + entry_notes.append(f"#{i}: {HISTORY_ACT_COUNTED}") + # disposition roadmap T1.6 / critic: how the entry's words were read + for line in _flag_lines(s1.get("flags"), places=s1.get("gate_places")): + entry_notes.append(f"#{i}: {line}") + if s1.get("question_flag"): + note.append("step 1 NOT SETTLED on the wording: " + + DISPOSITION_FLAG_QUESTIONS[s1["question_flag"]]) + if s1.get("row_mismatch"): + # disposition roadmap T1.11: the row selected by mechanism + note.append(_row_mismatch_line(s1["row_mismatch"], card=True)) + entry_notes.append(f"#{i}: {_row_mismatch_line(s1['row_mismatch'])}") + alias_entry_needs += [dict(n, entry=i) for n in + alias_needs(offense, j["state"], od, ent.get("facts"))] + for n in (offence_notes(offense, j["state"], od, ent.get("facts")) + + disposition_notes(ent["disposition"])): + if f"#{i}: {n}" not in entry_notes: + entry_notes.append(f"#{i}: {n}") + if j["kind"] in ("non-us", "canada"): + note.append("non-US record" if j["kind"] == "non-us" else "Canada given") + for n in j["notes"]: + if not n.startswith("NOTE -- "): # the label is printed once, above + entry_notes.append(f"#{i}: {n}") + group = ent.get("occurrence_group") + if absentia: + note.append("in absentia: no clock until served") + elif absentia_held: + note.append("in absentia: HELD FOR REVIEW, the no-clock rule is not in this " + "deployment -- the clock runs from completion (sentence assumed served)") + rows.append({"n": i, "offense": ent["offense"], "disposition": ent["disposition"], + "state": ent.get("state"), "country": ent.get("country") or country, + "entry": entry_id, "verdict": verdict, + "conviction": conv, "unknown": s1["unknown"], "eq": eq, + "display": display or (eq or {}).get("pairing"), + # a conviction in absentia: its completion date is not used + "completed": None if absentia else completed, + "completed_given": completed, "offence_date": od, + "group": None if group is None else str(group), + "youth": entry_id in _YOUTH_ENTRIES, + "s17_excepted": entry_id in _YOUTH_ENTRIES, + "absentia": absentia, "note": "; ".join(note), + "jurisdiction": j["kind"], "s37": s37f, "youth_screen": ys, + "youth_needs": yneeds, "canada": None, "conviction_date": cdate, + "held": held_ids, "disposition_flags": list(s1.get("flags") or []), + "act": s1.get("act") or {}, "row_tier": _row_tier_label(s1["row"]), + "question_flag": s1.get("question_flag"), + "gate_places": s1.get("gate_places"), + "_row_mismatch": s1.get("row_mismatch"), + # what the confidence block and the decisive facts read + "_entry_obj": s1["entry"], "_row_obj": s1["row"], + "_pairing_obj": (s2 or {}).get("pairing"), + "_cands": (s2 or {}).get("candidates") or []}) + return rows, entry_notes, alias_entry_needs, held_rows + + +# The IRPR s. 17 clock's open question on a later conviction (the verification fixes of +# 2026-09-22, F1: LEGAL-8, BI-4, HELD): whether it is 'a subsequent offence' when it may be +# no offence under an Act of Parliament -- a foreign conviction whose pairing says it has no +# federal equivalent ('none'), one with no curated pairing ('unpaired'), or a conviction in +# Canada the entry does not show to be under an Act of Parliament ('canada': no maximum, +# no hybrid or indictable mode and no curated federal pairing -- a provincial or municipal +# offence is not one). +_S17_QUESTION_WORDS = {"none": "no Canadian federal equivalent", + "unpaired": "no curated pairing", + "canada": "a conviction in Canada not shown to be under an Act of " + "Parliament"} + + +def _s17_open_question(r): + """'none' | 'unpaired' | 'canada' | None: why a conviction may not be 'a + subsequent offence' for IRPR s. 17 (_S17_QUESTION_WORDS).""" + if r["canada"]: + info = r["canada"]["info"] + # a maximum, a curated federal pairing, or a hybrid or indictable mode (only an + # offence under an Act of Parliament is prosecuted by indictment) shows it + federal = (info["max_years"] is not None or info["mode"] in ("hybrid", "indictable") + or (info["pairing"] is not None and not info["no_federal"])) + return None if federal else "canada" + if r["eq"] is None: + return "unpaired" + return "none" if r["eq"].get("branch") == "none" else None + + +def _s17_moved_clause(r): + """How the s. 17 line ends when a later conviction restarts the five years + (LEGAL-9, 2026-09-22): 'even though it grounds no inadmissibility itself' + only for a conviction whose pairing says it has no federal equivalent; for + one whose equivalent is not settled, 'whatever it grounds'. Before, every + later conviction was said to ground nothing, beside a line saying its + equivalent was not settled.""" + branch = (r["eq"] or {}).get("branch") + if branch == "none": + return (", so this later conviction restarts the five years even though it grounds " + "no inadmissibility itself.") + if r["canada"] or branch in ("serious-criminality", "criminality", "criminality-two-plus-only"): + return ", so this later conviction restarts the five years." + return (", so this later conviction restarts the five years whatever it grounds (its " + "equivalent is not settled).") + + +# The G3 fixes of 2026-09-22 (PREROG-2, HELD): a conviction in Canada whose words name a +# CONDITIONAL prerogative pardon is counted on the s. 17 clock as the conviction it is. +S17_CONDITIONAL_PARDON = ( + " Its words name a conditional pardon, counted as a conviction: Criminal Code s. 748(2) " + "grants one 'to any person who has been convicted of an offence', and s. 748(3) speaks " + "of a free pardon only.") + + +def _s17_conditional(r): + """Whether a row is a conviction in Canada under a conditional prerogative + pardon (rehab.prerogative_kind), counted plainly on the s. 17 clock.""" + from canlex.rehab import prerogative_kind + return bool(r["canada"] and r["canada"]["cd"].get("prerogative_pardon") + and prerogative_kind(r["disposition"]) == "conditional") + + +def _s17_missing(needs): + """What the card's NOT SETTLED asks when the open fact is an s. 17 date: 'a + sentence-completion date' as before, or the offence date an undated act + needs (S17-2, G3 2026-09-22).""" + facts = {n["fact"] for n in needs or ()} + acts = [n["entry"] for n in needs or () if n["fact"] == "offence_date"] + if not acts: + return "a sentence-completion date" + what = "the offence date of entr" + ("y " if len(acts) == 1 else "ies ") + ", ".join( + f"#{n}" for n in acts) + return (what + " and a sentence-completion date" + if facts & {"sentence_completed", "sentence_served"} else what) + + +def _canada_open_question(rows, e): + """What the record card asks when a conviction in Canada is the only possible + ground and its branch is open (the G3 fixes of 2026-09-22, with AI-9): the + prerogative question, whether the words are a conviction, or the entry's + first open fact.""" + cd = next(r["canada"]["cd"] for r in rows if r["n"] == e["entry"]) + if cd.get("prerogative_pardon"): + return f"#{e['entry']}: {CANADA_PREROGATIVE_QUESTION}" + if cd["conviction"] is None: + return f"#{e['entry']}: {CANADA_UNSETTLED_QUESTION}" + q = next((n.get("question") for n in e["needs"] if n.get("question")), None) + return f"#{e['entry']}: {q or 'its branch turns on the readings in the full memo'}" + + +def _s17_open_why(r): + """Why an entry's conviction status is open, for the s. 17 lines (PREROG-3; + the H3 fixes of 2026-09-22 add the entries step 1 places without settling).""" + if r["canada"] and r["canada"]["cd"].get("prerogative_pardon"): + return ("its words name a pardon under the royal prerogative of mercy or Criminal " + "Code s. 748, not shown to be conditional") + if r["youth"]: + return ("IRPR s. 17 excepts a Young Offenders Act offence, not " + + ("a youth sentence under the Youth Criminal Justice Act" + if r["canada"] else "a juvenile or youthful-offender disposition")) + if r["canada"]: + return "its words do not settle a conviction in Canada" + if r.get("verdict") == "likely-no": + return "step 1 places it as no conviction, but does not settle it" + return "step 1 does not settle the verdict" + + +def _s17_open_lead(r): + """How the s. 17 lines name an open entry (the H3 fixes of 2026-09-22). A + youth disposition's CONVICTION is settled; what is open is whether IRPR + s. 17's exception -- 'an offence under the Young Offenders Act' -- reaches + it, so the line says that rather than 'conviction status is not settled'.""" + if r["youth"]: + return (f"entry #{r['n']} is " + + ("a conviction in Canada whose sentence is a youth sentence under the " + "Youth Criminal Justice Act" if r["canada"] else + "a juvenile or youthful-offender disposition") + + ", which IRPR s. 17's exception does not name") + return f"entry #{r['n']}'s conviction status is not settled ({_s17_open_why(r)})" + + +def _s17_reading_text(r, rd, is_open): + """The NOT SETTLED sentence for one other s. 17 reading of the clock entry r + (rehab._s17's readings; no final period). The G3 fixes of 2026-09-22: a + reading leaves out only the clock entry -- or, for a pairing that says it has + no federal equivalent, every such conviction, the same legal question (AI-5: + an unpaired felony was called 'the same open question' as unpaired + jaywalking) -- and an entry whose conviction status is open gets its own + readings (PREROG-2/3).""" + n, when, on = r["n"], rd["from"], rd["apply_on"] + tail = f"the clock runs from {when} and the earliest application is {on}" + if is_open and rd["key"] == "a-committed-act": + return (f"NOT SETTLED: {_s17_open_lead(r)}. " + "If it is a committed act rather than a conviction, s. 17(b) runs the five " + "years 'after committing an offence', and on that reading " + tail) + if is_open and rd["key"] == "not-a-subsequent-offence" and r["youth"]: + # H3 (S17-INT-7): the youth exception is the READING, not the headline + return (f"NOT SETTLED: entry #{n} is " + + ("a conviction in Canada whose sentence is a youth sentence under the " + "Youth Criminal Justice Act" if r["canada"] else + "a juvenile or youthful-offender disposition") + + ". IRPR s. 17 prescribes the five years only if the person 'has not been " + "convicted of a subsequent offence other than an offence designated as a " + "contravention under the Contraventions Act or an offence under the Young " + "Offenders Act'. Read as the analogue of that exception it is not a " + "subsequent offence, and on that reading " + tail + + " -- confirm that reading") + if is_open and r["canada"] and r["canada"]["cd"].get("prerogative_pardon"): + return (f"NOT SETTLED: entry #{n}'s words name a pardon under the royal prerogative of " + "mercy or Criminal Code s. 748 not shown to be conditional. If that pardon " + "means the person has not been 'convicted of a subsequent offence' for s. 17 " + "(CanLex does not settle this), " + tail) + if is_open: + return (f"NOT SETTLED: {_s17_open_lead(r)}. " + "If it is not a conviction that counts as 'a subsequent offence' for s. 17, " + + tail) + question = _s17_open_question(r) + if question == "unpaired": + text = (f"NOT SETTLED: entry #{n}'s Canadian equivalent is " + "not settled (no curated pairing). If it has no Canadian " + "federal equivalent and such a conviction is not a subsequent " + "offence for s. 17 (CanLex does not settle this), " + tail) + elif question == "none": + text = (f"NOT SETTLED: entry #{n} has no Canadian federal " + "equivalent. If such a conviction is not 'a subsequent offence' " + "for s. 17 (CanLex does not settle this), " + tail) + else: + text = (f"NOT SETTLED: entry #{n} (a conviction in Canada) is not " + "shown to be for an offence under an Act of Parliament. If it is not " + "(a provincial or municipal offence) and such a conviction is not a " + "subsequent offence for s. 17 (CanLex does not settle this), " + tail) + also = rd.get("also") or [] + if also: + text += (" -- on that reading entr" + ("y " if len(also) == 1 else "ies ") + + ", ".join(f"#{m} ({_S17_QUESTION_WORDS['none']})" for m in also) + + (" is" if len(also) == 1 else " are") + + " not a subsequent offence either, the same open question") + return text + + +def _history_answer(rows, person, today_d, edata, application=None, entry_notes=()): + """The record step of canlex_screen_history (moved here unchanged): the + count for IRPR s. 18(2), IRPR s. 18(2) and s. 18.1 limb by limb + (rehab.assess_record), the s. 17 clock, the Canadian entries and the + record-level notes. Returns the fields the tool renders and the + decisive-facts re-runs compare.""" + from canlex.rehab import assess_canadian + from canlex.screening import add_years, occurrence_range, parse_date + # record-level analysis: the foreign entries (s. 36(1)(b)/(c), (2)(b)/(c)) + foreign_rows = [r for r in rows if not r["canada"]] + canada_rows = [r for r in rows if r["canada"]] + engaged = [r for r in foreign_rows if (r["eq"] or {}).get("branch") in + ("serious-criminality", "criminality", "criminality-two-plus-only")] + convictions = [r for r in engaged if r["conviction"]] + acts = [r for r in engaged if not r["conviction"]] + serious = [r for r in engaged if r["eq"]["branch"] == "serious-criminality"] + summary_only = [r for r in engaged if r["eq"]["branch"] == "criminality-two-plus-only"] + indictable = [r for r in engaged if r["eq"]["branch"] in ("serious-criminality", "criminality")] + unresolved = [r for r in foreign_rows if r["eq"] is None + or r["eq"].get("branch") in ("unknown", "depends", "tie", "tran-open", + "limb-open")] + # the Canadian entries (roadmap T2.2): s. 36(2)(a)'s two-offence limb and + # IRPR s. 18.1 count Canadian convictions only; a youth sentence and a + # contravention are outside s. 36 (s. 36(3)(e)) + c_limb = [r for r in canada_rows if r["conviction"] and not r["s17_excepted"]] + c_conv = [r for r in c_limb if not r["canada"]["cd"]["record_suspension"]] + c_summary = [r for r in c_conv if r["canada"]["info"]["mode"] == "summary-only"] + c_count = "one" + if len(c_summary) >= 2: + least, most, _ = occurrence_range( + [(f"#{r['n']}", r["group"], r["offence_date"]) for r in c_summary]) + c_count = "two-plus-summary-only" if least >= 2 else ("one" if most <= 1 else None) + c_clock = max((r["completed"] for r in c_summary if r["completed"]), default=None) + + def c_assess(r, clean, solely=None): + c = r["canada"] + cd, info, summary = c["cd"], c["info"], r in c_summary + + def c_run(cnt, clk): + return assess_canadian( + status=person, count=cnt, max_years=info["max_years"], mode=info["mode"], + sentence=cd["sentence"], custody_months=c["custody"], + record_suspension=cd["record_suspension"], + conviction=cd["conviction"] is not False, completed=clk, today=today_d, + clean_record=clean if summary else not engaged, + summary_only=info["summary_only"], solely=solely if summary else None) + two = summary and c_count == "two-plus-summary-only" + c["res"] = c_run("two-plus-summary-only" if two else "one", + c_clock if two else r["completed"]) + c["alt"] = c_run("two-plus-summary-only", c_clock) if summary and c_count is None else None + for r in canada_rows: + c_assess(r, not engaged and len(c_conv) == len(c_summary)) + # The whole record, IRPR s. 18(2) and s. 18.1 limb by limb (roadmap T2.3). + # s. 18.1 covers a foreign national inadmissible 'solely' on the Canadian + # summary-only convictions: the limb reads the record's other entries, and + # the Canadian answers follow it where it is settled. + rec = _record_assessment(_record_entries(rows, edata), today_d, person) + solely = next((l["status"] for l in (rec["s18_1"] or {}).get("limbs", []) + if l["limb"] == "(1) solely"), None) + if solely in ("pass", "fail"): + for r in c_summary: + c_assess(r, solely == "pass", solely=False if solely == "fail" else None) + canada_entries = [] + for r in canada_rows: + c = r["canada"] + res = c["res"] + canada_entries.append({ + "entry": r["n"], "canada_branch": res["canada_branch"], + "canada_limbs": res["canada_limbs"], "branch": res["branch"], + "rehab_status": res["status"], "earliest_deemed": res.get("earliest_deemed"), + "removal": res["removal"], "removal_provision": res["removal_provision"], + "iad_appeal": res["iad_appeal"], "readings": res["readings"], + "needs": list(res["needs"]) + list(r["youth_needs"]), + "count_readings": ({"one-occurrence": res["canada_branch"], + "separate-occurrences": c["alt"]["canada_branch"]} + if c["alt"] else None)}) + notes = [] + if unresolved: + notes.append(f"{len(unresolved)} entr{'y' if len(unresolved) == 1 else 'ies'} " + "unresolved (no pairing, a tie, or a value/schedule/elements " + "question): " + ", ".join(f"#{r['n']}" for r in unresolved) + + ". The record-level count below leaves them out; the IRPR " + "s. 18(2) limbs read every way they could go, and a limb one of " + "them could change is UNKNOWN. Resolve them and re-run.") + for r in unresolved: + q = (r["eq"] or {}).get("elements_question") + if q: + notes.append(f"#{r['n']} ({r['eq']['pairing']}) -- elements question: {q}") + notes += entry_notes + c_serious = [r for r in canada_rows if r["canada"]["res"]["canada_branch"] == "36(1)(a)"] + c_open = [r for r in canada_rows if r["canada"]["res"]["canada_branch"] == "not-settled"] + if person == "permanent-resident": + if serious or c_serious: + notes.append("Permanent resident: only the serious-criminality entries " + "count (s. 36(1)); the rest ground nothing.") + elif c_open: + notes.append("Permanent resident with no settled serious-criminality " + "entry: s. 36(2) reaches foreign nationals only, but " + "Canadian entr" + ("y " if len(c_open) == 1 else "ies ") + + ", ".join(f"#{r['n']}" for r in c_open) + " could be " + "s. 36(1)(a) (NOT SETTLED, below).") + else: + notes.append("Permanent resident with no serious-criminality entry: " + "nothing on this record grounds s. 36 inadmissibility " + "(s. 36(2) reaches foreign nationals only).") + # The two-offence limb counts two offences only if 'not arising out of a + # single occurrence' (s. 36(2)(b); critic, 2026-09-12): equal or missing + # offence dates leave that open, and then both readings are given. + summary_convictions = [r for r in summary_only if r["conviction"]] + one_occurrence, occurrence_unsettled, open_pairs = False, False, [] + if len(summary_only) >= 2 and not indictable: + if len(summary_convictions) >= 2: + least, most, open_pairs = occurrence_range( + [(f"#{r['n']}", r["group"], r["offence_date"]) for r in summary_convictions]) + if least >= 2: + notes.append(f"Two or more summary-only equivalents ({len(summary_only)}), " + "arising out of separate occurrences (different offence " + "dates or occurrence_group): s. 36(2)(b)'s second limb " + "is engaged for a foreign national (Libby: a process " + "offence tied to its charge is the same occurrence -- " + "check that none is).") + elif most <= 1: + one_occurrence = True + notes.append("The summary-only convictions arose out of ONE " + "occurrence (the same occurrence_group): s. 36(2)(b)'s " + "second limb needs two offences 'not arising out of a " + "single occurrence', so together they ground nothing.") + else: + occurrence_unsettled = True + notes.append("COUNT NOT SETTLED: s. 36(2)(b)'s second limb counts two " + "summary-only offences only if 'not arising out of a " + "single occurrence', and " + + ", ".join(f"{a} and {b}" for a, b in open_pairs) + + " have the same offence date or no offence date. Both " + "readings follow; give occurrence_group per entry (the " + "same value = one occurrence) to settle it.") + else: + notes.append(f"Two or more summary-only equivalents ({len(summary_only)}): " + "s. 36(2)(b)'s second limb is engaged for a foreign " + "national if they did not arise out of a single " + "occurrence (Libby: a process offence tied to its charge " + "is the same occurrence).") + elif len(summary_only) == 1 and not indictable: + notes.append("One summary-only equivalent: grounds nothing on its own.") + if len(indictable) > 1: + count = "multiple" + elif len(indictable) == 1: + count = "one" + elif len(summary_only) >= 2 and not (one_occurrence or occurrence_unsettled): + count = "two-plus-summary-only" + else: + count = None + lt10 = not serious + # The s. 17 clock (roadmap T1.9; the G3 fixes of 2026-09-22, HELD): rehab._s17 runs it + # over the whole record -- the completion of every conviction of ANY kind (s. 17 + # excepts only contraventions and Young Offenders Act offences), the offence date of + # every committed act s. 17(b) runs from, on either basis (S17-1), and every entry + # whose conviction status is open read as a conviction (PREROG-2/3). The headline is + # the strictest date any reading allows; the clock entry's other readings are + # rec['s17']['readings'] (one per open entry -- AI-5). The deeming clocks stay on the + # entries that ground inadmissibility. + s17 = rec["s17"] + by_n = {r["n"]: r for r in rows} + s17_from = parse_date(s17["from"]) if s17["from"] else None + s17_act_from = parse_date(s17["act_from"]) if s17["act_from"] else None + latest = max((r["completed"] for r in convictions if r["completed"]), default=None) + latest_act = max((r["offence_date"] for r in acts if r["offence_date"]), default=None) + rehab, count_readings, s17_row, s17_readings = None, None, None, None + s17_act_row = None # the committed act the clock runs from (S17-1) + s17_notes = [] # the NOT SETTLED s. 17 sentences, printed on the card too (PREROG-3) + # A conviction with no completion date (verifier, 2026-09-12 -- the T1.9 + # defect class): the s. 17 clock runs from the completion of the sentence + # for the most recent conviction, so it cannot be fixed from the dated + # entries alone; nor can the deeming clock when that conviction is itself + # engaged. The answer gives a lower bound and a reading conditioned on + # the missing date, never a date. The G3 fixes (2026-09-22) add a conviction + # under a conditional prerogative pardon (PREROG-2), an entry whose conviction + # status is open with no completion date (PREROG-3), and a committed act with no + # offence date (S17-2/AI-8: it was dropped from the clock, leaving a settled date). + undated = [r for r in rows if r["conviction"] and not r["s17_excepted"] + and not r["completed"]] + undated += [by_n[n] for n in s17["undated"] if not any(r is by_n[n] for r in undated)] + undated_open = [by_n[n] for n in s17["undated_open"]] + undated_acts = [by_n[n] for n in s17["undated_acts"]] + needs = ([({"fact": "sentence_served", "entry": r["n"], "parameter": None} + if r["absentia"] else + {"fact": "sentence_completed", "entry": r["n"], + "parameter": "sentence_completed"}) for r in undated] + + [{"fact": "sentence_completed", "entry": r["n"], "parameter": "sentence_completed"} + for r in undated_open] + + [{"fact": "offence_date", "entry": r["n"], "parameter": "offence_date"} + for r in undated_acts]) + not_before = None + open_named = [] + + def _s17_clock(): + """(apply_from, s17_row, s17_act_row): rehab._s17's clock entry -- a + completion (a conviction, or an entry read as one), or an act's offence + date (S17-1).""" + row = by_n.get(s17["entry"]) if s17_from else None + if row is None: + return None, None, None + if s17["from_kind"] == "offence": + return None, None, row + return s17_from, row, None + + def _settle(r_assess, clock, apply_from, act=False): + """(answer, note): r_assess and None when every date the clock needs is + given; otherwise an open answer and the note. act: the s. 17(b) clock, + which runs from the commission of the act unless a later conviction + restarts it (LEGAL-7). The start is rehab._s17's (S17-3/AI-2: it was the + deeming clock, which on the act basis can be a conviction before the act). + When the earliest application cannot be before a date after today on any + reading, the answer is NOT YET with that date (AI-6), not NOT SETTLED.""" + nonlocal not_before, open_named + if not (undated or undated_open or undated_acts): + return r_assess, None + start = max(d for d in (clock, apply_from, s17_from) if d) + convlike = undated + undated_open + eng_conv = [r for r in convlike if r in engaged] + eng_act = [r for r in undated_acts if r in engaged] + engaged_undated = eng_conv + eng_act + cond_date = clock if engaged_undated else start + od_bounds = [r["offence_date"] for r in convlike if r["offence_date"]] + # (INFO-1: a sentence cannot be completed before the conviction either) + cd_bounds = [r["conviction_date"] for r in convlike if r.get("conviction_date")] + floor = max([start] + od_bounds + cd_bounds) + not_before = add_years(floor, 5).isoformat() + open_named = [r["n"] for r in undated_open] + ids = ", ".join(f"#{r['n']}" for r in undated) + one_c = len(undated) == 1 + one = len(convlike) + len(undated_acts) == 1 + plain = [r for r in undated if not r["absentia"]] + absent = [r for r in undated if r["absentia"]] + pids = ", ".join(f"#{r['n']}" for r in plain) + aids = ", ".join(f"#{r['n']}" for r in absent) + oids = ", ".join(f"#{r['n']}" for r in undated_open) + tids = ", ".join(f"#{r['n']}" for r in undated_acts) + parts = [] + if undated: + parts.append( + f"entr{'y' if one_c else 'ies'} {ids} {'is a conviction' if one_c else 'are convictions'} " + "with no sentence-completion date" if not absent else + (f"entr{'y' if len(plain) == 1 else 'ies'} {pids} " + f"{'is a conviction' if len(plain) == 1 else 'are convictions'} with no " + "sentence-completion date; " if plain else "") + + f"entr{'y' if len(absent) == 1 else 'ies'} {aids} " + f"{'is a conviction' if len(absent) == 1 else 'are convictions'} in " + "absentia whose sentence is not shown to have been served (ENF 14 " + "s. 16: 'Conviction and sentence in absentia: not eligible for " + "rehabilitation because the sentence has not been completed')") + if undated_open: + parts.append(f"the conviction status of entr{'y' if len(undated_open) == 1 else 'ies'} " + f"{oids} is not settled and {'it has' if len(undated_open) == 1 else 'they have'} " + "no sentence-completion date (read as a conviction, the strictest way)") + if undated_acts: + parts.append(f"entr{'y' if len(undated_acts) == 1 else 'ies'} {tids} " + f"{'is a committed act' if len(undated_acts) == 1 else 'are committed acts'}" + " with no offence date") + head = "; ".join(parts) + if convlike: + text = (head + ". IRPR s. 17 prescribes the five " + "years only if the person 'has not been convicted of a subsequent " + "offence', and they run from " + + ("the commission of the act or, after a later conviction, from the " + "completion of its sentence" if act else + "the completion of the sentence for the most recent conviction, or " + "from the commission of a later act (s. 17(b))" if s17_act_from else + "the completion of the sentence for the most recent conviction") + + ("; s. 17(b) runs them 'after committing an offence' for a committed " + "act" if undated_acts else "")) + else: + text = (head + ". IRPR s. 17(b) prescribes the five years 'after committing an " + "offence' for the matters in IRPA s. 36(1)(c) and (2)(c)") + text += (", so the application date cannot be fixed " + f"without {'it' if one else 'them'}" + + (" -- nor the deeming date, since the undated entry is itself engaged" + if eng_conv and all(any(r is o for o in undated_open) for r in eng_conv) else + " -- nor the deeming date, since the undated conviction is itself " + "engaged" if eng_conv else + " -- nor the deeming date, since the undated act is itself engaged" + if eng_act else "") + + f". The earliest application is not before {not_before} (five years " + f"after {floor.isoformat()}" + + ((", the latest offence date among them -- a sentence cannot be " + "completed before its offence" if floor in od_bounds else + ", the latest conviction date among them -- a sentence cannot be " + "completed before the conviction") if floor > start else "") + ").") + not_yet = (add_years(floor, 5) > today_d + and r_assess["status"] in ("not-yet", "eligible-to-apply")) + if (not not_yet and not undated_open and not undated_acts + and r_assess["status"] not in ("undetermined", "not-settled") and all( + r["offence_date"] is None or r["offence_date"] <= cond_date for r in undated)): + text += (f" Only if every such sentence was completed on or before " + f"{cond_date.isoformat()} would the answer be " + f"{_rehab_brief(r_assess)} -- confirm the date" + f"{'' if one else 's'} before relying on it.") + if plain: + text += f" Give sentence_completed for {pids if absent else ids}." + if absent: + text += f" Confirm whether, and when, the sentence for {aids} was served." + if undated_open: + text += f" Give sentence_completed for {oids} if it is a conviction." + if undated_acts: + text += f" Give offence_date for {tids}." + if not engaged_undated and r_assess["status"] == "deemed-rehabilitated": + text += (" Deemed rehabilitation runs from " + + ("the commission of the act" if act else + "the engaged conviction's completion") + + ", which is given, so it stands; only the s. 17 " + "application date is open, and it is moot while deemed " + "rehabilitation holds.") + return dict(r_assess, earliest_apply=None), text + if not_yet: + # AI-6: every date the open facts allow is after today + return {"status": "not-yet", "provision": r_assess["provision"], + "earliest_deemed": (None if engaged_undated + else r_assess.get("earliest_deemed")), + "earliest_apply": None, "earliest_apply_not_before": not_before, + "explanation": text}, text + return {"status": "undetermined", "provision": r_assess["provision"], + "earliest_deemed": (None if engaged_undated + else r_assess.get("earliest_deemed")), + "earliest_apply": None, "explanation": text}, text + + if occurrence_unsettled: + clock = max((r["completed"] for r in summary_convictions if r["completed"]), default=None) + if clock: + apply_from, s17_row, s17_act_row = _s17_clock() + # reading 1 -- separate occurrences: IRPR s. 18(2)(b), limb by limb + r_sep, open_note = _settle(rec, clock, apply_from) + if open_note: + s17_row = s17_act_row = None + count_readings = { + "separate-occurrences": {"count": "two-plus-summary-only", + "rehab_status": r_sep["status"], + "rehab_provision": r_sep["provision"], + "earliest_deemed": r_sep.get("earliest_deemed"), + "earliest_apply": r_sep.get("earliest_apply"), + **({"earliest_apply_not_before": + r_sep["earliest_apply_not_before"]} + if r_sep.get("earliest_apply_not_before") else {})}, + "single-occurrence": {"count": None, "rehab_status": None, + "rehab_provision": None}} + # (AI-6: a NOT YET whose floor is after today is said as NOT YET) + notes.append("Reading 1 -- separate occurrences: count two-plus-summary-only, " + + (f"{_rehab_brief(r_sep)}. {open_note}" + if open_note and r_sep["status"] == "not-yet" else + f"NOT SETTLED -- {open_note}" if open_note + else f"{_rehab_brief(r_sep)}.")) + else: + count_readings = {"separate-occurrences": {"count": "two-plus-summary-only"}, + "single-occurrence": {"count": None}} + notes.append("Reading 1 -- separate occurrences: count two-plus-summary-only; " + "give the sentence-completion dates to compute the clocks.") + notes.append("Reading 2 -- one occurrence: a single summary-only equivalent, " + "which grounds nothing on its own; no s. 36 ground on these " + "entries.") + elif count and person == "foreign-national" or (count and serious): + # Deemed rehabilitation limb by limb over the whole record (roadmap + # T2.3; rehab.assess_record): the deeming clock is the paragraph's own + # -- the single conviction's completion, the last summary sentence, or + # the act -- and every other entry is read against (iii)-(vii). + basis = s17["basis"] + clock = (parse_date(rec["deem_clock"]) if rec["deem_clock"] else + latest if basis == "foreign-conviction" else latest_act) + if clock: + apply_from, s17_row, s17_act_row = _s17_clock() + rehab, open_note = _settle(rec, clock, apply_from, + act=basis != "foreign-conviction") + if open_note and rehab["status"] == "undetermined": + s17_row = s17_act_row = None + notes.append(f"Record-level rehabilitation (count {count}): NOT " + f"SETTLED -- {open_note}") + else: + expl = rehab["explanation"] if rehab["explanation"] != open_note else "" + notes.append(f"Record-level rehabilitation (count {count}" + f"{_deem_clock_text(rec)}): {_rehab_brief(rehab)}. " + + " ".join(x for x in (expl, open_note) if x)) + if open_note: + s17_row = s17_act_row = None + elif any(r["absentia"] for r in convictions): + notes.append(f"Record-level count is {count}; " + ABSENTIA_CLOCK) + else: + notes.append(f"Record-level count is {count}; give the sentence-completion " + "(or offence) dates to compute the clocks.") + if s17_act_row is not None and s17["basis"] == "foreign-conviction": + # S17-1: a committed act later than every sentence on the record sets the clock + notes.append(f"IRPR s. 17 application clock: from entry #{s17_act_row['n']}'s offence " + f"date ({s17_act_row['offence_date'].isoformat()}) -- s. 17(b) " + "prescribes the five years 'after committing an offence' for the matters " + "in IRPA s. 36(1)(c) and (2)(c), and no sentence on the record was " + "completed after it.") + if s17_row is not None: + is_open = s17_row["n"] in s17["open"] + if s17["basis"] == "foreign-conviction": + moved = (latest is not None and s17_row["completed"] > latest + and (s17_act_from is None or s17_row["completed"] > s17_act_from)) + else: + moved = bool(s17["moved"]) + if is_open: + notes.append(f"IRPR s. 17 application clock: from entry #{s17_row['n']}'s sentence " + f"completion ({s17_row['completed'].isoformat()}), read the strictest " + f"way -- {_s17_open_lead(s17_row)}, and s. 17 prescribes the five years only " + "if the person 'has not been convicted of a subsequent offence', " + "excepting only contraventions and Young Offenders Act offences; if it " + "is a conviction, it restarts them.") + else: + notes.append(f"IRPR s. 17 application clock: from entry #{s17_row['n']}'s sentence " + f"completion ({s17_row['completed'].isoformat()}), the most recent " + "conviction on the record -- s. 17 prescribes the five years only " + "if the person 'has not been convicted of a subsequent offence', " + "excepting only contraventions and Young Offenders Act offences" + + (_s17_moved_clause(s17_row) if moved else ".") + + (S17_CONDITIONAL_PARDON if _s17_conditional(s17_row) else "")) + # the audit follow-up of 2026-09-21 (engine-s17), the verification fixes of + # 2026-09-22 (F1: LEGAL-8, BI-4) and the G3 fixes (AI-5, PREROG-2/3): the clock + # entry's other readings, each leaving out only that entry (every conviction + # whose pairing says it has no federal equivalent together -- the same legal + # question), are given as NOT SETTLED sentences; the headline keeps the strict date + for rd in s17["readings"]: + text = _s17_reading_text(s17_row, rd, is_open) + notes.append(text + ".") + s17_notes.append(text) + if s17["readings"]: + s17_readings = {"subsequent-offence": { + "entry": s17_row["n"], "from": s17_row["completed"].isoformat(), + "apply_on": add_years(s17_row["completed"], 5).isoformat()}} + for rd in s17["readings"]: + s17_readings[rd["key"]] = {"from": rd["from"], "apply_on": rd["apply_on"]} + # (PREROG-3: the youth note prints on the act basis too, not only after a + # later conviction moved the clock) + s17_start = (s17_row["completed"] if s17_row is not None else + s17_act_row["offence_date"] if s17_act_row is not None else None) + if s17_start is not None: + for r in rows: + if r["youth"] and r["conviction"] and r["completed"] and r["completed"] > s17_start: + notes.append(f"Entry #{r['n']} (a juvenile or youthful-offender " + "disposition) is not counted as a subsequent offence -- " + "read as the analogue of s. 17's Young Offenders Act " + "exception; confirm that reading.") + if rehab or count_readings: + for r in rows: + if r["unknown"] and not r["completed"] and r["n"] not in open_named: + notes.append(f"Entry #{r['n']}'s conviction status is not settled and " + "it has no completion date: if it is a conviction, it may " + "move the s. 17 clock -- give its sentence_completed.") + notes += _canada_history_notes(canada_rows, person) + notes += _youth_history_notes(rows) + notes += _possession_limit_notes(rows) + c_reportable = any(e["removal"] or any(rd["removal"] for rd in e["readings"]) + for e in canada_entries) + if engaged or unresolved or c_reportable: + notes.append(_family_line(person, application)) + return {"foreign_rows": foreign_rows, "canada_rows": canada_rows, "engaged": engaged, + "serious": serious, "summary_only": summary_only, "unresolved": unresolved, + "canada_entries": canada_entries, "rec": rec, "notes": notes, "count": count, + "count_readings": count_readings, "rehab": rehab, "s17_row": s17_row, + "s17_act_row": s17_act_row, "s17_notes": s17_notes, + "s17_readings": s17_readings, "not_before": not_before, "needs": needs, + "occurrence_unsettled": occurrence_unsettled, "c_reportable": c_reportable, + "convictions": convictions, "acts": acts} + + +CARD_POSSESSION_LIMIT_BRANCH = "NOT SETTLED (was the cannabis within the Canadian limits?)" +CARD_POSSESSION_LIMIT_NOTE = ( + "Entr{s} {entries}: the possession limit is the whole question -- {question}") + + +def _possession_limit_notes(rows): + """The possession-limit question on the canlex_screen / canlex_screen_history + card (verification round H1, GQ-6; screening.possession_limit_open): the card + showed only the entry's branch -- 'NO CANADIAN FEDERAL EQUIVALENT', or + 'NOT SETTLED (value, schedule or elements)' -- and an officer reading it + never saw that the Canadian line for resin is 1 g. The whole question is + printed as a NOTE, once, naming the entries it applies to.""" + from canlex.screening import POSSESSION_LIMIT_QUESTION + ns = [r["n"] for r in rows + if not r["canada"] and (r["eq"] or {}).get("possession_limit_open")] + if not ns: + return [] + return [CARD_POSSESSION_LIMIT_NOTE.format( + s="y" if len(ns) == 1 else "ies", entries=", ".join(f"#{n}" for n in ns), + question=POSSESSION_LIMIT_QUESTION)] + + +def _history_signature(rows, ans): + """(each entry's branch, the count, the record-level rehabilitation + answer, whether the single-occurrence question is open): what a decisive + fact must change on a whole record (roadmap T4.1).""" + ents = tuple((r["n"], r["canada"]["res"]["canada_branch"] if r["canada"] + else (r["eq"] or {}).get("branch", "unknown")) for r in rows) + statuses = [(ans["rehab"] or {}).get("status")] + for k, v in sorted((ans["count_readings"] or {}).items()): + statuses.append(f"{k}: {v.get('rehab_status')}") + return (ents, ans["count"], tuple(statuses), bool(ans["occurrence_unsettled"])) + + +def _history_sig_dict(sig): + ents, count, statuses, occ = sig + return {"entries": [{"entry": n, "branch": b} for n, b in ents], "count": count, + "rehab_status": list(statuses), "occurrence_unsettled": occ} + + +def _history_decisive_answer(v): + """One value's answer on a whole record, as the block prints it.""" + ents = ", ".join(f"#{e['entry']} " + (f"s. {e['branch']}" if str(e["branch"]).startswith("36(") + else _DECISIVE_BRANCH.get(e["branch"], + str(e["branch"]).upper())) + for e in v["entries"]) + rs = " / ".join(str(s).upper().replace("-", " ") for s in v["rehab_status"] if s) + return (f"{ents}; count {v['count'] or 'none'}; record-level rehabilitation " + f"{rs or 'not computed'}" + + ("; the single-occurrence question is open" if v["occurrence_unsettled"] else "")) + + +def _parsed_with(parsed, n, dob=_AUTO, age=_AUTO, **ent): + """The parsed entries with entry n changed (entry keys, the age).""" + out = [] + for i, e, od, completed, dob_e, age_e, cin, cdate, pros in parsed: + if n is None or i == n: + e = dict(e, **ent) + dob_e = dob_e if dob is _AUTO else dob + age_e = age_e if age is _AUTO else age + out.append((i, e, od, completed, dob_e, age_e, cin, cdate, pros)) + return out + + +def _history_decisive(parsed, person, country, edata, today_d, application, rows, ans, + assumed, alias_entry_needs=()): + """(decisive_missing, checked) for canlex_screen_history: each entry's + open questions (a tie fact, the offence date of a straddled maximum, an + element or specification answer), the status, each conviction's relief + and the age, re-run over the whole record; kept only where the values + change an entry's branch, the count or the record-level rehabilitation + answer (_history_signature).""" + sig0 = _history_signature(rows, ans) + + def run(parsed_v=None, person_v=None, tran_od=None): + pv = person_v or person + rws = _history_rows(parsed_v or parsed, pv, country, edata, tran_od)[0] + return _history_signature(rws, _history_answer(rws, pv, today_d, edata, application)) + ents = {i: e for i, e, *_ in parsed} + trials = [] + with _step_memo(): + for r in rows: + if r["canada"]: + continue + i = r["n"] + eq = r["eq"] or {} + cands_all = list(eq.get("tran_candidates") or []) + for _, f in r["_cands"]: + cands_all += list(f.get("tran_candidates") or []) + needs = list(eq.get("needs") or []) + [n for n in alias_entry_needs + if n.get("entry") == i] + seen = set() + for need in needs: + fact, par = need.get("fact"), need.get("parameter") + if (fact, par) in seen or par not in ("value_over_5000", "schedule", "facts", + "offence_date"): + continue + seen.add((fact, par)) + vals = [] + for o in need.get("options") or []: + v, label = o.get("value"), o.get("label") or str(o.get("value")) + if par == "value_over_5000" and v is not None: + vals.append((v, label, run(_parsed_with(parsed, i, + value_over_5000=bool(v))))) + elif par == "schedule" and v: + vals.append((v, label, run(_parsed_with(parsed, i, schedule=v)))) + elif par == "facts" and v is not None: + ans_ = f"{fact}: {v}" + was = ents[i].get("facts") + vals.append((ans_, label, run(_parsed_with( + parsed, i, facts=f"{was}; {ans_}" if was else ans_)))) + elif par == "offence_date": + d = _period_date(v, cands_all) + if d is not None: + vals.append((v, label, run(tran_od={i: d}))) + if len(vals) >= 2: + trials.append((f"#{i} {fact}", f"entry #{i} {par}", need.get("question"), + None, vals)) + if "status-foreign-national" in assumed: + trials.append(("status", "status", DECISIVE_QUESTIONS["status"], + DECISIVE_ASSUMED["status"], + [("foreign-national", "a foreign national (what the answer assumes)", + sig0), + ("permanent-resident", "a permanent resident", + run(person_v="permanent-resident"))])) + if "no-foreign-relief" in assumed: + for r in rows: + if (r["canada"] or not (r["conviction"] or r["unknown"]) + or ents[r["n"]].get("relief") is not None): + continue # (roadmap T4.4: relief given for this entry, or the + # record-level relief read for it) + i = r["n"] + vals = [("none", "none beyond the disposition's words (what the answer " + "assumes)", sig0)] + for value, label, add in DECISIVE_RELIEF: + vals.append((value, f"{label} (#{i} read as '{ents[i]['disposition']}{add}')", + run(_parsed_with(parsed, i, + disposition=f"{ents[i]['disposition']}{add}")))) + trials.append((f"#{i} relief", None, DECISIVE_QUESTIONS["relief"], + DECISIVE_ASSUMED["relief"], vals)) + if "adult-at-offence" in assumed: + vals = [("adult", "an adult at every offence (what the answer assumes)", sig0)] + for a, label in DECISIVE_AGES: + pv = [(i, e, od, c, None if (dob_e is None or od is None) else dob_e, + a if age_e is None and (dob_e is None or od is None) else age_e, + cin, cd, pros) + for i, e, od, c, dob_e, age_e, cin, cd, pros in parsed] + vals.append((a, label.replace("the offence", "each offence without an age"), + run(pv))) + trials.append(("age", "date_of_birth", DECISIVE_QUESTIONS["age"], + DECISIVE_ASSUMED["age"], vals)) + return _decisive_keep(trials, _history_sig_dict) + + +def _history_confidence(rows, rec, limbs_shown): + """The confidence block (roadmap T4.2) for a whole record: step 1 and + step 2 per entry (n), step 3 the s. 18(2) / s. 18.1 limbs left UNKNOWN.""" + from canlex.screening import (confidence_block, step1_confidence, step2_confidence, + step3_confidence) + s1, s2 = [], [] + for r in rows: + if r["canada"]: + p = (r["canada"].get("info") or {}).get("pairing") + if p: + s2.append(step2_confidence(p, None, n=r["n"])) + continue + s1.append(step1_confidence(r.get("_entry_obj"), r.get("_row_obj"), n=r["n"])) + cands = r.get("_cands") or [] + if (r["eq"] or {}).get("branch") == "tie": + s2 += [step2_confidence(p, f, n=r["n"]) for p, f in cands] + elif r.get("_pairing_obj"): + s2.append(step2_confidence(r["_pairing_obj"], r["eq"], n=r["n"])) + tested = bool(limbs_shown or rec.get("s18_1")) + limbs = ([l for p in rec.get("paragraphs") or [] for l in p.get("limbs") or []] + if limbs_shown else []) + list((rec.get("s18_1") or {}).get("limbs") or []) + return confidence_block(step1=s1, step2=s2, step3=step3_confidence(limbs, tested)) + + +def _card_deemed_open(ans): + """Whether IRPR s. 18(2) deemed rehabilitation (or the s. 18.1 exemption) + holds today, or an open fact could make it hold today, on SOME reading of + the record (the H3 fixes of 2026-09-22, S17-INT-4). A paragraph whose + outcome is 'deemed' holds; one whose outcome is 'open' turns on a fact the + record does not give, so some reading of it may hold. Everything else -- + 'not yet', 'never', a paragraph whose chapeau does not hold -- cannot cure + the ground today, and the report stands.""" + rec, rehab = ans["rec"] or {}, ans["rehab"] or {} + if "deemed-rehabilitated" in (rehab.get("status"), rec.get("status")): + return True + paras = list(rec.get("paragraphs") or []) + if rec.get("s18_1"): + paras.append(rec["s18_1"]) + for rd in rec.get("readings") or []: + if rd.get("status") == "deemed-rehabilitated": + return True + paras += list(rd.get("paragraphs") or []) + for rd in (ans["count_readings"] or {}).values(): + if rd.get("rehab_status") in (None, "deemed-rehabilitated"): + return True + return any(p.get("outcome") in ("deemed", "open") for p in paras) + + +# A decisive fact that can take a ground AWAY, so the card cannot state the report while it +# is open: relief on an entry (IRPA s. 36(3)(b); Saini for a foreign one), the age at the +# offence (a youth disposition, s. 36(3)(e)) and the person's status (s. 36(2) reaches a +# foreign national only). 'other_record' is not one -- another conviction can only add. +_CARD_GROUND_OPEN_FACTS = ("age",) + + +def _card_report_holds(ans, paras, person, decisive): + """The paragraphs a s. 44(1) report names on EVERY reading of a record some + fact leaves open, or [] when no report stands on every reading (the H3 fixes + of 2026-09-22, S17-INT-4; CARD_OPEN_FACT_NOTE): a settled entry grounds one + of them, its act branch is not itself open, no decisive fact can take that + ground away, and no reading of IRPR s. 18(2) cures it. A + 'criminality-two-plus-only' entry is not such a ground -- it needs a second + offence, which the single-occurrence question can take away -- and neither + is a Canadian entry whose branch is a ground on only some reading. While the + person's status is open the report names the s. 36(1) paragraphs alone, on a + serious-criminality or Canadian s. 36(1)(a) ground: s. 36(2) reaches a + foreign national only (IRPA s. 36(2)).""" + if not paras or _card_deemed_open(ans): + return [] + facts = [str(d.get("fact") or "") for d in decisive or ()] + if any(f in _CARD_GROUND_OPEN_FACTS or "relief" in f for f in facts): + return [] + status_open = "status" in facts + out = [p for p in paras if not status_open or str(p).startswith("36(1)")] + if not out: + return [] + grounds = [r for r in ans["engaged"] + if (r["eq"] or {}).get("branch") == "serious-criminality" + or ((r["eq"] or {}).get("branch") == "criminality" + and person == "foreign-national" and not status_open)] + grounds = [r for r in grounds + if not r.get("held") and not _act_not_settled(r.get("act") or {}) + and not any(f.startswith(f"#{r['n']} ") for f in facts)] + canada = [e for e in ans["canada_entries"] + if e["removal"] and all(rd["removal"] for rd in e["readings"]) + and (not status_open or str(e["canada_branch"]).startswith("36(1)"))] + return out if (grounds or canada) else [] + + +def _history_card(rows, ans, today_d, person, decisive, checked, assumed, notes=(), + whole=()): + """The result card for a whole record (roadmap T4.7). An entry whose words + reach content held for review says so on its line and in a HELD FOR + REVIEW line (wave-4 verifier: never a bare 'no equivalent'); notes: the + lines to print as NOTE (a relief not applied to a conviction in Canada).""" + from canlex.screening import branch_text + rehab, s17 = ans["rehab"], ans["s17_row"] + unresolved = ans["unresolved"] + # the G3 fixes of 2026-09-22 (with AI-9): a conviction in Canada whose ground is open + # (canada_branch 'not-settled', a ground on some reading) and nothing else on the record + # to report -- the card had said 'no s. 36 ground on this matter' + c_open = [e for e in ans["canada_entries"] if not e["removal"] + and any(rd["removal"] for rd in e["readings"])] + c_only = bool(c_open and not ans["engaged"] + and not any(e["removal"] for e in ans["canada_entries"])) + not_settled = bool(unresolved or ans["occurrence_unsettled"] or c_only + or (rehab or {}).get("status") in ("undetermined", "not-settled")) + # the review of batch 1: an acquitted entry the record-level answer counts + counted_open = [r for r in ans["engaged"] if _act_not_settled(r.get("act") or {}) + and (r.get("act") or {}).get("footing") == "act"] + why = None + if not_settled: + why = ((decisive[0]["question"] if decisive else None) + or ("entr" + ("y " if len(unresolved) == 1 else "ies ") + + ", ".join(f"#{r['n']}" for r in unresolved) + " unresolved" + if unresolved else None) + or ("the single-occurrence question (s. 36(2)(b))" + if ans["occurrence_unsettled"] else + _canada_open_question(rows, c_open[0]) if c_only else + _s17_missing(ans["needs"]))) + result = f"Result: NOT SETTLED -- {why}" + elif rehab: + result = f"Result: count {ans['count']}; rehabilitation {_rehab_brief(rehab)}" + elif ans["engaged"] or ans["c_reportable"]: + result = (f"Result: count {ans['count']}; rehabilitation not computed (give the " + "sentence-completion dates)") + else: + result = "Result: nothing on this record engages s. 36 on the curated pairings" + # the review of batch 1, round 2: the one-reading clause is its own step after the + # chain (it had taken the chain's 'else', so every other record read 'nothing engages') + if counted_open and not not_settled and (rehab or ans["engaged"] or ans["c_reportable"]): + rq = (counted_open[0].get("act") or {}).get("routed_question") + routed = rq and all((r.get("act") or {}).get("routed_question") for r in counted_open) + # the review of batch 1, round 3 (MW-08): a routed programme asks, never 'after an acquittal' + result += (CARD_HISTORY_COUNT_ONE_READING_ROUTED if routed else CARD_HISTORY_COUNT_ONE_READING).format( + entries=", ".join(f"#{r['n']}" for r in counted_open), question=rq, + label=(counted_open[0].get("act") or {}).get("label") or "policy") + ents = [] + for r in rows: + if r["canada"]: + b = r["canada"]["res"]["canada_branch"] + ents.append(f"#{r['n']} {r['offense']} ({r['disposition']}; Canada) -> {b}") + else: + b = (r["eq"] or {}).get("branch") + held = r.get("held") or [] + hl = HELD_PREFIX.rstrip(": ") + f" ({', '.join(held)})" if held else "" + if held and not r["eq"]: + ents.append(f"#{r['n']} {r['offense']} ({r['disposition']}) -> {hl}") + continue + # (round H1, GQ-6: a possession-limit entry names its own question) + label = (CARD_POSSESSION_LIMIT_BRANCH + if (r["eq"] or {}).get("possession_limit_open") + else _DECISIVE_BRANCH.get(b, str(b)) if b else "no equivalent") + ents.append(f"#{r['n']} {r['offense']} ({r['disposition']})" + + _history_step1_clause(r) + " -> " + f"{r['display'] or 'no pairing'}: " + label + + (f" [{hl}]" if held else "")) + s17_act = ans.get("s17_act_row") + dates = [f"s. 17 clock from entry #{s17['n']} ({s17['completed'].isoformat()})" + if s17 else + f"s. 17 clock from entry #{s17_act['n']}'s offence date " + f"({s17_act['offence_date'].isoformat()})" if s17_act else "s. 17 clock not fixed"] + if (rehab or {}).get("earliest_deemed"): + # REG-7 (H3): deemed rehabilitation reaches the foreign matters alone + dates.append(f"earliest deemed {rehab['earliest_deemed']}" + + (CARD_DEEMED_FOREIGN_ONLY if ans["c_reportable"] else "")) + if (rehab or {}).get("earliest_apply"): + dates.append(f"earliest application {rehab['earliest_apply']}") + if ans["not_before"]: + dates.append(f"application not before {ans['not_before']}") + dates.append(f"assessed {today_d.isoformat()}") + paras, act_open, caveated, clauses, held_open = [], [], [], [], [] + for r in ans["engaged"]: + ra = r.get("act") or {} + if ra.get("held"): + held_open.append(r) # round 4 (RP-04): no report is drafted on it + continue + # (round 5: the verdict travels too, so an entry no curated entry matches asks + # STEP1_UNKNOWN_QUESTION and names each branch conditionally, as the record card does) + rq = _step1_question({"question_flag": r.get("question_flag"), + "row_mismatch": r.get("_row_mismatch"), "entry": r.get("_entry_obj"), + "verdict": r.get("verdict")}, + r.get("disposition"), ra) + branch = (r["eq"] or {}).get("branch") + cp = [p for p, k in _allegation_paras(branch, True, False, ans["count"]) if k == "conviction"] + if rq and ra.get("footing") == "both" and cp: + # the review of batch 1, round 2: this entry's question, each branch conditionally + ap = [p for p, k in _allegation_paras(branch, False, False, ans["count"]) if k == "act"] + clauses.append(CARD_HISTORY_QUESTION_CLAUSE.format( + n=r["n"], question=rq, conv="a report on s. " + " or s. ".join(cp), + act=_question_act_text(ra, ap, "a report on s. "))) + continue + if _act_not_settled(ra): + act_open.append(r) + elif ra.get("act_branch") == "policy-caveat": + caveated.append(r) + for p, _ in _allegation_paras(r["eq"]["branch"], r["conviction"], r["unknown"], + ans["count"], act_not_settled=_act_not_settled(ra)): + if p not in paras: + paras.append(p) + for e in ans["canada_entries"]: + if e["removal"] and e["canada_branch"] not in paras: + paras.append(e["canada_branch"]) + # H3 (S17-INT-4): the Action follows the grounds that hold on EVERY reading, so an + # open fact that cannot remove the report leaves the report standing and becomes a + # NOTE on the Dates line; NOT SETTLED is kept for records some reading clears. + report_paras = _card_report_holds(ans, paras, person, decisive) if not_settled else [] + if report_paras: + dates.insert(len(dates) - 1, CARD_OPEN_FACT_NOTE.format(why=why)) + paras = report_paras + if not_settled and not report_paras: + action = CARD_NOT_SETTLED_ACTION + elif not paras and held_open and not clauses and not act_open: + action = CARD_HELD_ACTION.format(entries=", ".join(f"#{r['n']}" for r in held_open)) + elif not paras and clauses and not act_open: + action = CARD_HISTORY_QUESTION_ACTION.format(clauses="; ".join(clauses)) + elif not paras and act_open: + rq0 = act_open[0]["act"].get("routed_question") + action = (CARD_ROUTED_ACT_NOT_SETTLED_ACTION.format( + question=rq0, label=act_open[0]["act"].get("label") or "policy") + if rq0 and all(r["act"].get("routed_question") for r in act_open) + else CARD_ACT_NOT_SETTLED_ACTION.format(label=act_open[0]["act"].get("label") + or "policy")) + elif not paras: + action = CARD_NO_GROUND_ACTION + elif (rehab or {}).get("status") == "deemed-rehabilitated": + action = CARD_DEEMED_ACTION + else: + action = (CARD_REPORT_PR if person == "permanent-resident" else CARD_REPORT_FN).format( + paras=" or s. ".join(paras)) + if caveated: + action += CARD_POLICY_CAVEAT.format(label=caveated[0]["act"].get("label") + or "policy") + plain = [r for r in act_open if not r["act"].get("routed_question")] + if plain: + action += CARD_HISTORY_ACT_OPEN.format( + entries=", ".join(f"#{r['n']}" for r in plain), + label=plain[0]["act"].get("label") or "policy") + for r in act_open: + if r["act"].get("routed_question"): + action += CARD_HISTORY_ACT_OPEN_ROUTED.format( + entries=f"#{r['n']}", question=r["act"]["routed_question"], + label=r["act"].get("label") or "policy") + if clauses and not not_settled and (paras or act_open): + action += CARD_HISTORY_QUESTION.format(clauses="; ".join(clauses)) + if held_open and not not_settled and (paras or act_open or clauses): + action += CARD_HISTORY_HELD.format(entries=", ".join(f"#{r['n']}" for r in held_open)) + lines = [f"# Result card -- a record of {len(rows)} entr{'y' if len(rows) == 1 else 'ies'}", + result, "Entries: " + "; ".join(ents)] + held_rows = [r for r in rows if r.get("held")] + if held_rows: + lines.append(CARD_HELD.format(entries=", ".join( + f"#{r['n']} ({', '.join(r['held'])})" for r in held_rows))) + worded = [r for r in rows if r.get("disposition_flags")] + if worded: + # disposition roadmap T1.6 / critic: how each entry's words were read + lines.append("Step 1 wording: " + "; ".join( + f"#{r['n']} " + ", ".join(_flag_lines(r["disposition_flags"], card=True, + places=r.get("gate_places"))) + for r in worded)) + lines += ["Dates: " + "; ".join(dates), action] + ch_line = _card_changes(decisive, checked, whole) + if ch_line: + lines.append(ch_line) + # (PREROG-3, G3 2026-09-22: the other s. 17 readings are on the card, not only in + # the full memo and CANLEX_RESULT) + lines += [f"NOTE: {n}." for n in list(notes) + list(ans.get("s17_notes") or [])] + if assumed: + lines.append("Assumed (not given): " + ", ".join(assumed)) + lines.append(CARD_FULL) + return lines + + +def _transborder_confidence(edata, r): + """Roadmap T4.2: the pairing a Criminal Code or IRPA offence matched + (its mode), when one did.""" + from canlex.screening import confidence_block, step2_confidence + p = next((p for p in (edata or {}).get("pairings", []) if p["id"] == r.get("pairing")), + None) + return confidence_block(step2=[step2_confidence(p, None)] if p else []) + + +def _load_transborder(): + """The curated IRPR s. 19(1)(c)-(g) table (HELD FOR REVIEW), or None when + this deployment lacks it -- a composed release ships without held files, + and canlex_transborder then says the table awaits review.""" + from canlex.transborder import load_table + return load_table() + + +@mcp.tool(name="canlex_transborder", + annotations={"title": "Transborder Criminality (IRPA s. 36(2.1))", + **_READONLY}) +def canlex_transborder( + act: Annotated[Optional[str], Field( + description="The Act the offence is under, by name or code: 'Criminal " + "Code', 'IRPA', 'Firearms Act', 'Customs Act', 'CDSA', 'Cannabis Act' -- " + "IRPR s. 19 prescribes offences under those six only -- or any other " + "Act.", max_length=80)] = None, + section: Annotated[Optional[str], Field( + description="The provision contravened, as cited: '95', '320.15(1)', " + "'124(1)(b)'. For the Customs Act: '159(1)' (smuggling), '12' (failing " + "to report goods) or '156'.", max_length=30)] = None, + offence: Annotated[Optional[str], Field( + description="What happened, in words ('undeclared loaded handgun', " + "'impaired driving at the port of entry'); used in the draft allegation. " + "A Criminal Code or IRPA offence given only in words is matched to the " + "curated pairings for its mode.", max_length=200)] = None, + mode: Annotated[Optional[Literal["indictable", "hybrid", "summary-only"]], Field( + description="Criminal Code and IRPA offences only: how the offence may be " + "prosecuted. A hybrid offence is deemed indictable (IRPR s. 19(2)) and so " + "prescribed; one that may ONLY be prosecuted summarily is not.")] = None, + goods: Annotated[Optional[Literal["child-pornography", "firearm-or-weapon", + "controlled-substance", "schedule-ix-device", + "cannabis", "other"]], Field( + description="Customs Act offences only: the goods. 'firearm-or-weapon' = " + "the Criminal Code s. 84(1) firearms, weapons, prohibited devices and " + "ammunition; 'controlled-substance' = CDSA Schedules I-VI; " + "'schedule-ix-device' = CDSA Schedule IX.")] = None, + status: Annotated[Optional[Literal["foreign-national", "permanent-resident"]], + Field(description="Whose conduct it is. Omit when not " + "known: a foreign national is assumed and the result " + "says so. s. 36(2.1) does not reach a permanent " + "resident.")] = None, +) -> str: + """Screen an offence committed ON ENTERING Canada -- an undeclared firearm, + cannabis or drugs, an offence at the port of entry -- for transborder + criminality under IRPA s. 36(2.1) (former s. 36(2)(d) is repealed). It + decides whether the offence is prescribed by IRPR s. 19: any indictable or + hybrid Criminal Code or IRPA offence (a rule on the mode), or the listed + Firearms Act, Customs Act (by goods), CDSA and Cannabis Act offences (a + curated table, held for review -- absent, the answer says so). It then + gives the removal path (the Minister's delegate for the IRPR + s. 228(1)(a.01) offences, otherwise the Immigration Division under + s. 229(1)(d.1)), the rule that s. 36(3)(c) rehabilitation does not reach + s. 36(2.1), the s. 36(3)(e)(i) contraventions check against the + Contraventions Regulations, the TRP option and a draft allegation. A + permanent resident is not reached. For a record of convictions or acts + outside Canada use canlex_screen_record. + + Returns: + str: The memo and one CANLEX_RESULT line. + """ + from canlex import transborder as tb + from canlex.commentary import BANNER + from canlex.screening import match_pairings, result_line + if not ((act or "").strip() or (offence or "").strip()): + return ("Error: give the Act (and the section contravened), or describe the " + "offence in words.") + edata = _load_equivalency() + table = _load_transborder() + r = tb.assess(act, section, mode, goods, status, offence, + pairings=(edata or {}).get("pairings"), table=table, + match=match_pairings) + contr = tb.contravention_check(r["act"]) if r["act"] else None + assumed = ["status-foreign-national"] if status is None else [] + out = [BANNER, "", "# Transborder criminality screen (IRPA s. 36(2.1))"] + out += _assumed_block(assumed) + out += [""] + tb.render(r, offence, _fee_amount("trp"), + _family_line(r["person"], None), contr) + # roadmap T3.6: the statute the matched table row names (held; the approved + # table has no see_also) + from canlex import readnext + row_obj = next((x for x in (table or {}).get("rows") or [] + if isinstance(x, dict) and x.get("id") == r.get("row")), None) + rn_items = readnext.items(row_obj) + rn = readnext.lines(rn_items, _index_chunks()) + if rn: + out += [""] + rn + rn_ids = readnext.chunk_ids(rn_items, _index_chunks()) + out += ["", "## Bottom line", f"- {tb.bottom_line(r)}", + "- This is legal information from statute text and a curated table, " + "not legal advice; the officer's determination governs, and every " + "provision cited should be read with canlex_get_section.", ""] + out.append(result_line( + tool="canlex_transborder", person=r["person"], act=r["act"], + section=r["section"], mode=r["mode"], mode_source=r["mode_source"], + goods=r["goods"], pairing=r["pairing"], paragraph=r["paragraph"], + prescribed=r["prescribed"], branch=r["branch"], + md_orderable=r["md_orderable"], md_provision=r["md_provision"], + removal=r["removal"], removal_provision=r["removal_provision"], + rehabilitation="none", contravention=(contr or {}).get("status"), + table=r["table"], row=r.get("row"), readings=r["readings"], + needs=r["needs"], assumed=assumed, + confidence=_transborder_confidence(edata, r), + **({"read_next": rn_ids} if rn_ids else {}))) + return "\n".join(out) + + +@mcp.tool(name="canlex_list_acts", + annotations={"title": "List Loaded Legislation", **_READONLY}) +def canlex_list_acts( + doc_type: Annotated[Optional[DocType], Field( + description="Optional: list one source type only (the doc_type values " + "canlex_search_legislation filters by). Omit for the whole corpus.")] = None, + compact: Annotated[bool, Field( + description="True: one line per source type -- how many instruments and their " + "short names (case law: the count and the years decided) -- instead of one " + "line per instrument.")] = False, +) -> str: + """List what the CanLex corpus contains -- Acts and regulations, + administrative guidance (CBSA D-Memoranda and detention policy, IRCC ENF + manuals and PDIs, IRB Guidelines and Legal Services papers), collective + agreements, NJC directives, leading cases, delegation instruments, + benefit-plan booklets, employer policy instruments, union documents, and + CanLex's curated commentary datasets. + + Use this to learn the scope and currency of the corpus before searching, or to + report it to the user. doc_type narrows the list to one source type and + compact gives one line per source type (the full list runs to about 44,000 + characters). + + Returns: + str: Markdown grouped by source type. + """ + index = _index() + acts: dict[str, dict] = {} + agreements: dict[str, dict] = {} + directives: dict[str, dict] = {} + cases: dict[str, dict] = {} + delegations: dict[str, dict] = {} + benefits: dict[str, dict] = {} + commentary: dict[str, dict] = {} + union_docs: dict[str, dict] = {} + policies: dict[str, dict] = {} + # Guidance by publisher (roadmap T1.10): D-memos count by memo number, + # every other source by document (act_code) -- ENF and PDI sections used + # to be counted as 'D-Memoranda'. + guidance: dict[str, dict] = {} + fr_chunks = 0 + fr_acts: set[str] = set() + for c in index.chunks: + if c.get("lang", "en") == "fr": + # French twins share their English instrument's identity; count + # them once as coverage rather than doubling every act's rows. + # Legislation only (2026-09-22): the line names instruments and + # articles, and counted 44 French guidance documents too (124). + if c.get("doc_type", "legislation") == "legislation": + fr_chunks += 1 + fr_acts.add(c["act_code"]) + continue + kind = c.get("doc_type", "legislation") + if kind == "memorandum": + prefix, publisher, _, _ = _publisher(c) + g = guidance.setdefault(publisher if prefix != "D-Memo" else "CBSA D-Memoranda", { + "docs": set(), "chunks": 0, "newest": "", "memos": prefix == "D-Memo"}) + g["docs"].add(c["section"] if prefix == "D-Memo" else c["act_code"]) + g["chunks"] += 1 + g["newest"] = max(g["newest"], c["current_to"] or "") + elif kind == "agreement": + entry = agreements.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "directive": + entry = directives.setdefault(c["act_code"], { + "short": c["act_short"], "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "caselaw": + entry = cases.setdefault(c["act_code"], { + "name": c["act_name"], "decided": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "delegation": + entry = delegations.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "benefits": + entry = benefits.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "commentary": + entry = commentary.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "union": + entry = union_docs.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + elif kind == "policy": + entry = policies.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + else: + entry = acts.setdefault(c["act_code"], { + "short": c["act_short"], "name": c["act_name"], + "code": c["act_code"], "current_to": c["current_to"], "count": 0, + }) + entry["count"] += 1 + # [(doc_type, heading, one line per instrument, the compact line)]: rendered + # whole, one type (doc_type) or one line per type (compact) by _corpus_text + # (audit follow-up 2026-09-21, missing 13) + groups = [] + + def shorts(entries, one, many): + return (f"- {len(entries)} {one if len(entries) == 1 else many}: " + + ", ".join(sorted(e["short"] for e in entries.values()))) + + act_lines = [f"- **{a['short']}** — {a['name']} ({a['code']}): " + f"{a['count']} sections, current to {a['current_to'] or 'n/a'}" + for a in sorted(acts.values(), key=lambda x: x["short"])] + groups.append(("legislation", "## Enacted law", act_lines, + shorts(acts, "instrument", "instruments"))) + if guidance: + rows = [] + for name in sorted(guidance, key=lambda n: (not guidance[n]["memos"], n)): + g = guidance[name] + unit = "memoranda" if g["memos"] else ( + "document" if len(g["docs"]) == 1 else "documents") + rows.append(f"- **{name}** — {len(g['docs'])} {unit} " + f"({g['chunks']} sections), modified up to " + f"{g['newest'] or 'n/a'}.") + groups.append(("memorandum", "## Administrative guidance (persuasive, not binding " + "law; each result is labelled with its publisher)", rows, + f"- {len(guidance)} publisher{'s' if len(guidance) != 1 else ''}: " + + ", ".join(sorted(guidance, key=lambda n: (not guidance[n]["memos"], n))))) + if agreements: + groups.append(("agreement", "## Collective agreements", [ + f"- **{a['short']}** — {a['name']}: {a['count']} articles, " + f"in force to {a['current_to'] or 'n/a'}" + for a in sorted(agreements.values(), key=lambda x: x["short"])], + shorts(agreements, "agreement", "agreements"))) + if directives: + groups.append(("directive", "## NJC directives", [ + f"- **{a['short']}**: {a['count']} sections, " + f"effective {a['current_to'] or 'n/a'}" + for a in sorted(directives.values(), key=lambda x: x["short"])], + shorts(directives, "directive", "directives"))) + if cases: + years = sorted(a["decided"][:4] for a in cases.values() if a["decided"]) + groups.append(("caselaw", "## Case law", [ + f"- **{a['name']}**, {cite}: {a['count']} excerpts, " + f"decided {a['decided'] or 'n/a'}" + for cite, a in sorted(cases.items(), key=lambda kv: kv[1]["decided"])], + f"- {len(cases)} decision{'s' if len(cases) != 1 else ''}" + + (f", decided {years[0]}–{years[-1]}" if years else "") + + " (doc_type 'caselaw' lists them)")) + if delegations: + groups.append(("delegation", "## Delegation instruments", [ + f"- **{a['short']}** — {a['name']}: {a['count']} items, " + f"dated {a['current_to'] or 'n/a'}" + for a in sorted(delegations.values(), key=lambda x: x["short"])], + shorts(delegations, "instrument", "instruments"))) + if benefits: + groups.append(("benefits", "## Benefit plans", [ + f"- **{a['short']}** — {a['name']}: {a['count']} " + f"sections, booklet edition {a['current_to'] or 'n/a'}. " + f"Plain-language member guide; the plan rules govern." + for a in sorted(benefits.values(), key=lambda x: x["short"])], + shorts(benefits, "booklet", "booklets"))) + if policies: + groups.append(("policy", "## Employer policy instruments", [ + f"- **{a['short']}** — {a['name']}: {a['count']} " + f"sections, page modified {a['current_to'] or 'n/a'}. " + f"Condition-of-employment policy; not legislation, " + f"not the collective agreement." + for a in sorted(policies.values(), key=lambda x: x["short"])], + shorts(policies, "instrument", "instruments"))) + if union_docs: + groups.append(("union", "## Union documents (CIU/PSAC)", [ + f"- **{a['short']}** — {a['name']}: {a['count']} " + f"sections, currency {a['current_to'] or 'n/a'}. " + f"Bargaining-agent document; not employer policy." + for a in sorted(union_docs.values(), key=lambda x: x["short"])], + shorts(union_docs, "document", "documents"))) + if commentary: + groups.append(("commentary", "## Curated commentary (not law)", [ + f"- **{a['short']}** — {a['name']}: {a['count']} " + f"entries, reviewed {a['current_to'] or 'n/a'}. " + f"CanLex-authored analysis of the cited authorities; " + f"clearly bannered, never a source of law." + for a in sorted(commentary.values(), key=lambda x: x["short"])], + shorts(commentary, "dataset", "datasets"))) + notes = [] + if fr_chunks: + notes.append(f"_Bilingual: {len(fr_acts)} instruments also carry " + f"their parallel French text ({fr_chunks} articles); " + f"a French-language query searches both._") + notes += _embedding_currency_lines(index) + return _corpus_text(groups, notes, doc_type, compact) + + +_CORPUS_FOOTER = ( + "Search with canlex_search_legislation; filter by doc_type " + "(legislation / memorandum / agreement / directive / caselaw / " + "delegation / benefits / commentary / union / policy). Fetch a " + "known provision " + "with canlex_get_section, a decision's text with " + "canlex_search_legislation (doc_type 'caselaw'), and a decision's " + "later treatment with canlex_case where a CanLII citator is " + "configured.") + + +def _corpus_text(groups, notes, doc_type=None, compact=False): + """canlex_list_acts' Markdown: the title, the notes (bilingual coverage, + embedding currency), each group's heading and lines -- only doc_type's group + when one is named, one compact line per group when compact -- and the + footer. With neither option the text is the listing as it stood before + the options (audit follow-up 2026-09-21, missing 13).""" + lines = ["# CanLex corpus"] + list(notes) + [""] + shown = [g for g in groups if doc_type in (None, g[0])] + if not shown: + lines.append(f"Nothing of doc_type '{doc_type}' is loaded.") + for i, (_, heading, rows, line) in enumerate(shown): + lines += ([""] if i else []) + [heading] + ([line] if compact else rows) + lines += ["", _CORPUS_FOOTER] + return "\n".join(lines) + + +def _embedding_currency_lines(index): + """One line on whether every chunk has its stored embedding vector, read + from the index's embedding_missing (a count, or the missing ids) when the + index offers it; [] when it does not (audit follow-up 2026-09-21, missing + 15: a missing vector was only a stderr line at load). An index whose + semantic search is off (the embedder failed to load, or no store) says so + instead, and the all-present line claims nothing about currency: whether + a stored vector was embedded from the current text is canlex.embed's to + count (verification fix 2026-09-22, A-EMB-2: both read 'semantic search + is current').""" + try: + if getattr(index, "semantic", True) is False: + return ["_Embeddings: semantic search is off (keyword search only)._"] + missing = getattr(index, "embedding_missing", None) + except Exception: # a property that cannot answer says nothing + return [] + if isinstance(missing, bool) or missing is None: + return [] + if not isinstance(missing, int): + try: + missing = len(missing) + except TypeError: + return [] + if missing <= 0: + return ["_Embeddings: every chunk has a stored vector._"] + return [f"_Embeddings: {missing} chunk{'s' if missing != 1 else ''} " + f"{'have' if missing != 1 else 'has'} no stored vector -- semantic search " + f"does not reach {'them' if missing != 1 else 'it'} until the embeddings " + f"are rebuilt (py -m canlex.embed); keyword search still does._"] + + +@mcp.tool(name="canlex_whats_new", + annotations={"title": "What Changed Recently", **_READONLY}) +def canlex_whats_new( + since: Annotated[str, Field( + description="ISO date (YYYY-MM-DD): report amendments and additions " + "on or after this date.", pattern=r"^\d{4}-\d{2}-\d{2}$")], +) -> str: + """List what has changed in the corpus's law since a date: provisions + amended or newly in force on or after it, plus each guidance source's + newest modification date. + + Use for questions like "has IRPA changed since March?" or "what did the + last refresh pick up?". Changes reflect the ingested consolidations -- + an amendment newer than the corpus's 'current to' dates is invisible + here; say so when the date asked about is very recent. + + Returns: + str: Markdown -- amended/new provisions grouped by Act, then + guidance sources' newest dates. + """ + from canlex.screening import parse_date + try: + # (the code audit of 2026-09-21: the pattern let '2020-99-99' through, which + # string-compares above every real 2020 date and silently excluded that year) + parse_date(since) + except ValueError as exc: + return f"Error: since must be a real date, YYYY-MM-DD ({exc})." + index = _index() + amended: dict[str, list] = {} + guidance_newest: dict[str, str] = {} + for c in index.chunks: + if c.get("lang", "en") == "fr": + continue + if c.get("doc_type", "legislation") == "legislation": + stamp = max(c.get("last_amended") or "", c.get("in_force") or "") + if stamp >= since and c.get("section"): + amended.setdefault(c["act_short"], []).append( + (c["section"], c.get("marginal_note", ""), stamp)) + else: + key = c.get("act_short") or c.get("doc_type") + cur = c.get("current_to") or "" + if cur > guidance_newest.get(key, ""): + guidance_newest[key] = cur + lines = [f"# Changes since {since}", ""] + if amended: + lines.append("## Provisions amended or newly in force") + for act in sorted(amended): + secs = sorted({(s, n, d) for s, n, d in amended[act]}, + key=lambda t: t[2], reverse=True) + lines.append(f"**{act}** ({len(secs)} provisions):") + for s, n, d in secs[:25]: + lines.append(f"- s. {s} ({n}) — {d}") + if len(secs) > 25: + lines.append(f"- ...and {len(secs) - 25} more.") + else: + lines.append("No ingested provision shows an amendment or " + "coming-into-force date on or after that date.") + recent_guidance = {k: v for k, v in guidance_newest.items() if v >= since} + if recent_guidance: + lines += ["", "## Guidance sources modified since then"] + # newest first (the code audit of 2026-09-21: the key was -ord('2') for every + # ISO date, so the list came out in insertion order) + for k, v in sorted(recent_guidance.items(), key=lambda kv: (kv[1], kv[0]), reverse=True): + lines.append(f"- {k}: {v}") + lines += ["", "_Reflects the ingested consolidations only; verify " + "anything newer than the corpus's 'current to' dates upstream._"] + return "\n".join(lines) + + +@mcp.tool(name="canlex_citing", + annotations={"title": "What Cites This Provision", **_READONLY}) +def canlex_citing( + act: Annotated[str, Field( + description="Act short name or code, e.g. 'IRPA'.", + min_length=1, max_length=60)], + section: Annotated[str, Field( + description="Section number, e.g. '34' (prefixes accepted).", + min_length=1, max_length=40)], +) -> str: + """Reverse cross-reference lookup: everything in the corpus that cites a + given provision -- related provisions in the same Act, regulations made + under it, and the CBSA D-memoranda/guidance citing the section -- + without fetching the section's own text first. + + Returns: + str: Markdown lists of citing/related material, or a not-found + message. + """ + index = _index() + result = index.get_section(act, section) + if result is None and section != _normalize_section(section): + result = index.get_section(act, _normalize_section(section)) + if result is None: + label = _schedule_label(index, act, section) + if label: + result = index.get_section(act, label) + if result is None: + return _not_found(index, act, section) + related = index.related(result) or {} + lines = [f"# Material citing {_clean_citation(result['citation'])}", ""] + memos = related.get("memoranda") + if memos: + lines.append("**Guidance citing this section** (persuasive, not " + "binding): " + ", ".join(memos)) + provisions = related.get("provisions") + if provisions: + lines.append("**Related provisions in this Act:** " + + "; ".join(f"s. {s} ({n})" if n else f"s. {s}" + for s, n in provisions)) + regs = related.get("regulations") + if regs: + lines.append("**Regulations made under this Act:** " + + "; ".join(f"{n} ({s})" for s, n in regs)) + enabling = related.get("enabling_act") + if enabling: + lines.append(f"**Made under:** {enabling[1]} ({enabling[0]})") + if len(lines) == 2: + lines.append("Nothing in the corpus cross-references this section.") + lines += ["", "For case law interpreting it, search with " + "canlex_search_legislation (doc_type 'caselaw')."] + return "\n".join(lines) + + +TOC_FULL_LISTING = 400 # sections: an Act this size or smaller is listed in full + + +@mcp.tool(name="canlex_toc", + annotations={"title": "Act Table of Contents", **_READONLY}) +def canlex_toc( + act: Annotated[str, Field( + description="Act short name or code, e.g. 'IRPA' or 'Customs Act'.", + min_length=1, max_length=60)], + part: Annotated[Optional[str], Field( + description="Optional: one Part or Division to expand in full -- 'Part 1', " + "'Division 6', a bare number (a Part's), or words of its heading " + "('detention'). Omit for the whole outline.", max_length=120)] = None, +) -> str: + """Browse an Act's structure: its Parts/Divisions and the sections under + each, with their marginal notes. Use when you know the Act but not the + section number, or to survey what an Act covers. + + Returns: + str: Markdown outline. An Act of up to 400 sections (e.g. IRPA) is + listed in full; a larger one (e.g. the IRPR or the Criminal Code) + returns each Part's section range and, under it, each Division's + range -- give part= ('Part 1', 'Division 6', or heading words) to + list one Part or Division in full, with its Divisions as headings. + """ + index = _index() + a = act.strip().lower() + secs = [c for c in index.chunks + if c.get("doc_type", "legislation") == "legislation" + and c.get("lang", "en") == "en" + and a in (c["act_short"].lower(), c["act_code"].lower(), + c.get("act_name", "").lower()) + and c.get("section") and not c["id"].endswith(tuple( + f"-p{k}" for k in range(2, 40)))] + if not secs: + known = sorted({c["act_short"] for c in index.chunks + if c.get("doc_type", "legislation") == "legislation"}) + return (f"No legislation matches '{act}'. Loaded: " + + ", ".join(known)) + by_part: dict[str, list] = {} + seen = set() + for c in secs: + if c["section"] in seen: + continue + seen.add(c["section"]) + by_part.setdefault(c.get("part") or "(no Part)", []).append( + (c["section"], c.get("marginal_note", ""), c.get("division") or "")) + name = secs[0]["act_name"] + if part: + return _toc_part(name, by_part, part) + total = sum(len(v) for v in by_part.values()) + lines = [f"# {name} — contents ({total} sections)", ""] + detailed = total <= TOC_FULL_LISTING + for label, entries in by_part.items(): + lines.append(f"## {label}" if label != "(no Part)" else "## —") + if detailed: + for s, n, _ in entries: + lines.append(f"- s. {s}" + (f" — {n}" if n else "")) + else: + first, last = entries[0][0], entries[-1][0] + lines.append(f"- ss. {first}–{last} ({len(entries)} sections)") + lines += _toc_division_ranges(entries) + if not detailed: + lines += ["", "Give part= (e.g. 'Part 1', 'Division 6' or words of a heading) to " + "list one Part or Division in full."] + lines += ["", "Fetch any section with canlex_get_section."] + return "\n".join(lines) + + +_TOC_NUMBER = r"[0-9]+(?:\.[0-9]+)*[a-z]?|[ivxlc]+(?:\.[0-9]+)?" +_TOC_LABEL = re.compile(r"\s*(part|division)\s+(" + _TOC_NUMBER + r")\b", re.I) +_TOC_QUERY = re.compile(r"(?:(part|division|div\.?)\s*(" + _TOC_NUMBER + + r")|([0-9]+(?:\.[0-9]+)*[a-z]?))") + + +def _toc_matches(part_label, division_label, query): + """True when the Part or Division label is the one canlex_toc's part= + names: 'Part 1' / 'Part VIII' / 'Division 6' / a bare arabic number (a + Part's) by number -- 'Part 1' is never Part 1.1 or Part 10, 'Part VIII' + never Part VIII.1 -- else words of either heading.""" + q = " ".join((query or "").lower().split()) + m = _TOC_QUERY.fullmatch(q) + if m: + kind = "division" if (m.group(1) or "").startswith("div") else "part" + number = m.group(2) or m.group(3) + got = _TOC_LABEL.match((division_label if kind == "division" else part_label) or "") + return bool(got) and (got.group(1).lower(), got.group(2).lower()) == (kind, number) + return bool(q) and any(q in (label or "").lower() for label in (part_label, division_label)) + + +def _toc_division_ranges(entries): + """A collapsed Part's Division lines: each Division's section range.""" + groups: dict[str, list] = {} + for s, _, division in entries: + if division: + groups.setdefault(division, []).append(s) + return [f" - {d}: ss. {v[0]}–{v[-1]} ({len(v)} section{'s' if len(v) != 1 else ''})" + if len(v) > 1 else f" - {d}: s. {v[0]} (1 section)" for d, v in groups.items()] + + +def _toc_part(name, by_part, query): + """canlex_toc with part=: every section of the Parts and Divisions the + query names (_toc_matches), in full, each Division a heading.""" + picked = {label: [e for e in entries if _toc_matches(label, e[2], query)] + for label, entries in by_part.items()} + picked = {label: entries for label, entries in picked.items() if entries} + if not picked: + parts = [label for label in by_part if label != "(no Part)"] + return (f"No Part or Division of {name} matches '{query}'." + + (f" Its Parts: {'; '.join(parts)}." if parts else " It has no Parts.") + + " Omit part= for the whole outline.") + total = sum(len(v) for v in picked.values()) + lines = [f"# {name} — contents: {query} ({total} section{'s' if total != 1 else ''})", ""] + for label, entries in picked.items(): + lines.append(f"## {label}" if label != "(no Part)" else "## —") + division = None + for s, n, d in entries: + if d and d != division: + lines.append(f"### {d}") + division = d + lines.append(f"- s. {s}" + (f" — {n}" if n else "")) + lines += ["", "Fetch any section with canlex_get_section."] + return "\n".join(lines) + + +_CITATOR = None + + +def _citator(): + """Load and cache the CanLII citator on first use.""" + global _CITATOR + if _CITATOR is None: + from canlex.citator import Citator + _CITATOR = Citator() + return _CITATOR + + +def _format_case(report: dict) -> str: + """Render a CanLII citator report as Markdown.""" + meta = report["meta"] + lines = [f"## {meta.get('title', '(untitled case)')}", + meta.get("citation", "").strip()] + facts = [] + if meta.get("decisionDate"): + facts.append(f"decided {meta['decisionDate']}") + if meta.get("docketNumber"): + facts.append(f"docket {meta['docketNumber']}") + if facts: + lines.append("(" + "; ".join(facts) + ")") + if meta.get("keywords"): + lines.append(f"**Keywords:** {meta['keywords']}") + if meta.get("topics"): + lines.append(f"**Topics:** {meta['topics']}") + if meta.get("url"): + lines.append(f"CanLII: {meta['url']}") + lines.append("") + lines.append("_CanLII citator data (live). This is case metadata and the " + "citation graph only -- not the judgment text; follow the CanLII " + "link to read the decision. A case is binding precedent depending " + "on the court and jurisdiction._") + + def case_list(label, block): + rows = [f"\n### {label}: {block['total']}"] + for item in block["items"]: + rows.append(f"- {item.get('title', '')} — {item.get('citation', '')}") + extra = block["total"] - len(block["items"]) + if extra > 0: + rows.append(f" ...and {extra} more.") + return rows + + lines += case_list("Cited by (later cases citing this one)", report["citingCases"]) + lines += case_list("Cites (authorities this case relies on)", report["citedCases"]) + + legis = report["citedLegislations"] + lines.append(f"\n### Legislation cited: {legis['total']}") + for item in legis["items"]: + kind = item.get("type", "") + lines.append(f"- {item.get('title', '')} — {item.get('citation', '')}" + + (f" [{kind}]" if kind else "")) + extra = legis["total"] - len(legis["items"]) + if extra > 0: + lines.append(f" ...and {extra} more.") + return "\n".join(lines) + + +@mcp.tool(name="canlex_case", + annotations={"title": "CanLII Case Citator", "readOnlyHint": True, + "destructiveHint": False, "idempotentHint": True, + "openWorldHint": True}) +def canlex_case( + case_url: Annotated[str, Field( + description="A Canadian case, given either as a full canlii.org URL " + "or -- for a Supreme Court (SCC), Federal Court of Appeal (FCA), " + "Federal Court (FC; FCT for 2001-2003), FPSLREB, CIRB, Ontario Court " + "of Appeal (ONCA) or Alberta Court of Appeal (ABCA) decision -- its " + "neutral citation (e.g. '2019 SCC 65', '2016 FCA 93', '2023 FPSLREB " + "12', '2021 ONCA 700'). For any other court, supply the canlii.org " + "URL; find it by web search if you only have the case name.", + min_length=8, max_length=400)], +) -> str: + """Look up a Canadian case on CanLII and return its citation graph. + + Returns the case's metadata plus its citator: the cases it cites, the cases + that cite it (its treatment and how leading it is), and the legislation it + cites -- live from the CanLII API. Use it to gauge whether a decision is + still good law -- how heavily and how recently it has been cited. + + Supply either a canlii.org URL or, for a Supreme Court (SCC), Federal + Court of Appeal (FCA), Federal Court (FC; FCT for 2001-2003), FPSLREB, + CIRB, Ontario Court of Appeal (ONCA) or Alberta Court of Appeal (ABCA) + decision, its neutral citation (e.g. '2019 SCC 65', '2021 ONCA 700') -- + the citation a canlex_search_legislation result already shows; any other + court needs the canlii.org URL. This returns metadata and the citation + graph only, NOT the judgment text -- follow the CanLII link for that. A + call takes ~15-20 seconds (the API is rate-limited). + + Returns: + str: Markdown -- the case's title, neutral citation, date, docket and + topics; how many cases cite it (with examples); how many it cites; and the + legislation it cites. Returns an error message if the URL is not a + recognized canlii.org case URL, or if the CanLII API is unavailable. + """ + try: + citator = _citator() + except Exception: + return ("This deployment has no citator configured (the live CanLII " + "lookup is unavailable here). Fall back to a web search for " + "the case's CanLII page and note that its later treatment " + "was not verified.") + try: + report = citator.case_report(case_url) + except Exception as exc: + return f"CanLII lookup failed: {type(exc).__name__}: {exc}" + if "error" in report: + return f"Error: {report['error']}" + return _format_case(report) + + +@mcp.prompt(name="us_record_screening", + description="Guided IRPA s. 36 screening of a foreign criminal " + "record (US state rows where they exist; a non-US record is " + "labelled 'non-US record: curated coverage is thin'): " + "disposition -> equivalency -> rehabilitation, using the CanLex " + "screening tools with the right hand-offs. It asks for the " + "person's status (foreign national or permanent resident) and the " + "application type, and takes the completion date as YYYY-MM-DD.") +def us_record_screening(disposition: str, state: str = "", + offense: str = "", sentence_completed: str = "", + status: str = "", application: str = "", + sentence_completed_year: str = "", + country: str = "", + ) -> str: + """The s. 36 screening chain as a reusable client prompt. + sentence_completed is the full date (YYYY-MM-DD) the whole sentence was + completed; sentence_completed_year is the older year-only argument, still + accepted but never turned into a date.""" + facts = [f"Disposition: {disposition}"] + if country: + facts.append(f"Country: {country}") + if state: + facts.append(f"State: {state}") + if offense: + facts.append(f"Offence: {offense}") + if sentence_completed: + facts.append(f"Sentence completed: {sentence_completed}") + elif sentence_completed_year: + facts.append(f"Sentence completed (year only -- ask for the full date; " + f"never assume a day): {sentence_completed_year}") + facts.append(f"Status: {status}" if status else + "Status: NOT GIVEN -- ask whether the person is a foreign " + "national or a permanent resident") + facts.append(f"Application: {application}" if application else + "Application: NOT GIVEN -- ask whether this is temporary entry " + "(visitor, worker, student), an application for permanent " + "residence, or a returning permanent resident") + return ( + "Screen this criminal record for inadmissibility under IRPA s. 36. " + "An offence committed ON ENTERING Canada (an undeclared firearm, cannabis " + "or drugs; an offence at the port of entry) is transborder criminality, " + "s. 36(2.1): screen it with canlex_transborder instead -- it reaches a " + "foreign national only and has no rehabilitation route. If the facts " + "suggest a criminal organization, pass organization=True to " + "canlex_screen_record: s. 37 is a separate ground to consider. " + "A conviction IN CANADA (CPIC) is screened by the same tools on " + "s. 36(1)(a)/(2)(a): pass country 'Canada' (or the province as the " + "state) with canada_offence_mode, custody_imposed_months, " + "canada_sentence and canada_max_years where known -- a discharge is not " + "a conviction, a conditional sentence is not imprisonment, and the cure " + "is a record suspension. Where the person may have been under 18 at the " + "offence, pass date_of_birth (or age_at_offence): the youth block keeps " + "the statute and IRCC policy apart. " + "First confirm the two facts that change the answer and that the " + "record does not show: the person's STATUS (pass status: " + "'foreign-national' or 'permanent-resident') and the APPLICATION (pass " + "application: 'temporary-entry', 'pr-applicant' or 'returning-pr' -- it " + "decides the family-member line under IRPA s. 42). Every date is " + "YYYY-MM-DD: pass sentence_completed as the full date the ENTIRE " + "sentence was completed; if only a year is known, say so and do not " + "invent a day. For a record from outside the US pass country (and " + "state only for a US state): the tools then skip the US state rows " + "and label the answer 'non-US record: curated coverage is thin' -- " + "repeat that caveat. " + "Fastest: call canlex_screen_record once per entry with the offence, " + "the disposition in the record's own words, the state or country, the offence " + "date and the sentence-completion date (plus value_over_5000 or the " + "CDSA schedule where the pairing says the branch depends on them); " + "for a record with several entries, call canlex_screen (typed entries) " + "or canlex_screen_history with all of them so the count and the s. 17 " + "clock are computed at record level. Read the CANLEX_RESULT line: every item in 'assumed' is a fact " + "the tool assumed because it was not given, and every item in 'needs' " + "is a question that decides the answer -- ask about each before " + "concluding, and never restate branch 'tie' as settled. Otherwise use " + "the three screening tools IN ORDER, carrying each step's CANLEX_RESULT " + "values into the next:\n\n" + + "\n".join(facts) + "\n\n" + "STEP 1 -- canlex_us_disposition (pass the disposition, and the state or country): is " + "this a 'conviction' at all? Plain words ('convicted', 'pled guilty', " + "'sentenced') are a conviction. If the verdict is NO or LIKELY NO, " + "note that the act branch (s. 36(1)(c)/(2)(c)) may still apply.\n" + "STEP 2 -- canlex_us_equivalency (pass the offence, the state or country, the " + "offence date and the status): what Canadian offence does it equate " + "to, and is the branch serious criminality (s. 36(1)(b)) or " + "criminality (s. 36(2)(b))? The maximum is assessed at the offence " + "date (Tran); an attempt takes s. 463. Whether it is under 10 years " + "feeds step 3.\n" + "STEP 3 -- canlex_rehabilitation: if step 1 found a conviction, pass " + "basis='foreign-conviction' with the date the ENTIRE sentence was " + "completed; if step 1 found NO conviction and the act branch is in " + "play, pass basis='foreign-act' with the offence date. Pass the " + "max-penalty answer from step 2, the count, the status and whether " + "the record is otherwise clean.\n\n" + "Then give a single bottom-line assessment with the draft allegation " + "paragraph, every authority cited, every uncertainty and assumption " + "flagged, the next steps (s. 44 report and the removal path, TRP, " + "rehabilitation application, ARC, family members by application type, " + "youth exclusion), and the reminder that this is legal information, " + "not legal advice, and that an officer's determination governs at the " + "port of entry.") + + +@mcp.resource("canlex://grounding-note", + name="CanLex answering instructions", + description="The grounding/citation/hedging instructions " + "prepended to every search result.") +def _res_grounding() -> str: + return GROUNDING_NOTE + + +@mcp.resource("canlex://corpus", + name="CanLex corpus inventory", + description="Everything the corpus contains, grouped by source " + "type, with currency dates.") +def _res_corpus() -> str: + return canlex_list_acts() + + +@mcp.resource("canlex://corpus/{view}", + name="CanLex corpus inventory (one view)", + description="The corpus inventory in one view: 'compact' (one line per " + "source type) or a doc_type ('legislation', 'memorandum', 'caselaw', " + "...) for that source type alone.") +def _res_corpus_view(view: str) -> str: + """The canlex://corpus inventory narrowed (audit follow-up 2026-09-21, + missing 13): 'compact', or one doc_type.""" + kinds = DocType.__args__ + if view == "compact": + return canlex_list_acts(compact=True) + if view in kinds: + return canlex_list_acts(doc_type=view) + return (f"Error: unknown corpus view '{view}'. Use 'compact' or one of: " + + ", ".join(kinds) + ".") + + +@mcp.resource("canlex://methodology/conviction", + name="Foreign disposition conviction framework", + description="The governing framework for whether a foreign " + "(in CanLex's curated data, mostly US) disposition is an IRPA " + "s. 36 'conviction'.") +def _res_conviction_framework() -> str: + data = _load_dispositions() + if data: + for m in data.get("methodology", []): + if m.get("id") == "conviction-framework": + return m["text"] + return "The dispositions dataset is not present in this deployment." + + +@mcp.resource("canlex://methodology/equivalency", + name="Offence equivalency framework (Hill)", + description="The Hill v MEI methodology for equating a foreign " + "offence to a Canadian one, with the s. 36(3)(a) and Tran rules.") +def _res_equivalency_framework() -> str: + data = _load_equivalency() + if data: + for m in data.get("methodology", []): + if m.get("id") == "equivalency-framework": + return m["text"] + return "The equivalency dataset is not present in this deployment." + + +@mcp.resource("canlex://methodology/rehabilitation", + name="Criminal rehabilitation methodology", + description="The two clocks (IRPR s. 17 from completion itself; " + "s. 18(2) and s. 18.1 from the day after), which of s. 17, " + "s. 18(2) and s. 18.1 applies, the permanent-resident rule, the " + "record-suspension route for Canadian convictions and where " + "Guide 5312 departs from the Regulations -- each quotation tied " + "to a corpus chunk id.") +def _res_rehabilitation_methodology() -> str: + from canlex.rehab import methodology_text + return methodology_text() + + +def _utf8_body(body: bytes): + """(body, changed): the request body re-encoded to UTF-8 when it was not. + + The Space's run log showed a real client posting a Latin-1 body -- 'é' + as 0xE9 -- and the MCP transport answering HTTP 500 with a + UnicodeDecodeError, i.e. every accented (French) query from that client + failed. JSON-RPC bodies are text, so a body that is not valid UTF-8 is + re-read as cp1252 (Latin-1's superset on Windows clients) and re-encoded. + """ + try: + body.decode("utf-8") + return body, False + except UnicodeDecodeError: + pass + for enc in ("cp1252", "latin-1"): + try: + return body.decode(enc).encode("utf-8"), True + except UnicodeDecodeError: + continue + return body, False + + +_seen_clients = set() +_SEEN_CLIENTS_MAX = 256 # distinct client tags noted (then the line stops printing) +_CLIENT_TAG_MAX = 80 # characters of a client tag kept + + +def _note_client(body: bytes) -> None: + """One stderr line per distinct MCP client (name + version) at + initialize -- the only way to tell which client sent a malformed body. + Never raises (the code audit of 2026-09-21: a null clientInfo raised out + of the ASGI wrapper and the initialize request got a 500). The tag is + caller-controlled: control characters are dropped (a name could forge a + log line), it is capped at _CLIENT_TAG_MAX characters, and after + _SEEN_CLIENTS_MAX distinct tags nothing more is kept or printed.""" + if b'"initialize"' not in body: + return + try: + params = json.loads(body).get("params") or {} + info = params.get("clientInfo") if isinstance(params, dict) else None + if not isinstance(info, dict): + return + tag = f"{info.get('name', '?')} {info.get('version', '')}".strip() + tag = "".join(ch for ch in tag if ch >= " " and ch != "\x7f")[:_CLIENT_TAG_MAX] + if tag in _seen_clients or len(_seen_clients) >= _SEEN_CLIENTS_MAX: + return + _seen_clients.add(tag) + print(f"CanLex MCP: client {tag}", file=sys.stderr) + except Exception: + return + + +MAX_BODY_BYTES = 1024 * 1024 +# The largest legitimate request is canlex_screen with 20 entries of fields up to +# 600 characters (about 40 KB) or canlex_screen_history's 24,000-character +# entries_json; a body past MAX_BODY_BYTES is refused with 413 before it is +# buffered, decoded or handed to the MCP transport (the code audit of 2026-09-21: +# nothing in uvicorn, Starlette or the transport caps the body, and a 64 MB POST +# was buffered several times over). + + +async def _too_large(send, size): + text = (f"Request body too large: at most {MAX_BODY_BYTES} bytes " + f"({size} received).\n").encode("utf-8") + await send({"type": "http.response.start", "status": 413, + "headers": [(b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(text)).encode()), + (b"connection", b"close")]}) + await send({"type": "http.response.body", "body": text, "more_body": False}) + + +class _Utf8Body: + """ASGI wrapper around the streamable-HTTP app: buffers each POST body up + to MAX_BODY_BYTES (413 past it), re-encodes it to UTF-8 if needed (see + _utf8_body) and logs the client at initialize. Non-POST requests pass + straight through.""" + + def __init__(self, app, max_bytes=None): + self.app = app + self.max_bytes = MAX_BODY_BYTES if max_bytes is None else max_bytes + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("method") != "POST": + await self.app(scope, receive, send) + return + declared = next((v for k, v in scope.get("headers", []) if k.lower() == b"content-length"), + None) + try: + declared = int(declared) if declared is not None else None + except ValueError: + declared = None + if declared is not None and declared > self.max_bytes: + await _too_large(send, declared) + return + chunks, queued, size = [], [], 0 + while True: + message = await receive() + if message["type"] != "http.request": + queued.append(message) # e.g. http.disconnect + break + chunk = message.get("body", b"") + size += len(chunk) + if size > self.max_bytes: + await _too_large(send, size) + return + chunks.append(chunk) + if not message.get("more_body"): + break + body, changed = _utf8_body(b"".join(chunks)) + if changed: + scope = dict(scope) + scope["headers"] = [ + (k, v) for k, v in scope.get("headers", []) + if k.lower() != b"content-length" + ] + [(b"content-length", str(len(body)).encode())] + print("CanLex MCP: re-encoded a non-UTF-8 request body as UTF-8", + file=sys.stderr) + _note_client(body) + replay = [{"type": "http.request", "body": body, + "more_body": False}] + queued + + async def receive_replay(): + if replay: + return replay.pop(0) + return await receive() + + await self.app(scope, receive_replay, send) + + +SESSION_IDLE_SECONDS = 1800.0 +# An MCP session the client never closes lives until the process restarts (mcp +# 1.27.1's FastMCP builds its session manager with no idle timeout), and each +# keeps its initialize params -- an extra 17 MB field, or 41 KB from a plain +# initialize -- so a public endpoint accrues memory it never frees (the code +# audit of 2026-09-21). The manager supports an idle timeout; this builds it +# with one before the app is created. +# +# SESSION_MAX_OPEN (verification fix 2026-09-22, SEC-1): mcp 1.30.0's manager +# refuses every new session with 503 once 10,000 are open, whoever opened them, +# so one unauthenticated client could lock every officer out for the idle +# timeout. None removes the global cap, as in 1.27.2: open sessions are bounded +# by the idle timeout and by the per-client cap on new sessions +# (canlex/ratelimit.py, SessionOpenLimit), on by default over HTTP. +SESSION_MAX_OPEN = None +_TOOL_LIMITER = {} + + +def session_manager_with_timeout(server, seconds=SESSION_IDLE_SECONDS, + max_sessions=SESSION_MAX_OPEN): + """Build the server's StreamableHTTPSessionManager with an idle timeout + and an explicit max_sessions (default None: no global cap), exactly as + FastMCP.streamable_http_app would build it otherwise; a no-op when the + manager already exists. Returns the manager.""" + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + if server._session_manager is None: + server._session_manager = StreamableHTTPSessionManager( + app=server._mcp_server, + event_store=server._event_store, + retry_interval=server._retry_interval, + json_response=server.settings.json_response, + stateless=server.settings.stateless_http, + security_settings=server.settings.transport_security, + session_idle_timeout=seconds, + max_sessions=max_sessions) + return server._session_manager + + +def http_app(server, environ=None): + """The ASGI app the HTTP transport (CANLEX_HTTP) serves: the session + manager with its idle timeout and no global cap, _Utf8Body around the + streamable-HTTP app, then canlex/ratelimit.py's per-client cap on new + sessions (on by default; verification fix 2026-09-22, SEC-1) and its + optional rate limit (off unless CANLEX_RATE_LIMIT_PER_MIN is set). Each + step is best-effort: a failure prints one stderr line and the app is + served without that step.""" + try: + session_manager_with_timeout(server) + print(f"CanLex MCP: idle sessions expire after {SESSION_IDLE_SECONDS:g} s; " + f"no global session cap.", file=sys.stderr) + except Exception as exc: + print(f"CanLex MCP: no session idle timeout ({type(exc).__name__})", + file=sys.stderr) + from canlex import ratelimit + app = _Utf8Body(server.streamable_http_app()) + for wrap, name in ((ratelimit.sessions_from_env, "per-client session cap"), + (ratelimit.from_env, "rate limit")): + try: + app = wrap(app, environ) + except Exception as exc: + print(f"CanLex MCP: no {name} ({type(exc).__name__})", file=sys.stderr) + return app + + +# The curated screening tools (audit follow-up 2026-09-21, optimization 10): +# they read the curated tables, the alias table and small caches that are safe +# for concurrent readers once built (warm_tables builds them at load), each +# call keeps its own step memo, so up to SCREENING_LIMIT of them run at once +# beside a search. Every other tool -- the index tools, whose lazy tables and +# reranker are not proven safe for a second thread -- shares one token. +SCREENING_TOOLS = frozenset({ + "canlex_us_disposition", "canlex_us_equivalency", "canlex_rehabilitation", + "canlex_screen_record", "canlex_screen_history", "canlex_screen", "canlex_transborder"}) +SCREENING_LIMIT = 3 + + +def _limiter_group(name): + """The CapacityLimiter a tool runs under: 'screening' for SCREENING_TOOLS, + 'index' for every other tool (search, sections, the corpus views).""" + return "screening" if name in SCREENING_TOOLS else "index" + + +def offload_sync_tools(server, limit=1, screening_limit=SCREENING_LIMIT): + """Run every synchronous tool off the event loop (the code audit of + 2026-09-21): FastMCP calls a plain-def tool inline, so one slow call + stalled every other client, the notice page and any health probe for its + whole duration. Each sync tool's fn becomes an async wrapper that runs + the original in a worker thread under an anyio.CapacityLimiter: the + screening tools (SCREENING_TOOLS) share one of screening_limit tokens, + every other tool shares one of `limit` (1: a search, a section lookup and + the corpus views still run one at a time; audit follow-up 2026-09-21, + optimization 10 -- tests/test_concurrency.py runs the screening tools + 8 threads at a time against their serial answers). Argument validation + still runs on the loop (Tool.run validates, then calls fn); contextvars + propagate into the thread, so the call log's record and the step memo + keep working. Returns the number of tools offloaded; idempotent.""" + import functools + import anyio + + tokens = {"index": limit, "screening": screening_limit} + + def limiter(group): + # a CapacityLimiter needs a running async backend: made on first use + if group not in _TOOL_LIMITER: + _TOOL_LIMITER[group] = anyio.CapacityLimiter(tokens[group]) + return _TOOL_LIMITER[group] + + count = 0 + for tool in server._tool_manager._tools.values(): + if tool.is_async or getattr(tool.fn, "_canlex_offloaded", False): + continue + original = tool.fn + + async def run(*, _fn=original, _group=_limiter_group(tool.name), **kwargs): + return await anyio.to_thread.run_sync(functools.partial(_fn, **kwargs), + limiter=limiter(_group)) + run._canlex_offloaded = True + run._canlex_original = original + tool.fn = run + tool.is_async = True + count += 1 + return count + + +def warm_tables(index_tables=False): + """Build every lazily built table the tools read before the first call + (audit follow-up 2026-09-21, optimization 10), so that concurrent + screening calls only ever read them: the curated datasets and the alias + table (installed once; load_aliases is a no-op while it is unchanged), the + disposition-name folds, the held manifest, the alias keys, and -- with + index_tables, as the HTTP transport asks -- the loaded index's typo and + trigram tables when the index offers them (the stdio server keeps them + lazy: one client, and a few seconds of start-up saved). Best-effort and + idempotent: returns the names built; a failure is printed to stderr and + never raises.""" + from canlex import screening + built = [] + steps = [("equivalency", _load_equivalency), ("dispositions", _load_dispositions), + ("alias table", screening._alias_table), ("alias keys", _alias_keys_read), + ("held manifest", _held_manifest), ("disposition names", _warm_disposition_names)] + if index_tables and _INDEX is not None: + for name, args in (("_word_index", ("en",)), ("_word_index", ("all",)), + ("_fuzzy_term", ("warmup",)), ("_acts_named", ("",))): + fn = getattr(_INDEX, name, None) + if callable(fn): + steps.append((f"index {name}{args}", functools.partial(fn, *args))) + for name, fn in steps: + try: + fn() + built.append(name) + except Exception as exc: # never fails startup + print(f"CanLex MCP: could not prebuild {name} ({type(exc).__name__})", + file=sys.stderr) + return built + + +def _warm_disposition_names(): + """Fold every curated disposition name once (_disp_fold is memoised).""" + for e in (_load_dispositions() or {}).get("dispositions", []): + for name in _entry_names(e): + _disp_fold(name) + + +if __name__ == "__main__": + try: + idx = _index() + print(f"CanLex MCP: {len(idx.chunks)} sections loaded.", file=sys.stderr) + except Exception as exc: # surfaced in the MCP client's server logs + print(f"CanLex MCP: failed to load legislation index: {exc}\n" + f"Run 'py -m canlex.ingest' first.", file=sys.stderr) + sys.exit(1) + # the lazy tables, built before the first call (audit follow-up 2026-09-21): + # concurrent screening calls then only read them + built = warm_tables(index_tables=bool(os.environ.get("CANLEX_HTTP"))) + print(f"CanLex MCP: prebuilt {len(built)} lazy tables.", file=sys.stderr) + # The call log (2026-09-16): records every tool call when CANLEX_LOG_DIR, + # or CANLEX_LOG_DATASET with CANLEX_LOG_TOKEN, is set; otherwise nothing is + # wrapped and nothing is logged. Best-effort: it never fails startup. + try: + from canlex import calllog + calllog.install(mcp) + except Exception as exc: + print(f"CanLex MCP: call log not started ({type(exc).__name__})", + file=sys.stderr) + # CANLEX_HTTP switches on the remote (web) transport; default is local stdio. + if os.environ.get("CANLEX_HTTP"): + import uvicorn + try: + n = offload_sync_tools(mcp) + print(f"CanLex MCP: {n} tools run off the event loop: the screening tools " + f"{SCREENING_LIMIT} at a time, the others one at a time.", file=sys.stderr) + except Exception as exc: + print(f"CanLex MCP: tools stay on the event loop ({type(exc).__name__})", + file=sys.stderr) + app = http_app(mcp) + print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr) + uvicorn.run(app, host=mcp.settings.host, port=mcp.settings.port, + log_level=mcp.settings.log_level.lower()) + else: + mcp.run()