diff --git "a/canlex/rehab.py" "b/canlex/rehab.py" new file mode 100644--- /dev/null +++ "b/canlex/rehab.py" @@ -0,0 +1,3657 @@ +"""Criminal-rehabilitation assessment under IRPA s. 36(3)(c) and IRPR ss. 17-18.1. + +Pure rule logic, verified against the corpus text of IRPR ss. 17, 18 and 18.1 +(SOR-2002-227, current consolidation), IRPA ss. 36 and 64 and Interpretation +Act ss. 28 and 37. Every statutory phrase the rules rely on is pinned to the +corpus by tests/test_rehab.py and tests/test_rehab_properties.py. The rules: + + Deemed rehabilitation (IRPR s. 18(2)) -- automatic, no application: + (a) ONE foreign conviction, indictable-equivalent, punishable in Canada by + a maximum of LESS THAN 10 years: 10 years after the day after sentence + completion, with the cleanliness conditions (i)-(vii). + (b) TWO OR MORE foreign convictions that would all be summary offences: + 5 years after the day after the sentences were completed, with + conditions. + (c) ONE committed act (s. 36(1)(c)/(2)(c)), <10-year maximum: 10 years + after the day after the commission, with conditions. + Serious criminality (>=10-year maximum equivalent) can NEVER be deemed + rehabilitated -- only an individual application. + + Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17): + eligible to APPLY five years "after the completion of an imposed sentence" + (conviction branches) or "after committing an offence" (act branches) -- + s. 17's words, which unlike s. 18(2) do not say "the day after", so the + application date is the fifth anniversary of completion itself. Available + for serious criminality too. BUT s. 17 prescribes that period only "if the + person has not been convicted of a subsequent offence" (other than a + contravention or a young-offender matter): a later conviction restarts the + clock from completion of ITS sentence. The tool cannot tell whether the + other matters on a record pre- or post-date the one being screened, so + whenever the record is not clean it reports 'apply-conditional' and says + what decides it, rather than affirming an eligibility s. 17 may deny. + + One computation per clock (roadmap T1.12): every status is decided by + comparing the assessment date with the very date the answer prints + (add_years, which follows Interpretation Act ss. 37(1) and 28(c) on + 29 February), and the elapsed figure shown is the clock of the provision + reported. + + Act branch, summary-only acts: IRPA s. 36(1)(c)/(2)(c) reach only an act + that would be an INDICTABLE offence in Canada. There is no two-summary-acts + rule -- that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)) -- so two + or more summary-only committed acts ground no criminal inadmissibility and + there is nothing to rehabilitate. + + Permanent residents: s. 36(2) -- criminality, including its two-offences + limb -- reaches foreign nationals only. Under s. 36 a permanent resident + is inadmissible only under s. 36(1), so any number of sub-10-year foreign + matters grounds nothing against a PR under s. 36 and there is nothing to + rehabilitate; the caveat says to check every matter for a 10-year + equivalent. (Organized criminality, s. 37, reaches permanent residents + too; it is a separate ground this module does not assess.) + + In-Canada convictions (roadmap T1.8) are outside the rehabilitation scheme + entirely -- s. 36(3)(c) lists only (1)(b),(c) and (2)(b),(c) -- and the + cure is a record suspension (IRPA s. 36(3)(b)): + - s. 36(1)(a), for PR and FN alike: a maximum of at least 10 years, or a + term of imprisonment of MORE than six months imposed. + - s. 36(2)(a), foreign nationals only: an offence punishable by way of + indictment -- a hybrid offence is deemed indictable even if prosecuted + summarily (s. 36(3)(a)) -- or two offences not arising out of a single + occurrence. + - IRPR s. 18.1: a class of FOREIGN NATIONALS inadmissible solely for two + or more in-Canada convictions for offences that 'may only be + prosecuted summarily', exempt from s. 36(2)(a) five years after the day + after the sentences were completed. Never a permanent resident; never + a hybrid offence. + - A PR's appeal to the IAD (s. 64(2)) turns on a DIFFERENT threshold for + a Canadian conviction: 'punished in Canada by a term of imprisonment of + at least six months'. + canada_offence_mode and custody_imposed_months are optional; when a rule + needs one that is missing, the answer keeps its footing but prints the + condition it assumed and lists the input in `needs`. +""" +import datetime as _dt +import itertools +import re + +# The unit table screening reads a WRITTEN sentence with. The Canadian outcome allowlists +# build their custodial vocabulary from it (K1, 2026-09-23) instead of repeating a +# hand-written list, so a term in weeks cannot be read by the sentence reader and missed by +# the outcome reader. (canlex.screening imports nothing from this module, so the import is +# not a cycle; every other use of screening here is still deferred to call time.) +from .screening import _SENT_UNITS as _SCREENING_SENT_UNITS + +BASES = ("foreign-conviction", "foreign-act", "canada-conviction") +COUNTS = ("one", "two-plus-summary-only", "multiple") +STATUSES = ("foreign-national", "permanent-resident") +# How a Canadian offence may be prosecuted. IRPA s. 36(3)(a) deems a hybrid +# offence indictable 'even if it has been prosecuted summarily'; IRPR +# s. 18.1 reaches only offences 'that may only be prosecuted summarily'. +CANADA_MODES = ("summary-only", "hybrid", "indictable") + +# The exception clause exactly as IRPR s. 18(2) words it. tests/test_rehab.py +# checks that a rendered limb carries it exactly when the corpus limb does. +EXCEPT_CONTRAVENTION_YCJA = ( + "other than an offence designated as a contravention under the " + "Contraventions Act or an offence under the Youth Criminal Justice Act") +_X = EXCEPT_CONTRAVENTION_YCJA + +# The cleanliness conditions of each deeming paragraph, in the Regulations' +# own order, so the answer names the limbs a record can fail. +_CONDITIONS = { + "IRPR s. 18(2)(a)": [ + "(i) the offence is punishable in Canada by a maximum of less than 10 years", + "(ii) at least 10 years have elapsed since the day after the completion of the imposed sentence", + "(iii) no conviction in Canada of an indictable offence, ever", + "(iv) no conviction in Canada of a summary conviction offence within the last 10 years, and no more than one before the last 10 years, " + _X, + "(v) no conviction OUTSIDE Canada within the last 10 years of an offence that would be an offence under an Act of Parliament (summary equivalents included), " + _X, + "(vi) no more than one conviction outside Canada, before the last 10 years, of an offence that would be a summary conviction offence", + "(vii) no committed act described in s. 36(2)(c) other than the one convicted of", + ], + "IRPR s. 18(2)(b)": [ + "(i) at least five years have elapsed since the day after the completion of the imposed sentences", + "(ii) no conviction in Canada of an indictable offence, ever", + "(iii) no conviction in Canada within the last five years of an offence under an Act of Parliament, " + _X, + "(iv) no conviction outside Canada within the last five years of an offence that would be an offence under an Act of Parliament, " + _X, + "(v) no more than one conviction in Canada, before the last five years, of a summary conviction offence under an Act of Parliament, " + _X, + "(vi) no conviction of an offence referred to in s. 36(2)(b) that would be an INDICTABLE offence -- any indictable-equivalent foreign conviction, ever, defeats this paragraph", + "(vii) no committed act described in s. 36(2)(c)", + ], + "IRPR s. 18(2)(c)": [ + "(i) the offence is punishable in Canada by a maximum of less than 10 years", + "(ii) at least 10 years have elapsed since the day after the commission of the offence", + "(iii) no conviction in Canada of an indictable offence, ever", + "(iv) no conviction in Canada of a summary conviction offence within the last 10 years, and no more than one before the last 10 years, " + _X, + "(v) no conviction outside Canada within the last 10 years of an offence that would be an offence under an Act of Parliament, " + _X, + "(vi) no more than one conviction outside Canada, before the last 10 years, of an offence that would be a summary conviction offence", + "(vii) no conviction outside Canada, ever, of an offence that would be an INDICTABLE offence under an Act of Parliament", + ], +} + +_TRP_CAVEAT = ( + "A temporary resident permit (IRPA s. 24) may be available to a foreign " + "national regardless of rehabilitation status -- at the officer's " + "discretion and subject to the s. 24(3.1)-(5) bars (e.g. a failed " + "refugee claimant within 12 months of the claim's rejection).") + +# Printed with every in-Canada answer: what the six-month limb counts, and +# the points CanLex does not settle (roadmap DECISIONS, interpretive rule c). +CUSTODY_CAVEAT = ( + "IRPA s. 36(1)(a) counts an offence 'for which a term of imprisonment of " + "more than six months has been imposed'. A conditional sentence is not a " + "term of imprisonment (Tran v. Canada (Public Safety and Emergency " + "Preparedness), 2017 SCC 50, in the CanLex case-law corpus). Whether an " + "intermittent sentence, or pre-sentence custody credited at sentencing, " + "counts toward the six months is NOT settled in CanLex: where it decides " + "the answer, read it both ways and record which reading the report " + "relies on.") + +# Guide 5312 departs from the Regulations on one point the tool meets. +GUIDE_5312_DIVERGENCE = ( + "Guide 5312 (IRCC's applicant guide, policy-imm-5312-11) says of two or " + "more summary-equivalent foreign convictions 'You are eligible to apply " + "for rehabilitation: not applicable'. The Regulations do not exclude " + "them: IRPA s. 36(3)(c) reaches s. 36(2)(b) matters and IRPR s. 17(a) " + "prescribes five years 'after the completion of an imposed sentence' for " + "them, so this answer gives the statutory application date. On a clean " + "record the two differ by one day (s. 18(2)(b) deems rehabilitation the " + "day after that date); the difference matters where a s. 18(2)(b) " + "condition fails and an application is the only route. Treat the Guide's " + "'not applicable' as a point to confirm with IRCC, not as the " + "Regulations.") + + +def conditions_for(paragraph): + """The (i)-(vii) limbs of a deeming paragraph, or [] for other provisions.""" + for key, limbs in _CONDITIONS.items(): + if paragraph.startswith(key): + return limbs + return [] + + +def _months(n): + return f"{n:g} month{'' if n == 1 else 's'}" + + +def _pr_appeal(lt10, custody): + """(iad_appeal, note) for a permanent resident's Canadian conviction. + IRPA s. 64(1) bars an appeal to the Immigration Appeal Division for + serious criminality, and s. 64(2) confines that to 'a crime that was + punished in Canada by a term of imprisonment of at least six months' (or + a s. 36(1)(b)/(c) matter) -- 'at least', where s. 36(1)(a) says 'more + than'. iad_appeal: 'barred', 'not-barred-by-s64', 'depends' or None.""" + by_max = not lt10 + if custody is None: + if by_max: + return "depends", ( + "Serious criminality on the 10-year limb of s. 36(1)(a). " + "Whether the permanent resident keeps an appeal to the " + "Immigration Appeal Division turns on the sentence: s. 64(2) " + "brings a Canadian conviction under the s. 64(1) bar only for " + "a crime 'punished in Canada by a term of imprisonment of at " + "least six months'. Give custody_imposed_months.") + return None, None + if custody > 6 or (by_max and custody >= 6): + return "barred", ( + "No appeal to the Immigration Appeal Division (IRPA s. 64(1)): " + "the crime was 'punished in Canada by a term of imprisonment of " + f"at least six months' (s. 64(2); {_months(custody)} imposed).") + if by_max: + return "not-barred-by-s64", ( + "Serious criminality on the 10-year limb of s. 36(1)(a), but " + f"{_months(custody)} is less than the 'at least six months' " + "s. 64(2) requires of a crime punished in Canada, so the s. 64(1) " + "bar on an appeal to the Immigration Appeal Division does not " + "apply.") + if custody == 6: + return None, ( + "Exactly six months was imposed. That is not 'more than six " + "months', so the six-month limb of s. 36(1)(a) is not met. " + "s. 64(2) uses a different threshold for the appeal bar -- 'at " + "least six months', which six months meets -- but it bars an " + "appeal only where serious criminality is found, and with a " + "maximum below 10 years it is not found here.") + return None, None + + +def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record, + status="foreign-national", completed=None, today=None, + canada_offence_mode=None, custody_imposed_months=None, + apply_from=None, solely=None): + """Assess rehabilitation status. Returns a dict: + {status, provision, explanation, caveats, elapsed, clock, elapsed_by_clock, + earliest_deemed, earliest_apply, warnings, conditions, needs, iad_appeal, + appeal_note}. + + status: 'foreign-national' or 'permanent-resident'. + completed / today: datetime.date. When `completed` (the day the entire + sentence was completed, or the act was committed) is given, the + elapsed years are computed here -- full years from the day after + for the s. 18(2)/18.1 tests, from completion itself for s. 17 -- and + the answer carries the earliest dates on which deemed rehabilitation + and application eligibility are reached, so the client never does + date arithmetic. `elapsed` is the figure for the clock of the + provision reported (`clock`: 's17', 'day-after' or None when no + clock applies); `elapsed_by_clock` carries both. + + basis: how the inadmissibility arises (BASES). + max_penalty_lt_10: True if the equivalent Canadian offence's maximum term + is under 10 years (False = serious criminality territory). None + raises ValueError: an unknown maximum is the caller's to run both ways. + count: 'one' | 'two-plus-summary-only' | 'multiple' (COUNTS). + years_elapsed: full years since sentence completion (conviction bases) + or since commission (act basis), used only when no date is given. + clean_record: True if there are no other convictions or committed acts + (the s. 18(2) (i)-(vii) conditions, simplified -- see caveats). + canada_offence_mode: for basis 'canada-conviction' only -- 'summary-only' + (may only be prosecuted summarily), 'hybrid' or 'indictable' + (CANADA_MODES), or None when unknown. + custody_imposed_months: for basis 'canada-conviction' only -- the term of + imprisonment imposed, in months (0 = none), or None when unknown. + solely: for basis 'canada-conviction' only -- False when the record holds + another ground, so IRPR s. 18.1 ('inadmissible solely on the basis + of') cannot apply (canlex_screen_history reads it limb by limb, + roadmap T2.3); None keeps the clean_record condition text. + """ + from .screening import add_years, date_warnings, full_years, years_since + if max_penalty_lt_10 is None: + # audit follow-up 2026-09-21 (engine-info-trio): None read as 'not under + # 10 years' -- serious criminality asserted on a maximum nobody knew + raise ValueError("max_penalty_lt_10 must be True or False; an unknown maximum " + "is run both ways by the caller, never passed as None") + today = today or _dt.date.today() + warnings = date_warnings(completed=completed, today=today) + years_elapsed = years_elapsed or 0 + if completed is not None: + day_after = completed + _dt.timedelta(days=1) + # One computation per clock (roadmap T1.12). A year is 'any period of + # twelve consecutive months' (Interpretation Act s. 37(1), I-21-s37) + # and a period of months ends on the day with the same number 'or, if + # that month has no day with that number, the last day of that month' + # (s. 28(c), I-21-s28); add_years does exactly that, so five years + # after 2016-02-29 is 2021-02-28. Each status below is decided by + # comparing `today` with the date add_years gives -- eligible to apply + # iff today >= add_years(completed, 5), the date printed -- so the + # date and the status agree on every day. s. 17 counts from the + # completion itself; s. 18(2) and s. 18.1 from the day after. + # apply_from: a LATER completion that restarts the s. 17 clock -- the + # sentence for a subsequent conviction (s. 17 excepts only + # contraventions and Young Offenders Act offences; roadmap T1.9). The + # deeming clocks stay on `completed`. + apply_start = max(completed, apply_from) if apply_from else completed + apply_on = add_years(apply_start, 5) + years_elapsed = full_years(completed, today) # day-after clock + years_apply = years_since(apply_start, today) # s. 17 clock + can_apply = today >= apply_on + + def reached(need): + return today >= add_years(day_after, need) + + def deemed_date(need): + return add_years(day_after, need).isoformat() if need else None + else: + apply_on = None + years_apply = years_elapsed + can_apply = years_elapsed >= 5 + + def reached(need): + return years_elapsed >= need + + def deemed_date(need): + return None + apply_iso = apply_on.isoformat() if apply_on else None + + def res(status_, provision, explanation, cav, clock=None, deemed=None, + apply=None, **extra): + out = {"status": status_, "provision": provision, + "explanation": explanation, "caveats": cav, + "elapsed": (years_apply if clock == "s17" else + years_elapsed if clock == "day-after" else None), + "clock": clock, + "elapsed_by_clock": {"s17": years_apply, + "day-after": years_elapsed}, + "earliest_deemed": deemed, "earliest_apply": apply, + "warnings": warnings, "conditions": [], "needs": [], + "iad_appeal": None, "appeal_note": None} + out.update(extra) + return out + + caveats = [ + _TRP_CAVEAT, + "Time runs from the day AFTER completion of the ENTIRE sentence -- " + "including probation, fines and driving prohibitions -- for the " + "s. 18(2) deeming tests; the s. 17 application period runs five " + "years 'after the completion of an imposed sentence'.", + "IRPR s. 17 prescribes the five-year application period only 'if the " + "person has not been convicted of a subsequent offence' (other than a " + "contravention or a young-offender matter): where a later conviction " + "exists, the five years run from completion of the sentence for the " + "MOST RECENT conviction, not from the matter being screened.", + ] + + if basis == "canada-conviction": + return _assess_canada(res, max_penalty_lt_10, count, clean_record, + status, canada_offence_mode, + custody_imposed_months, reached, deemed_date, + years_elapsed, solely) + + since = ("sentence completion" if basis == "foreign-conviction" + else "the commission of the act") + # The s. 17 clock's start, in words: a later conviction's completion when + # apply_from moved it (roadmap T1.9). + since_apply = (since if not (completed is not None and apply_from + and apply_from > completed) + else "the completion of the sentence for the most recent " + "(subsequent) conviction") + + def _with_conditions(para, extra=()): + limbs = conditions_for(para) + if not limbs: + return caveats + list(extra) + return [f"The 'clean record' answer stands in for every condition of " + f"{para.split(';')[0]}; each must hold: " + "; ".join(limbs) + + ". Confirm each against the full record before relying on " + "deemed rehabilitation."] + caveats + list(extra) + + def _application(provision, lead, need=None, extra=()): + """Eligibility to apply under s. 17 -- which a subsequent conviction + defeats. The record is described by one bool, so a dirty record is + reported as conditional and the deciding fact is named.""" + deemed_on = deemed_date(need) + cav = _with_conditions(provision, extra) + if clean_record: + return res("eligible-to-apply", provision, + f"{lead} at least five years have passed since {since_apply}, " + "so the person is eligible to APPLY for individual " + "rehabilitation.", cav, "s17", deemed_on, apply_iso) + return res("apply-conditional", provision, + f"{lead} five years have passed since {since_apply} -- but the " + "record is not clean, and IRPR s. 17 prescribes the " + "five-year period only 'if the person has not been " + "convicted of a subsequent offence'. If any other " + "conviction POSTDATES this matter, eligibility to apply " + "runs from completion of the sentence for the MOST RECENT " + "conviction: recompute from that date. If every other " + "matter PREDATES this one, the person is eligible to apply " + "now.", cav, "s17", deemed_on, apply_iso) + + if status == "permanent-resident" and max_penalty_lt_10: + return res("not-inadmissible-pr", "IRPA s. 36(1)(b)/(c); s. 36(2)", + "The equivalent Canadian offence carries a maximum below " + "10 years, so this matter is at most CRIMINALITY -- and " + "IRPA s. 36(2), including its two-offences limb, applies " + "to foreign nationals only. Under s. 36 a permanent " + "resident is inadmissible only under s. 36(1) (serious " + "criminality), so however many sub-10-year matters the " + "record holds there is nothing to rehabilitate under s. 36 " + "(organized criminality, s. 37, is a separate ground not " + "assessed here). Check every other matter on the record for " + "a 10-year equivalent, and for the act branch note " + "s. 36(3)(d): a permanent resident's committed-act finding " + "requires the balance of probabilities.", caveats[1:]) + + if basis == "foreign-act" and count == "two-plus-summary-only": + return res("not-inadmissible", "IRPA s. 36(2)(c)", + "A committed act grounds inadmissibility only where it " + "would be an INDICTABLE offence in Canada (IRPA " + "s. 36(1)(c)/(2)(c)); there is no two-summary-acts rule -- " + "that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)). " + "Two or more summary-only committed acts ground no " + "criminal inadmissibility, so there is nothing to " + "rehabilitate. Verify that none of the acts has an " + "indictable (including hybrid) Canadian equivalent.", + caveats) + + if not max_penalty_lt_10: + # Serious criminality: deemed rehabilitation is never available. + if can_apply: + return _application( + "IRPA s. 36(3)(c); IRPR s. 17", + "The equivalent Canadian offence carries a maximum of 10 " + "years or more (serious criminality), so deemed " + "rehabilitation is NOT available --") + return res("not-yet", "IRPR s. 17", + "Serious criminality: deemed rehabilitation is not " + "available, and the five-year eligibility period to apply " + f"for individual rehabilitation has not yet run " + f"({years_apply} of 5 years since {since_apply}).", caveats, + "s17", None, apply_iso) + + if count == "multiple": + if can_apply: + return _application( + "IRPA s. 36(3)(c); IRPR s. 17", + "More than one indictable-equivalent conviction/act: deemed " + "rehabilitation is not available (IRPR s. 18(2) covers only " + "a single such conviction or act), but") + return res("not-yet", "IRPR s. 17", + "Multiple indictable-equivalent matters: only individual " + "rehabilitation is possible, and its five-year eligibility " + f"period has not run ({years_apply} of 5 years).", caveats, + "s17", None, apply_iso) + + extra = () + if count == "two-plus-summary-only": + para = "IRPR s. 18(2)(b)" + need = 5 + if basis == "foreign-conviction": + extra = (GUIDE_5312_DIVERGENCE,) + elif basis == "foreign-act": + para = "IRPR s. 18(2)(c)" + need = 10 + else: + para = "IRPR s. 18(2)(a)" + need = 10 + + deemed_on = deemed_date(need) + cav = _with_conditions(para, extra) + if reached(need) and clean_record: + return res("deemed-rehabilitated", para, + f"All {para} requirements appear met: {years_elapsed} years " + f"since the day after {since} (needs {need}), a qualifying " + "single/summary profile, and a clean record. The person is " + "deemed rehabilitated -- the matter cannot ground " + "inadmissibility under IRPA s. 36(3)(c).", cav, "day-after", + deemed_on, apply_iso) + if can_apply: + why = (f"only {years_elapsed} of {need} years have run since the day " + f"after {since}" if not reached(need) else + "the record is not clean, which defeats deemed rehabilitation") + return _application( + f"{para}; IRPR s. 17", + f"Deemed rehabilitation is not (yet) made out ({why}), but", need, + extra) + return res("not-yet", f"{para}; IRPR s. 17", + f"Neither deemed rehabilitation ({years_elapsed} of {need} " + f"years since the day after {since}) nor eligibility to apply " + f"({years_apply} of 5 years since {since_apply}) has been reached.", + cav, "s17", deemed_on, apply_iso) + + +def _assess_canada(res, lt10, count, clean_record, status, mode, custody, + reached, deemed_date, years_elapsed, solely=None): + """An in-Canada conviction (roadmap T1.8), from the corpus text of IRPA + ss. 36(1)(a), 36(2)(a), 36(3)(a)-(c) and 64(2) and IRPR s. 18.1.""" + pr = status == "permanent-resident" + over_six = custody is not None and custody > 6 + serious = (not lt10) or over_six + cav = ([] if pr else [_TRP_CAVEAT]) + [CUSTODY_CAVEAT] + conditions, needs = [], [] + if custody is None and lt10: + needs.append("custody_imposed_months") + conditions.append( + "IRPA s. 36(1)(a) also reaches an offence 'for which a term of " + "imprisonment of more than six months has been imposed'. This " + "answer assumes no such term was imposed; give " + "custody_imposed_months to settle it.") + cure = CANADA_CURE + + def out(status_, provision, explanation, clock=None, deemed=None, + canada_branch=None, canada_limbs=(), **kw): + # canada_branch / canada_limbs name the s. 36 limb the rule applied + # (roadmap T2.2): the screening tools report them, and + # canlex_rehabilitation ignores them. + r = res(status_, provision, explanation, cav, clock, deemed, None, + canada_branch=canada_branch, canada_limbs=list(canada_limbs), **kw) + r["conditions"] = conditions + r["conditions"] + r["needs"] = needs + r["needs"] + return r + + appeal, appeal_note = (_pr_appeal(lt10, custody) if pr else (None, None)) + if appeal == "depends": + needs.append("custody_imposed_months") + + if serious: + limbs, keys = [], [] + if not lt10: + limbs.append("the offence is punishable by a maximum term of " + "imprisonment of at least 10 years") + keys.append("10-year") + if over_six: + limbs.append(f"a term of imprisonment of more than six months " + f"has been imposed ({_months(custody)})") + keys.append("six-month") + return out("record-suspension-route", "IRPA s. 36(1)(a); s. 36(3)(b)", + "Serious criminality under IRPA s. 36(1)(a), which reaches " + "permanent residents and foreign nationals alike: " + + " and ".join(limbs) + ". " + cure, + canada_branch="36(1)(a)", canada_limbs=keys, + iad_appeal=appeal, appeal_note=appeal_note) + + if pr: + custody_txt = ("no term of imprisonment was given" if custody is None + else f"{_months(custody)} is not more than six months") + return out("not-inadmissible-pr", "IRPA s. 36(1)(a); s. 36(2)", + "A permanent resident is inadmissible for a Canadian " + "conviction only under IRPA s. 36(1)(a): an offence " + "punishable by a maximum of at least 10 years, or one for " + "which a term of imprisonment of more than six months has " + f"been imposed. The maximum is below 10 years and " + f"{custody_txt}. s. 36(2)(a) -- indictable, hybrid and " + "two-offence convictions -- reaches foreign nationals " + "only, and IRPR s. 18.1 is a class of foreign nationals. " + "On this conviction s. 36 does not reach the person and " + "there is nothing to cure. Check every other matter on the " + "record for a s. 36(1) ground.", + canada_branch="none", + iad_appeal=appeal, appeal_note=appeal_note) + + # Foreign national, no s. 36(1)(a) ground. + if mode in ("hybrid", "indictable"): + why = ("a hybrid offence 'is deemed to be an indictable offence, even " + "if it has been prosecuted summarily' (s. 36(3)(a))" + if mode == "hybrid" else "the offence is indictable") + not_181 = (" IRPR s. 18.1 does not apply: it covers only offences " + "that may only be prosecuted summarily." + if count == "two-plus-summary-only" else "") + return out("record-suspension-route", "IRPA s. 36(2)(a); s. 36(3)(b)", + "Criminality under IRPA s. 36(2)(a): convicted in Canada " + "of an offence under an Act of Parliament punishable by way " + f"of indictment -- {why}." + not_181 + " " + cure, + canada_branch="36(2)(a)") + + if mode == "summary-only" and count == "one": + return out("not-inadmissible", "IRPA s. 36(1)(a); s. 36(2)(a)", + "One Canadian conviction for an offence that may only be " + "prosecuted summarily is not an offence 'punishable by way " + "of indictment', and the second limb of s. 36(2)(a) needs " + "'two offences under any Act of Parliament not arising out " + "of a single occurrence'; with a maximum below 10 years " + "s. 36(1)(a) is not met either. This conviction grounds no " + "s. 36 inadmissibility on its own. A second Canadian " + "conviction would engage s. 36(2)(a) (and IRPR s. 18.1 if " + "both offences may only be prosecuted summarily).", + canada_branch="none") + + if mode is None and count != "two-plus-summary-only": + if count == "one": + needs.append("canada_offence_mode") + conditions.append( + "If the offence may only be prosecuted summarily, one " + "conviction is neither an offence 'punishable by way of " + "indictment' nor 'two offences' under s. 36(2)(a), and it " + "grounds no s. 36 inadmissibility on its own. A hybrid " + "offence counts as indictable even if prosecuted summarily " + "(s. 36(3)(a)). Give canada_offence_mode to settle it.") + provision = "IRPA s. 36(3)(b)" + else: + provision = "IRPA s. 36(2)(a); s. 36(3)(b)" + return out("record-suspension-route", provision, + "Rehabilitation does not apply to in-Canada convictions. " + "The cure is a record suspension under the Criminal Records " + "Act (IRPA s. 36(3)(b)); once ordered and not revoked, the " + "conviction cannot ground inadmissibility.", + canada_branch=None if count == "one" else "36(2)(a)") + + # Not solely on these convictions (roadmap T2.3: the record's other entries, + # read limb by limb): IRPR s. 18.1 cannot apply, whatever the mode. + if solely is False: + return out("record-suspension-route", "IRPA s. 36(2)(a); s. 36(3)(b)", + "Criminality under IRPA s. 36(2)(a): two offences under an Act of " + "Parliament not arising out of a single occurrence. IRPR s. 18.1 does " + "not apply: it covers a foreign national inadmissible SOLELY on the " + "basis of convictions in Canada for offences that may only be " + "prosecuted summarily, and another matter on the record is itself a " + "ground of inadmissibility. " + cure, + canada_branch="36(2)(a)-two-offences") + + # IRPR s. 18.1: two or more summary-only convictions (mode given), or the + # count says so and the mode is unknown (the condition is printed). + if mode is None: + needs.append("canada_offence_mode") + conditions.append( + "IRPR s. 18.1 covers only 'offences that may only be prosecuted " + "summarily'. A hybrid offence is deemed indictable 'even if it has " + "been prosecuted summarily' (IRPA s. 36(3)(a)), so two summarily " + "prosecuted hybrid convictions engage s. 36(2)(a) and take the " + "record-suspension route instead. Give canada_offence_mode to " + "settle it.") + elif count == "multiple": + conditions.append( + "count 'multiple' was read as two or more convictions for " + "offences that may only be prosecuted summarily, because " + "canada_offence_mode is 'summary-only'.") + if not clean_record: + conditions.append( + "IRPR s. 18.1 covers a foreign national inadmissible SOLELY on " + "these convictions, and the record is not clean: check that no " + "other matter is itself a ground of inadmissibility.") + exempt_on = deemed_date(5) + lead = ("A foreign national inadmissible solely for two or more in-Canada " + "convictions for offences that may only be prosecuted summarily " + "(not arising out of a single occurrence -- two offences that do " + "arise out of one occurrence do not engage s. 36(2)(a) at all)") + if reached(5): + return out("exempt", "IRPR s. 18.1", + f"{lead}, and at least five years have passed since the day " + "after the sentences were completed: the person is in the " + "prescribed class exempt from IRPA s. 36(2)(a).", + "day-after", exempt_on, canada_branch="s18.1-exempt") + return out("not-yet", "IRPR s. 18.1", + f"{lead} is exempt from IRPA s. 36(2)(a) five years after the " + f"day after the sentences were completed ({years_elapsed} " + "elapsed). Until then the cure is a record suspension under " + "the Criminal Records Act (s. 36(3)(b)).", + "day-after", exempt_on, canada_branch="36(2)(a)-two-offences") + + +# ---- Canadian convictions in the screening tools (roadmap T2.2) --------- +# +# canlex_screen_record and canlex_screen_history screen a Canadian entry +# (country 'Canada', a province as the state, or jurisdiction 'canada') on +# IRPA s. 36(1)(a)/(2)(a). assess_canadian settles what comes BEFORE the T1.8 +# rules (is there a conviction at all; does s. 36(3)(b) or (3)(e) take it out; +# what counts as custody) and then runs those rules through assess() -- it +# never restates them. Where an input the answer turns on is missing (the +# maximum, the mode, the custody), it runs every value and reports each +# reading instead of assuming one (governing principle 5). Every statutory +# phrase quoted below is pinned by tests/test_canada_youth_finality.py. + +# The cure for a Canadian conviction, as _assess_canada has always worded it. +CANADA_CURE = ("Rehabilitation under IRPA s. 36(3)(c) does not reach it (s. " + "36(3)(c) lists only the matters in s. 36(1)(b),(c) and " + "(2)(b),(c)); the cure is a record suspension under the Criminal " + "Records Act -- once ordered and not revoked, the conviction " + "cannot ground inadmissibility (s. 36(3)(b)).") + +# What a Canadian sentence or outcome can be. A conditional sentence is not a +# term of imprisonment (Tran); an intermittent sentence is not settled; a +# discharge is not a conviction; a contravention and a youth sentence are +# outside s. 36 (s. 36(3)(e)). +CANADA_SENTENCES = ("custody", "intermittent", "conditional-sentence", + "suspended-sentence", "probation", "fine", + "absolute-discharge", "conditional-discharge", + "youth-sentence", "contravention") +_NO_CUSTODY = ("suspended-sentence", "probation", "fine") + +DISCHARGE_TEXT = ( + "Not a conviction: a Canadian absolute or conditional discharge is directed " + "'instead of convicting the accused', and the offender 'shall be deemed not " + "to have been convicted of the offence' (Criminal Code s. 730(1), (3)). IRPA " + "s. 36(1)(a) and (2)(a) need a person 'convicted in Canada', and s. 36(1)(c) " + "and (2)(c) reach only 'committing an act outside Canada', so this matter " + "grounds no s. 36 inadmissibility. If the discharge was later revoked and a " + "conviction entered (s. 730(4): the court may 'revoke the discharge, convict " + "the offender'), re-screen it as a conviction.") +NOT_CONVICTED_TEXT = ( + "No conviction: IRPA s. 36(1)(a) and (2)(a) need a person 'convicted in " + "Canada', and the act branches, s. 36(1)(c) and (2)(c), reach only " + "'committing an act outside Canada' -- a Canadian charge that did not end " + "in a conviction grounds no s. 36 inadmissibility. (An offence committed ON " + "ENTERING Canada is transborder criminality, s. 36(2.1): screen it with " + "canlex_transborder.)") +CONTRAVENTION_TEXT = ( + "Not a ground: IRPA s. 36(3)(e) -- inadmissibility 'may not be based on an " + "offence' '(i) designated as a contravention under the Contraventions Act'. " + "IRPR s. 17 excepts it from the 'subsequent offence' too ('other than an " + "offence designated as a contravention under the Contraventions Act'). " + "Confirm that the ticket was for an offence the Contraventions Regulations " + "designate (their schedules are in the corpus).") +YOUTH_SENTENCE_TEXT = ( + "Not a ground: IRPA s. 36(3)(e) -- inadmissibility 'may not be based on an " + "offence' for which the person 'received a youth sentence under the Youth " + "Criminal Justice Act' (or, under the former Act, 'is found guilty under the " + "Young Offenders Act'). An ADULT sentence imposed on a young person under the " + "YCJA is not a youth sentence, and is not excluded.") +RECORD_SUSPENSION_TEXT = ( + "Not a ground while it stands: IRPA s. 36(3)(b) -- inadmissibility 'may not " + "be based on a conviction in respect of which a record suspension has been " + "ordered and has not been revoked or ceased to have effect under the Criminal " + "Records Act'. If it was revoked or has ceased to have effect, screen the " + "conviction as it stands (re-run without the record-suspension words).") +ACT_OF_PARLIAMENT_NOTE = ( + "s. 36(1)(a) and (2)(a) reach only 'an offence under an Act of Parliament' " + "(or 'two offences under any Act of Parliament'): a conviction under a " + "provincial Act (highway traffic, liquor, provincial offences) is not " + "reached.") +CONDITIONAL_SENTENCE_NOTE = ( + "A conditional sentence is served in the community and is not a term of " + "imprisonment (Tran v. Canada (Public Safety and Emergency Preparedness), " + "2017 SCC 50): it counts as no custody for the six-month limb of " + "s. 36(1)(a), whatever its length.") +INTERMITTENT_NOTE = ( + "An intermittent sentence: whether it is 'a term of imprisonment of more " + "than six months' under s. 36(1)(a) is NOT settled in CanLex -- where it " + "decides the answer, both readings are given.") +CANADA_REMOVAL_FN = ( + "Removal path (foreign national, s. 36(1)(a) or (2)(a)): IRPR s. 228(1)(a) -- " + "'if the foreign national is inadmissible under paragraph 36(1)(a) or (2)(a) " + "of the Act on grounds of serious criminality or criminality, a deportation " + "order' -- so the Minister's delegate makes a deportation order and the " + "report is not referred to the Immigration Division. That holds only 'if a " + "report in respect of a foreign national does not include any grounds of " + "inadmissibility other than those set out in the following circumstances', " + "and not for a person who 'is under 18 years of age and not accompanied by a " + "parent or an adult legally responsible for them' or who cannot appreciate " + "the proceedings (s. 228(4)): those reports go to the Immigration Division " + "(IRPA s. 44(2)). At a port of entry the alternatives remain allowing the " + "person to withdraw the application to enter (IRPR s. 42) or a TRP.") +CANADA_REMOVAL_PR = ( + "Removal path (permanent resident, s. 36(1)(a)): the 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 under IRPR s. 229(1)(c) -- 'in " + "the case of a permanent resident inadmissible under subsection 36(1) of the " + "Act on grounds of serious criminality'.") +CANADA_BRANCH_TEXT = { + "36(1)(a)": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(a) (convicted in Canada; " + "reaches permanent residents and foreign nationals alike)", + "36(2)(a)": "CRIMINALITY -- IRPA s. 36(2)(a) (convicted in Canada of an " + "offence punishable by way of indictment; foreign nationals only)", + "36(2)(a)-two-offences": "CRIMINALITY -- IRPA s. 36(2)(a), two offences not " + "arising out of a single occurrence (foreign " + "nationals only); the IRPR s. 18.1 exemption is " + "not reached (not yet, or not solely on these " + "convictions)", + "s18.1-exempt": "EXEMPT -- IRPR s. 18.1: in the prescribed class exempt " + "from IRPA s. 36(2)(a)", + "none": "NO s. 36 GROUND on this conviction", + "not-a-conviction": "NOT A CONVICTION -- a Canadian discharge (Criminal " + "Code s. 730(3))", + "no-conviction": "NO CONVICTION -- a Canadian charge that did not end in a " + "conviction grounds no s. 36 inadmissibility", + "excluded-contravention": "NOT A GROUND -- a contravention (IRPA " + "s. 36(3)(e)(i))", + "excluded-youth": "NOT A GROUND -- a youth sentence (IRPA s. 36(3)(e))", + "record-suspension": "NOT A GROUND while the record suspension stands " + "(IRPA s. 36(3)(b))", + "not-settled": "NOT SETTLED -- an input the answer turns on was not " + "given; each reading is shown", +} +_CANADA_STD_BRANCH = {"36(1)(a)": "serious-criminality", "36(2)(a)": "criminality", + "36(2)(a)-two-offences": "criminality", + "s18.1-exempt": "criminality"} + +# The record's own words, for a Canadian entry (phrases only). +_CANADA_SENTENCE_WORDS = ( + (re.compile(r"\babsolute(?:ly)? discharge|\bdischarged absolutely"), "absolute-discharge"), + (re.compile(r"\bconditional(?:ly)? discharge|\bdischarged conditionally"), + "conditional-discharge"), + (re.compile(r"\bconditional sentence"), "conditional-sentence"), + (re.compile(r"\bintermittent"), "intermittent"), + (re.compile(r"\bsuspended (?:the )?(?:passing of )?sentence"), "suspended-sentence"), + (re.compile(r"\byouth sentence"), "youth-sentence"), +) +# The French names the Criminal Records Act's own French text uses, and the grant words that +# make one a naming clause (REL-16, verification round J2; the grammar is at +# _CANADA_RS_NAMING below, where the Act is quoted). +_RS_FR_GRANT = r"(?:accord[eé]e?s?|octroy[eé]e?s?|ordonn[eé]e?s?|en vigueur)" +# K2 (verification round K2, 2026-09-23; J2-L1): the ACCENT is part of the name. The Act's +# French term is 'réhabilitation'; the unaccented 'rehabilitation' is an ordinary English +# word -- 'rehabilitation granted 2005' in a Canadian entry's relief key is most naturally a +# court-ordered treatment programme, and IRPA s. 36(3)(b) excepts only a conviction 'in +# respect of which a record suspension has been ordered ... under the Criminal Records Act' +# (s. 36(3)(c)'s rehabilitation is a different instrument, and does not reach a conviction IN +# Canada at all). REL-16 left the accent optional HERE, so the English word in the relief key +# read as the pre-2012 pardon and the conviction answered 'no s. 36 ground'. The unaccented +# spelling now names the relief only before a French grant word -- the same rule +# _CANADA_RECORD_SUSPENSION below already applied to the disposition path. +_RS_FR_NAME = (r"(?:suspension du casier(?:\s+judiciaire)?|réhabilitation" + rf"|rehabilitation(?=\s+{_RS_FR_GRANT}))") +# REL-16: the French names too, so relief_reading finds where the relief clauses start. The +# ACCENTED 'rehabilitation' is the Act's French term (its French text writes rehabilitation +# as reintegration 'readaptation' -- s. 4.1(1): 'soutiendrait sa readaptation'); the +# unaccented spelling is an ordinary English word, and counts only before a French grant word +_CANADA_RECORD_SUSPENSION = re.compile( + r"\brecord suspension|\bpardon|\bsuspension du casier|\bréhabilitation" + rf"|\brehabilitation(?=\s+{_RS_FR_GRANT})") +# ---- The Canadian outcome words: an ALLOWLIST (H3, 2026-09-22; HELD) --------------------- +# Two of _CANADA_SENTENCE_WORDS decide the ANSWER rather than describe the sentence: a +# discharge is not a conviction (Criminal Code s. 730(3)) and a youth sentence is no ground +# at all (IRPA s. 36(3)(e)(iii)). F1 read them ANYWHERE in the disposition, so the word beat +# the record's explicit conviction words: 'convicted; request for a conditional discharge +# refused; 8 months imprisonment' answered NOT A CONVICTION and 'convicted as an adult; +# application for a youth sentence refused; 3 years imprisonment' NOT A GROUND, and both +# entries left the IRPR s. 17 clock (S17-INT-1, NEW-1). They now settle only when the phrase +# is the disposition's OWN outcome clause: exactly one clause names it, that clause is +# nothing but the order itself (_CANADA_DISCHARGE_CLAUSE, _CANADA_YOUTH_CLAUSE -- the court's +# order, with at most a s. 730 citation, a probation term or the YCJA named), and no other +# clause convicts, revokes, refuses, appeals, sets aside or names another count +# (_CANADA_DISCHARGE_CONTRA, _CANADA_YOUTH_CONTRA). Criminal Code s. 730(1) lets the court, +# 'instead of convicting the accused', direct that an accused who 'pleads guilty to or is +# found guilty of an offence' be discharged -- so the guilt words the subsection itself +# requires are no contradiction, while 'convicted' is; s. 730(3)(b) lets the Attorney General +# appeal 'the decision of the court not to convict the offender'; s. 730(4) lets the court +# 'revoke the discharge, convict the offender ... and impose any sentence'. IRPA +# s. 36(3)(e)(iii) excepts an offence for which the person 'received a youth sentence under +# the Youth Criminal Justice Act' -- an application for one that was refused or set aside is +# the opposite. Anything else does not settle the word: the conviction words decide, and +# where they do not the entry is NOT SETTLED, which keeps it on the s. 17 clock either way. +# The officer's own canada_sentence parameter is unchanged (it is a datum, not a reading). +_CANADA_DISCHARGE_PHRASE = re.compile( + r"\babsolute(?:ly)? discharge|\bdischarged absolutely" + r"|\bconditional(?:ly)? discharge|\bdischarged conditionally") +_CANADA_YOUTH_PHRASE = re.compile(r"\byouth sentence") +_CANADA_OUTCOME_SPLIT = re.compile(r"[;:.,()\[\]/]|\s-{1,2}\s|\s–\s|\s—\s") +_CANADA_DISCHARGE_CLAUSE = re.compile( + r"(?:(?:the )?(?:accused|offender|defendant|appellant|he|she|they) )?" + r"(?:(?:was|were|is|are|be|been) )?" + r"(?:(?:granted|given|received|ordered|directed to be|placed on) )?" + r"(?:an? )?" + r"(?:absolute(?:ly)? discharge|conditional(?:ly)? discharge" + r"|discharged absolutely|discharged conditionally)" + r"(?: (?:under|pursuant to)(?: the)?(?: criminal code)?(?: s\.?| section)? ?730[0-9()]*)?" + r"(?: (?:with|and|on|plus) (?:\d+ (?:month|months|year|years) )?probation" + r"(?: for \d+ (?:month|months|year|years))?)?$") +_CANADA_DISCHARGE_CONTRA = re.compile( + r"\bconvict\w*|\brevok\w*|\brefus\w*|\bdeni(?:ed|es|al)\b|\bset aside\b|\bsets aside\b" + r"|\bquash\w*|\bvacat\w*|\bappeal\w*|\boverturn\w*|\bdeclin\w*|\brescind\w*" + r"|\bcounts?\b|\bimprisonment\b|\bjail\b|\bcustody\b|\bpenitentiary\b|\bprison\b" + r"|\bincarcerat\w*|\breinstat\w*|\bvari(?:ed|es|ation)\b") +_CANADA_YOUTH_CLAUSE = re.compile( + r"(?:(?:the )?(?:accused|offender|defendant|young person|youth|he|she|they) )?" + r"(?:(?:was|were|is|are|be|been|receives?|received|serving|served) )?" + r"(?:(?:given|granted|ordered|imposed|sentenced to|subject to) )?" + r"(?:an? )?" + r"youth sentence" + r"(?: (?:under|pursuant to)(?: the)? (?:ycja|youth criminal justice act)" + r"(?: s\.?| section)? ?[0-9()a-z.]*)?" + r"(?: of \d+ (?:month|months|year|years)(?: (?:custody|probation|supervision)" + r"(?: and supervision)?)?)?$") +_CANADA_YOUTH_CONTRA = re.compile( + r"\brefus\w*|\bdeni(?:ed|es|al)\b|\bset aside\b|\bsets aside\b|\bquash\w*|\bvacat\w*" + r"|\bappeal\w*|\boverturn\w*|\bdeclin\w*|\brevok\w*|\brescind\w*" + r"|\bas an adult\b|\badult sentence\b|\badult court\b|\badult facility\b" + r"|\bliable to an adult\b|\braised to adult\b|\bordinary court\b") +# word -> (the phrase, the clause the word may be, what no other clause may say) +_CANADA_OUTCOME_ALLOW = { + "absolute-discharge": (_CANADA_DISCHARGE_PHRASE, _CANADA_DISCHARGE_CLAUSE, + _CANADA_DISCHARGE_CONTRA), + "conditional-discharge": (_CANADA_DISCHARGE_PHRASE, _CANADA_DISCHARGE_CLAUSE, + _CANADA_DISCHARGE_CONTRA), + "youth-sentence": (_CANADA_YOUTH_PHRASE, _CANADA_YOUTH_CLAUSE, _CANADA_YOUTH_CONTRA), +} + + +def canada_clauses(disposition): + """A Canadian disposition's clauses, as the outcome allowlists read them: + the text split on the punctuation that separates one statement of the + record from the next (H3, 2026-09-22).""" + return [c.strip() for c in _CANADA_OUTCOME_SPLIT.split(disposition or "") if c.strip()] + + +def canada_outcome_stands(word, disposition): + """Whether a Canadian discharge or youth-sentence WORD is the disposition's + own outcome (the ALLOWLIST of H3, 2026-09-22): exactly one clause names it, + that clause is nothing but the order itself, and no other clause convicts, + revokes, refuses, appeals, sets aside or names another count. False leaves + the word settling nothing -- the conviction words decide, and the entry + stays on the IRPR s. 17 clock. A word with no allowlist (a conditional, + intermittent or suspended sentence, which describe a sentence and decide no + branch) always stands.""" + rule = _CANADA_OUTCOME_ALLOW.get(word) + if rule is None: + return True + phrase, clause_ok, contra = rule + clauses = canada_clauses(disposition) + named = [c for c in clauses if phrase.search(c)] + if len(named) != 1 or not clause_ok.fullmatch(named[0]): + return False + return not any(contra.search(c) for c in clauses if c is not named[0]) + + +# ---- The Canadian relief reader: an ALLOWLIST (G2, 2026-09-22; HELD) --------------------- +# Six rounds of recognition rules for relief wording -- a list of not-in-force words, then +# lists of losses, negations and neutral tokens (F1) -- each left phrasings that read as a +# record suspension in force when the words do not say so: 'pardon ceased to have effect +# 2010, not revoked', 'pardon granted 1998 - rev. 2004', 'Record suspension: --', 'pardon +# 1999-2004', 'pardon granted 1998; convicted of robbery 2003' (REL-1 to REL-10). The reader +# now settles only on a small positive grammar; everything else is NOT in force (the +# conviction screened as it stands, with RECORD_SUSPENSION_NOT_IN_FORCE_TEXT), or NOT SETTLED +# where the words may name a relief that is no record suspension (a prerogative pardon, a +# 'pardon' dated after 2012-03-12) or where a later conviction may have ended it (CRA ss. 7.2 +# and 7). A record suspension stands only when the relief words, taken as a whole -- the +# disposition's relief clauses and the relief key read TOGETHER, never one after the other +# (REL-3) -- are ONE naming clause (_CANADA_RS_NAMING): +# record suspension | pardon | pardoned [granted | ordered | issued | in force] +# [[on | in] YYYY-MM-DD | YYYY] +# carrying a GRANT WORD or the participle 'pardoned' (a bare 'Pardon' or 'Record suspension' +# -- an empty form field -- says nothing was ordered, and H2 2026-09-22, G2-REL-D: a bare +# date beside the name, 'record suspension 2019', does not say it was ordered either), +# followed by nothing but 'not revoked' or +# 'has not ceased to have effect' (_CANADA_RS_KEEP: it keeps a relief that already stands and +# never makes one), and preceded -- in the disposition AND beside a key-only relief (H2, +# G2-REL-C) -- by nothing but the conviction words themselves (_CANADA_RS_CONVICTION), at +# most one of them undated (G2-REL-H). Its date must then fit the entry +# (relief_dates_stand: on or after the offence and the conviction, its LATEST day not after +# today -- REL-11, G2-REL-F). +# +# REL-16 (verification round J2, 2026-09-22): the SAME grammar reads the French wording, on +# the Act's own French words. The Criminal Records Act's French text defines 'suspension du +# casier' as 'Mesure ordonnee par la Commission en vertu de l'article 4.1' and its +# 'rehabilitation' -- the name the relief carried before 2012 -- reads '[Abrogee, 2012, +# ch. 1, art. 109]', exactly as the English 'pardon' does; s. 2.1(1) gives the Board the +# power 'pour ordonner, refuser ou revoquer la suspension du casier'; and IRPA s. 36(3)(b) +# in French excepts a conviction 'en cas de suspension du casier -- sauf cas de revocation +# ou de nullite -- au titre de la Loi sur le casier judiciaire'. So 'suspension du casier' +# is the record suspension, 'rehabilitation' is the pardon (and takes the pardon's +# post-2012-03-12 reading, AI-4), 'revoquee' and 'nullite' are its losses. Nothing new is +# recognised beyond the naming clause the English side already accepts: a French name still +# needs a French GRANT WORD ('accordee', 'octroyee', 'ordonnee', 'en vigueur'), and a bare +# 'suspension du casier' or 'suspension du casier : oui' stands no more than a bare 'Record +# suspension' does. The unaccented 'rehabilitation' is an ordinary English word, so it is +# read as the Act's French name only when a French grant word follows it. +_RS_DATE = r"(?P\d{4}-\d{2}-\d{2}|\d{4})" +_RS_LOSS_PHRASE = (r"(?:revoked|ceased(?:\s+to\s+have\s+effect)?" + r"|r[eé]voqu[eé]e?|annul[eé]e?|nulle?)") +_RS_NEG_LOSS = (rf"(?:(?:has|have)\s+)?(?:not|never|non|jamais|n'a\s+pas\s+[eé]t[eé])\s+" + rf"(?:been\s+)?{_RS_LOSS_PHRASE}") +# 'not revoked or ceased' negates both; after 'and' the second loss must be negated too +# ('not revoked and ceased' says it ceased) +_RS_KEEP = (rf"(?:(?:which|that|qui)\s+)?{_RS_NEG_LOSS}" + rf"(?:\s+(?:or|nor|ou|ni)\s+(?:{_RS_NEG_LOSS}|{_RS_LOSS_PHRASE})" + rf"|\s+(?:and|et)\s+{_RS_NEG_LOSS})?") +_CANADA_RS_KEEP = re.compile(_RS_KEEP) +_CANADA_RS_NAMING = re.compile( + rf"(?Precord suspension|pardoned|pardon|{_RS_FR_NAME})" + rf"(?:\s+(?Pgranted|ordered|issued|in force|{_RS_FR_GRANT}))?" + rf"(?:\s+(?:(?:on|in|le|en)\s+)?{_RS_DATE})?" + rf"(?:\s+(?:and\s+)?{_RS_KEEP})?") +# what each name is, once read: the French names are the Act's own (REL-16) +_CANADA_RS_NAME_READ = ( + (re.compile(r"^suspension du casier"), "record suspension"), + (re.compile(r"^r[eé]habilitation$"), "pardon"), +) +# the conviction words a disposition may carry before the naming clause, and nothing else +_CANADA_RS_CONVICTION = re.compile( + r"(?:convicted|found guilty|pleaded guilty|pled guilty|guilty plea|guilty|conviction" + r"|d[eé]clar[eé]e?\s+coupable|reconnue?\s+coupable|condamn[eé]e?|condamnation" + r"|plaidoyer de culpabilit[eé]|culpabilit[eé])" + r"(?:\s+(?:(?:on|in|le|en)\s+)?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +# where the relief clauses break: ';', ',', '.' and a spaced dash -- a parenthesis stays +# inside its clause (the audit follow-up of 2026-09-21); a separator the split does not know +# ('|', '/', '->') leaves one clause, which then fails the grammar +_CANADA_CLAUSE = re.compile(r"\s*(?:[;,.]|\s-\s|\s--\s|\(|\))\s*") +_CANADA_RS_CLAUSE = re.compile(r"\s*(?:[;,.]|\s-\s|\s--\s)\s*") +# shorthand for a record suspension the grammar does not read ('RS', 'R/S', 'R.S.', 'record +# susp.', 'record suspended'): it never stands, and the not-in-force note says so (REL-13) +_CANADA_RS_ABBREVIATION = re.compile( + r"(?' " + "as the entry's relief.") +# H2 (2026-09-22, G2-REL-A): an UNDATED 'pardon' or 'pardoned'. Every reading of its date +# may fall after 2012-03-12, so it gets the same NOT SETTLED reading as a dated one -- with +# strictly less information it may no longer answer the more favourable way. +PARDON_UNDATED_TEXT = ( + "NOT SETTLED -- the words name a pardon and give no date, so every reading of its date " + "may fall after 2012-03-12, when the Criminal Records Act stopped calling a relief it " + "orders a pardon: the Act's definition of pardon reads '[Repealed, 2012, c. 1, s. 109]', " + "and what the Parole Board of Canada orders is a record suspension: 'record suspension " + "means a measure ordered by the Board under section 4.1'; s. 9 keeps the other pardons: " + "'Nothing in this Act in any manner limits or affects Her Majesty's royal prerogative of " + "mercy or the provisions of the Criminal Code relating to pardons'. An undated pardon may " + "be a record suspension called by the old name, a pardon under the royal prerogative of " + "mercy or Criminal Code s. 748, or one ordered for another conviction -- and IRPA " + f"s. 36(3)(b) reaches only a conviction '{_S36_3_B_WORDS}'. What this pardon does to the " + "s. 36 ground is not settled in CanLex: the entry is screened as the conviction it names, " + "and if the pardon is a record suspension for this conviction that stands, no s. 36 " + "ground rests on it. Confirm from the instrument; if the Parole Board of Canada ordered a " + "record suspension, re-run with 'record suspension ordered ' as the entry's " + "relief.") +# H2 (2026-09-22, G2-REL-B): a pardon or record suspension dated before the Criminal Records +# Act as the CanLex corpus carries it (_CRA_FIRST_DAY) +RELIEF_BEFORE_CRA_TEXT = ( + "NOT SETTLED -- the words name a pardon or record suspension dated before the Criminal " + "Records Act as the CanLex corpus carries it, and IRPA s. 36(3)(b) reaches only a " + f"conviction '{_S36_3_B_WORDS}'. The corpus carries no enactment date for the Act: the " + "history line of its s. 1 ('This Act may be cited as the Criminal Records Act') reads " + "'R.S., c. 12 (1st Supp.), s. 1', and the earliest date the Act's own history lines carry " + "is 'R.S., 1985, c. C-47, s. 2' -- so CanLex cannot place a relief dated before 1985 " + "under it. The Act also keeps the other pardons outside itself (s. 9: 'Nothing in this " + "Act in any manner limits or affects Her Majesty's royal prerogative of mercy or the " + "provisions of the Criminal Code relating to pardons'), and a pardon under the royal " + "prerogative of mercy or Criminal Code s. 748 is no record suspension. What this relief " + "does to the s. 36 ground is not settled in CanLex: the entry is screened as the " + "conviction it names, and if a record suspension under the Criminal Records Act stands, " + "no s. 36 ground rests on it. Confirm the instrument and its date with the Parole Board " + "of Canada; if a record suspension was ordered for this conviction and stands, re-run " + "with 'record suspension ordered ' as the entry's relief.") +# H2 (2026-09-22, G2-REL-E): an UNDATED record suspension on an entry that carries a date to +# place it against -- it may have been ordered for an earlier conviction +RELIEF_UNDATED_TEXT = ( + "NOT SETTLED -- the words say a record suspension was ordered but give no date, and this " + "entry carries a date to place one against. A record suspension ordered for an EARLIER " + "conviction is not this conviction's: the Criminal Records Act makes 'a person ... " + "ineligible to apply for a record suspension until the following period has elapsed after " + "the expiration according to law of any sentence' (s. 4(1)), so one cannot be this " + "conviction's before its sentence expired; and IRPA s. 36(3)(b) reaches only a " + f"conviction '{_S36_3_B_WORDS}'. Whether this record suspension is this conviction's is " + "not settled " + "in CanLex: the entry is screened as the conviction it names, and if it is this " + "conviction's and stands, no s. 36 ground rests on it. Re-run with 'record suspension " + "ordered ' as the entry's relief (canlex_screen, canlex_screen_history) or as " + "the disposition 'convicted; record suspension ordered ' " + "(canlex_screen_record).") +# REL-5 (G2, 2026-09-22): another conviction on the record may be later than the record +# suspension (CRA s. 7.2(a): it ceases to have effect; s. 7(a): the Board may revoke it) +RELIEF_LATER_CONVICTION_TEXT = ( + "NOT SETTLED -- another conviction on this record may be a later conviction in Canada: its " + "date, or the record suspension's, does not show that it came first. Criminal Records Act " + "s. 7.2: 'A record suspension ceases to have effect if (a) the person to whom it relates " + "is subsequently convicted of (i) an offence referred to in paragraph 4(1)(a), or (ii) an " + "offence under the Criminal Code other than an offence under subsection 320.14(1) or " + "320.15(1) of that Act — or under the Cannabis Act, the Controlled Drugs and " + "Substances Act, the Firearms Act, Part III or IV of the Food and Drugs Act or the " + "Narcotic Control Act, chapter N-1 of the Revised Statutes of Canada, 1985 — that is " + "punishable either on conviction on indictment or on summary conviction'; s. 7: 'A record " + "suspension may be revoked by the Board (a) if the person to whom it relates is " + "subsequently convicted of an offence referred to in paragraph 4(1)(b), other than an " + "offence referred to in subparagraph 7.2(a)(ii)'. IRPA s. 36(3)(b) reaches only a " + f"conviction '{_S36_3_B_WORDS}'. Whether this record suspension still stands is not " + "settled in CanLex: the entry is screened as the conviction it names, and if the record " + "suspension still stands, no s. 36 ground rests on it. Confirm with the Parole Board of " + "Canada; on canlex_screen or " + "canlex_screen_history give every conviction as its own entry with its dates, and the " + "record suspension as 'record suspension ordered '.") +# The G3 fixes of 2026-09-22 (PREROG-2, HELD): the one phrase that settles the kind. A +# conditional pardon leaves the conviction standing (s. 748(2) grants one 'to any person +# who has been convicted of an offence'; s. 748(3) speaks of a free pardon only), so the +# IRPR s. 17 clock counts it plainly. Anything else -- a free pardon, or the kind unstated +# -- is 'other': the clock counts it the strict way and gives the date without it as a +# second reading. Reading 'conditional' only ever makes the date later, never earlier. +_PREROGATIVE_CONDITIONAL = re.compile(r"\bconditional pardon\b") +_PREROGATIVE_FREE = re.compile(r"\bfree\b") + + +def prerogative_kind(words): + """'conditional' when the words name a conditional pardon and nothing + free, else 'other' (the G3 fixes of 2026-09-22, PREROG-2).""" + t = _rs_text(words) + return ("conditional" if _PREROGATIVE_CONDITIONAL.search(t) + and not _PREROGATIVE_FREE.search(t) else "other") +RECORD_SUSPENSION_NOT_IN_FORCE_TEXT = ( + "The record suspension (pardon) words are not read as one in force, so IRPA s. 36(3)(b) " + "-- inadmissibility 'may not be based on a conviction in respect of which a record " + "suspension has been ordered and has not been revoked or ceased to have effect under the " + "Criminal Records Act' -- is not applied and the conviction is screened as it stands. " + "CanLex reads a record suspension as standing only on the words 'record suspension' or " + "'pardon' with 'granted', 'ordered', 'issued' or 'in force' and one date (YYYY-MM-DD) or " + "year, on or after this offence (and this conviction and the end of its sentence, where " + "given), with nothing beside them but the conviction words before them and 'not revoked' " + "or 'has not ceased to have effect' after them; anything else -- refused, pending, " + "revoked, ceased, a later conviction, a range of years, 'RS', a doubt, a second relief " + "clause, a date before the offence, a year that may not have run out by the assessment " + "date, or (where the relief is given in the entry's relief key) any clause in the " + "disposition beside the conviction words -- is read as not in force. If a record " + "suspension was in fact ordered for this conviction and stands, re-run with 'record " + "suspension ordered ' as the entry's relief (canlex_screen, " + "canlex_screen_history) or as the disposition 'convicted; record suspension ordered " + "' (canlex_screen_record).") +# the note for each NOT SETTLED relief reading (canada_disposition 'relief_open') +RELIEF_OPEN_TEXT = {"prerogative": PREROGATIVE_PARDON_TEXT, + "pardon-after-2012": PARDON_AFTER_2012_TEXT, + "pardon-undated": PARDON_UNDATED_TEXT, + "relief-before-cra": RELIEF_BEFORE_CRA_TEXT, + "relief-undated": RELIEF_UNDATED_TEXT, + "later-conviction": RELIEF_LATER_CONVICTION_TEXT} + + +def _rs_text(words): + """The relief words as the reader splits them: lower case, one space, and a + section citation's period kept inside its clause ('s. 748', 's. 7 of the + Criminal Records Act').""" + t = " ".join((words or "").lower().split()) + return re.sub(r"\b(ss?)\.\s*(?=\d)", r"\1 ", t) + + +def _rs_clauses(words): + """The relief words' clauses (_CANADA_RS_CLAUSE), empty ones dropped.""" + return [c for c in _CANADA_RS_CLAUSE.split(_rs_text(words)) if c] + + +def _rs_naming(clause): + """The _CANADA_RS_NAMING match of a clause that is one naming clause with a + grant word or the participle 'pardoned'; None for anything else. + + H2 (2026-09-22, G2-REL-D): a bare date no longer settles the clause. + 'record suspension 2019' and 'pardon 1998' say a record suspension or pardon + and a year and nothing about an order being made, and IRPA s. 36(3)(b) + reaches a conviction only where a record suspension 'has been ordered' -- + the same reason a bare 'Record suspension' (an empty form field) never + stood. Before: both settled the relief as ordered and in force.""" + m = _CANADA_RS_NAMING.fullmatch(clause) + if m and (m.group("grant") or m.group("name") == "pardoned"): + return m + return None + + +def rs_name_read(name): + """What a naming clause's name IS: the French names the Criminal Records Act's + French text uses read as their English twins -- 'suspension du casier' (and + 'suspension du casier judiciaire') is the record suspension, 'rehabilitation' + is the pardon the Act called by that name until 2012 (REL-16), so a French + record gets the same readings as an English one, the post-2012-03-12 pardon + reading included (pardon_after_2012).""" + for pat, read in _CANADA_RS_NAME_READ: + if pat.match(name or ""): + return read + return name + + +def prerogative_clause(clause): + """True when a clause is NOTHING BUT a pardon under the royal prerogative of + mercy or Criminal Code s. 748 -- the name, an optional grant word, an + optional date (_CANADA_RS_PREROGATIVE_CLAUSE) -- and carries no loss word + and no later conviction's words. REL-15.""" + return bool(_CANADA_RS_PREROGATIVE_CLAUSE.fullmatch(clause or "") + and not _CANADA_RS_LOSS.search(clause or "") + and not _CANADA_RS_LATER_OFFENCE.search(clause or "")) + + +def _rs_relief_part(clauses): + """The naming match when the clauses are ONE naming clause and nothing after + it but keep clauses ('not revoked'); None otherwise. + + REL-15 (verification round J2, 2026-09-22): a RECORD SUSPENSION naming clause + wins wherever it sits among the relief clauses, and a clause that is nothing + but a prerogative pardon (prerogative_clause) never takes it away. IRPA + s. 36(3)(b) excepts a conviction 'in respect of which a record suspension has + been ordered and has not been revoked or ceased to have effect under the + Criminal Records Act': what decides is the record suspension, and a pardon + under the royal prerogative or Criminal Code s. 748 is a separate instrument + that can only add relief, never revoke the Board's order (CRA s. 9: 'Nothing + in this Act in any manner limits or affects Her Majesty's royal prerogative + of mercy'). Before, 'record suspension ordered 2015; conditional pardon 2010' + and 'free pardon granted; record suspension ordered 2015' both read NOT + SETTLED on the prerogative pardon although the record suspension stands. + A naming clause that names only a PARDON keeps the old rule -- it must be the + first clause and everything after it a keep clause -- because that pardon may + itself be the prerogative one.""" + if not clauses: + return None + named = [(i, m) for i, m in ((i, _rs_naming(c)) for i, c in enumerate(clauses)) + if m is not None] + suspensions = [(i, m) for i, m in named + if rs_name_read(m.group("name")) == "record suspension"] + if len(suspensions) == 1: + i, m = suspensions[0] + rest = clauses[:i] + clauses[i + 1:] + if all(_CANADA_RS_KEEP.fullmatch(o) or prerogative_clause(o) for o in rest): + return m + return None + m = _rs_naming(clauses[0]) + if m is None or not all(_CANADA_RS_KEEP.fullmatch(c) for c in clauses[1:]): + return None + return m + + +def _rs_when(text): + """(earliest, latest) day the naming clause's date can mean -- the day, or a + year's first and last days; None when undated; False when it is no calendar + date.""" + if not text: + return None + try: + if len(text) == 4: + year = int(text) + return (_dt.date(year, 1, 1), _dt.date(year, 12, 31)) + day = _dt.date.fromisoformat(text) + return (day, day) + except ValueError: + return False + + +def relief_reading(disposition, relief=None): + """How the relief words read on the allowlist grammar (G2, 2026-09-22): the + disposition's relief clauses (from the first clause naming a record + suspension or pardon to the end) and the relief key read together. + Returns {named: a record suspension or pardon is named, stands: the words are + ONE naming clause (_CANADA_RS_NAMING) with nothing after it but keep clauses + (_CANADA_RS_KEEP) and nothing before it in the disposition but conviction + words (_CANADA_RS_CONVICTION), name: 'record suspension' | 'pardon' | + 'pardoned', date: (earliest, latest) or None}. The disposition and the key + may carry the same naming clause; two different ones do not stand, whatever + their order (REL-3). With the relief in the key alone, a disposition clause + carrying another relief word, a loss word or another conviction's words ('RS + revoked 2019', 'subsequent conviction') takes it away; and a year in the + disposition's own words that is not before the relief's ('convicted 2010; + pardon granted 2009') does too. These only ever take a relief away. The + entry's dates are not read here (relief_dates_stand). + + H2 (2026-09-22): the conviction-word allowlist is applied to the disposition + beside a KEY-ONLY relief too (G2-REL-C) -- before, those words were screened + only by the four word lists, and 13 of 17 clauses saying the relief had + ended or that a later conviction followed ('has been convicted since', + 'suspension terminated', 'set aside by the Board', 'suspension pending') + left the key standing, while the same clauses written inline in the + disposition were all refused. The word lists stay as a refusal on top. The + accepted strict cost: a disposition that carries a sentence or an appeal + clause beside a key ('pled guilty, 12 months probation') stops the key + standing, and the flag says what to give instead. A SECOND undated + conviction clause ('convicted 2005; convicted; record suspension ordered + 2010') no longer stands either: it is the one wording the allowlist cannot + place in time (G2-REL-H), so every conviction clause after the first must + carry a year the relief's own year can be compared with. + + J2 (2026-09-22): a RECORD SUSPENSION naming clause is read wherever it sits + among the relief clauses and a clause that is nothing but a prerogative + pardon does not take it away (REL-15, _rs_relief_part); the French wording + the Criminal Records Act's own French text uses is read by the same grammar, + 'suspension du casier' as the record suspension and 'rehabilitation' as the + pardon (REL-16, rs_name_read).""" + d, k = _rs_clauses(disposition), _rs_clauses(relief) + first = next((i for i, c in enumerate(d) if _CANADA_RECORD_SUSPENSION.search(c)), None) + conv, dr = (d, []) if first is None else (d[:first], d[first:]) + out = {"named": bool(dr) or any(_CANADA_RECORD_SUSPENSION.search(c) for c in k), + "stands": False, "name": None, "date": None} + if not dr and not k: + return out + md = _rs_relief_part(dr) if dr else None + mk = _rs_relief_part(k) if k else None + # REL-3: the disposition and the key may carry the SAME naming clause; two + # different ones do not stand. J2: the naming clauses are compared, not the + # first clauses, because a record suspension may sit after a prerogative one + if (dr and md is None) or (k and mk is None): + return out + if md is not None and mk is not None and md.group(0) != mk.group(0): + return out + # the conviction words, and nothing else, before the naming clause -- in the + # disposition and beside a key-only relief alike (H2, G2-REL-C) + if not all(_CANADA_RS_CONVICTION.fullmatch(c) for c in conv): + return out + # ... at most one of them undated (H2, G2-REL-H) + if len(conv) > 1 and not all(_CANADA_RS_YEAR.search(c) for c in conv): + return out + if not dr and any(_CANADA_RS_RELIEF_WORDS.search(c) or _CANADA_RS_ABBREVIATION.search(c) + or _CANADA_RS_LOSS.search(c) or _CANADA_RS_LATER_OFFENCE.search(c) + for c in conv): + return out + m = md or mk + when = _rs_when(m.group("date")) + if when is False: + return out + if when and any(int(y) >= when[0].year for c in conv for y in _CANADA_RS_YEAR.findall(c)): + return out + return dict(out, stands=True, name=rs_name_read(m.group("name")), date=when) + + +def relief_dates_stand(when, offence_date=None, conviction_date=None, today=None, + completed=None): + """True unless the naming clause's date may fall before this entry's offence + date, conviction date or sentence completion, or may fall after today + (REL-11: a record suspension ordered before the offence is not this + conviction's; CRA s. 4(1) makes a person ineligible to apply until a period + 'has elapsed after the expiration according to law of any sentence'). Each + comparison takes the reading that keeps the relief OUT: the earliest day the + date can mean against the entry's own dates, the LATEST day against today + (H2, 2026-09-22, G2-REL-F -- a year-only relief in the current year, 'record + suspension ordered 2026' read on 2026-09-22, stood although its latest day + is after the assessment date). An undated relief is not placed against the + entry here (canada_disposition -- G2-REL-E).""" + if not when: + return True + earliest, latest = when + return not (any(d is not None and earliest < d + for d in (offence_date, conviction_date, completed)) + or (today is not None and latest > today)) + + +def pardon_after_2012(reading): + """True when a standing naming clause names only a 'pardon' and its date may + fall after 2012-03-12 (AI-4): the Criminal Records Act orders record + suspensions since, and a pardon after it may be a prerogative one. H2 + (2026-09-22, G2-REL-A): a MISSING date may fall after it too, so an undated + 'pardon' / 'pardoned' opens the reading as a dated one does -- before, the + same words with strictly less information answered the more favourable way + ('convicted; pardoned' NOT A GROUND, 'convicted; pardoned 2016' NOT + SETTLED).""" + return bool(reading["stands"] and reading["name"] != "record suspension" + and (reading["date"] is None + or reading["date"][1] > _CRA_PARDON_LAST_DAY)) + + +def relief_before_cra(reading): + """True when a standing naming clause's date may fall before the Criminal + Records Act as the corpus carries it (_CRA_FIRST_DAY): it is not shown to be + a record suspension ordered under that Act, which is all IRPA s. 36(3)(b) + reaches (H2, 2026-09-22, G2-REL-B).""" + return bool(reading["stands"] and reading["date"] + and reading["date"][0] < _CRA_FIRST_DAY) + + +def relief_unplaced(reading, offence_date=None, conviction_date=None, completed=None): + """True when a standing naming clause carries NO date and the entry carries + one to place it against: a record suspension ordered for an earlier + conviction is not this one's, and CRA s. 4(1) bars an application until + after the sentence expires, so an undated relief cannot be shown to be this + conviction's (H2, 2026-09-22, G2-REL-E). With no entry date to place it + against, the relief is read as this conviction's, as before.""" + return bool(reading["stands"] and reading["date"] is None + and any(d is not None for d in (offence_date, conviction_date, completed))) + + +def record_suspension_in_force(words): + """What a Canadian record's words say about a record suspension or pardon, on + the allowlist grammar (relief_reading): True when one is named and the words + are one naming clause that stands, False when one is named and they are + anything else (or a 'pardon' dated after 2012-03-12, which is NOT SETTLED -- + pardon_after_2012), None when none is named. The entry's dates are not read + here: canada_disposition places the relief against the offence, the + conviction, today and the record's other convictions. + + G2 (2026-09-22): before, a clause reader over word lists (F1) let a later + 'not revoked' revive a ceased relief (REL-1), left an unplaceable later + clause standing (REL-4/6/8/9/10) and let neutral tokens stand ('Pardon:', + 'pardon may have been granted', 'pardon 1999-2004' -- REL-7).""" + reading = relief_reading(words) + if not reading["named"]: + return None + return reading["stands"] and not pardon_after_2012(reading) + + +def prerogative_pardon(words): + """True when the words name a pardon or clemency under the royal prerogative + of mercy or Criminal Code s. 748 ('conditional pardon', 'royal prerogative', + 'Governor General', 'Governor in Council', 's. 748'): not a Criminal Records + Act record suspension (the verification fixes of 2026-09-22, LEGAL-6).""" + t = _rs_text(words) + return bool(_CANADA_RS_PREROGATIVE.search(t) and _CANADA_RS_MERCY.search(t)) + + +# A conviction the words say was set aside: it no longer exists. +_CANADA_SET_ASIDE = re.compile(r"\b(?:quash\w*|reversed|overturned|set aside|vacated)\b") +# ... unless the words negate it ('conviction not set aside', 'never quashed') or it is the +# object of a motion, application, appeal or petition the same clause says was refused +# ('appeal to have conviction quashed dismissed') -- then the conviction stands. +_CANADA_SET_ASIDE_NEGATED = re.compile(r"\b(?:not|never|no|nor|without|refused to|declined to)\s+" + r"(?:be |been |being )?$") +_CANADA_SET_ASIDE_MOTION = re.compile(r"\b(?:motion|application|appeal|petition|request|" + r"leave|bid|attempt)\b") +_CANADA_SET_ASIDE_REFUSED = re.compile(r"\b(?:denied|dismissed|refused|rejected|abandoned|" + r"withdrawn|not allowed|unsuccessful|failed)\b") + + +def _set_aside(t): + """True when the words say the conviction was set aside and nothing in the + same clause negates it or makes it a refused motion's object. + J1 (2026-09-23): only where the set-aside is the disposition's OWN outcome + clause (canada_reading_stands) -- a sentence, an order, another count's or + another person's conviction quashed, or one the words restore, settles + nothing.""" + if not canada_reading_stands("set-aside", t): + return False + for clause in _CANADA_CLAUSE.split(t): + for m in _CANADA_SET_ASIDE.finditer(clause): + if _CANADA_SET_ASIDE_NEGATED.search(clause[:m.start()]): + continue + if _CANADA_SET_ASIDE_MOTION.search(clause) and _CANADA_SET_ASIDE_REFUSED.search(clause): + continue + return True + return False + + +def _no_conviction_words(t): + """True when a clause carries a no-conviction word that is the CHARGE's + (_CANADA_NO_CONVICTION) -- never a motion, application or appeal 'dismissed' + or 'withdrawn' in a clause that names one ('appeal to have conviction quashed + dismissed' is the appeal's dismissal, not the charge's). Whether those words + SETTLE 'no conviction' is canada_reading_stands' question (J1), not this + one's: here they only say the words are in the record.""" + for clause in _CANADA_CLAUSE.split(t): + for m in _CANADA_NO_CONVICTION.finditer(clause): + if (m.group(0).startswith(("dismiss", "withdrawn")) + and _CANADA_SET_ASIDE_MOTION.search(clause)): + continue + return True + return False + + +def _appeal_pending(t): + """True when the words note an appeal that no clause says was decided + (a clause naming the appeal and a refusal word is a decided one).""" + for clause in _CANADA_CLAUSE.split(t): + if re.search(r"\bappeal", clause) and not _CANADA_SET_ASIDE_REFUSED.search(clause): + return True + return False +# A charge that ended without a conviction. Beside conviction words they may +# name OTHER counts ('guilty plea; remaining charges withdrawn'), or not. +_CANADA_NO_CONVICTION = re.compile( + r"\b(?:acquit\w*|not guilty|withdrawn|dismiss\w*|stay of proceedings|" + r"proceedings stayed|stayed proceedings|no conviction)\b") +_CANADA_OTHER_COUNTS = re.compile( + r"\b(?:remaining|other|balance of the|rest of the|further)\s+(?:charges?|counts?)\b|" + r"\bcounts?\s*(?:#|no\.?\s*)?\d") +# An appeal allowed: the conviction may have been set aside, a new trial +# ordered or only the sentence varied -- the words alone do not say which. +_CANADA_APPEAL_ALLOWED = re.compile( + r"\bappeals? (?:was |is |has been )?(?:allowed|granted|successful|succeeded)\b|" + r"\ballow(?:ed|ing) (?:the |his |her |their )?appeals?\b") +# A verdict of not criminally responsible on account of mental disorder (the review of +# batch 1, round 4; HELD): Criminal Code s. 672.35 -- 'the accused shall not be found +# guilty or convicted of the offence' -- so it is no conviction for s. 36(1)(a)/(2)(a). +_CANADA_NCR = re.compile(r"\b(?:not criminally responsible|ncr|ncrmd)\b") +NCR_TEXT = ( + "No conviction: a verdict of not criminally responsible on account of mental disorder is " + "not a conviction -- Criminal Code s. 672.35: 'Where a verdict of not criminally responsible " + "on account of mental disorder is rendered, the accused shall not be found guilty or " + "convicted of the offence'. IRPA s. 36(1)(a) and (2)(a) need a person 'convicted in Canada', " + "and the act branches, s. 36(1)(c) and (2)(c), reach only 'committing an act outside " + "Canada' -- this matter grounds no s. 36 inadmissibility. (Confirm the verdict on the court " + "record; a review board disposition under Part XX.1 is not a sentence.)") +# A new trial ordered in Canada: the charge is pending again, not a conviction (round 4). +_CANADA_NEW_TRIAL = re.compile(r"\b(?:new trial|retrial|re-trial)\s+(?:was\s+|is\s+)?(?:ordered|directed|granted)\b|" + r"\b(?:ordered|directed|granted)\s+(?:a\s+)?(?:new trial|retrial|re-trial)\b") + +# ---- The Canadian outcome READINGS: an ALLOWLIST (J1, 2026-09-23; HELD) ------------------ +# The five readings canada_disposition still took from a WORD ANYWHERE in the disposition -- +# the NCR verdict, a set-aside, the no-conviction words (acquittal, withdrawal, dismissal, +# stay of proceedings), an appeal allowed and a new trial -- now get the treatment +# canada_outcome_stands gave the discharge and the youth sentence (H3). Each settles only as +# the disposition's OWN outcome clause: exactly one clause names the word (canada_clauses +# splits one statement of the record from the next), that clause is nothing but the outcome +# itself, and no other clause contradicts it -- refuses it, reverses it, negates it, appeals +# it, scopes it to another count, another file, another charge or another person, or names a +# conviction or a term of imprisonment. Everything else settles NOTHING: the conviction words +# decide, and where they do not the entry is NOT SETTLED (conviction None), which keeps it on +# the record's counts and its IRPR s. 17 clock and prints the deciding question. +# The one clause a contradiction test SKIPS (keep) is another of these outcomes' own clause +# where the Criminal Code itself puts the two together: s. 686(2) -- 'Where a court of appeal +# allows an appeal under paragraph (1)(a), it shall quash the conviction and (a) direct a +# judgment or verdict of acquittal to be entered; or (b) order a new trial' -- so 'conviction +# quashed; acquittal entered' and 'conviction quashed; new trial ordered' are ONE outcome +# written in two clauses, not a contradiction. A keep clause is the other reading's own +# fullmatch grammar, so it cannot read a refusal ('new trial refused') or a negation. The +# keep is NOT symmetric: a quashed conviction keeps a new trial ordered (the charge is live +# again: pending, NOT SETTLED), but a new trial does not keep the set-aside -- a conviction +# quashed with a new trial anywhere beside it is no settled 'no conviction'. +# What the corpus says the words mean, for each reading: +# NCR -- Criminal Code s. 672.35: 'Where a verdict of not criminally responsible on account +# of mental disorder is rendered, the accused shall not be found guilty or convicted of +# the offence'. An application for that verdict which was dismissed, withdrawn or set +# aside is the opposite, and an assessment ordered under Part XX.1 is not a verdict. +# set aside -- s. 686(1)(a)/(2) above; s. 686(1)(b) lets the court 'dismiss the appeal', +# and s. 686(3) substitute a verdict, so a refused or pending motion to quash, or a +# sentence, order or another person's conviction quashed, leaves this conviction. +# no conviction -- s. 579(1) stays proceedings on the Attorney General's direction and +# s. 579(2) lets them 'be recommenced ... within one year', so a stay on one count, a +# refused stay application or a recommenced proceeding is no acquittal. +# appeal allowed / new trial -- s. 686(2)(b) and s. 686(4)(b)(i); an appeal allowed as to +# the sentence, on another count or in another file leaves the conviction, and a motion +# for a new trial that was denied is not a new trial. +# A clause that is nothing but an empty or negative FORM VALUE ('NCR: no', 'NCR - not +# applicable', 'Stay of proceedings: --'): the label names the reading and the value refuses +# it, so it contradicts every reading. Anchored, so it never reads a value inside a sentence +# ('found not criminally responsible; no criminal record entered' is no refusal). +_CANADA_EMPTY_VALUE = r"|^(?:no|n|na|n\.a\.|none|nil|not applicable|unknown|-{1,2}|\?)$" +# ---- The OTHER clauses: an allowlist too (K1, 2026-09-23) -------------------------------- +# J1 read the naming clause on an allowlist and cleared every OTHER clause on a +# hand-written DENYLIST of 40-60 alternatives -- the shape the RUNBOOK records six earlier +# rounds failing on. The outcomes lens measured it: 157 of 265 dispositions still settled +# leniently, every one of them on a second clause whose wording nobody had listed -- a term +# in WEEKS ('acquitted; 6 weeks'), a sentence or ancillary order that can only follow a +# finding of guilt ('acquitted; CSO', 'acquitted; time served', 'acquitted; restitution +# ordered'), a clause saying the charge is LIVE ('stay of proceedings; bench warrant +# issued'), a reversal synonym ('conviction quashed; later annulled'), a count or docket +# scope ('acquitted; ct 2', 'acquitted; docket 1234') or a relief that presupposes the +# conviction ('acquitted; record suspension revoked 2019') -- J1-L1, L4, L5, L6, L7, L9. +# The second half is now an allowlist as well: a reading settles only where every other +# clause is one the grammar can PLACE beside it (_CANADA_COMPATIBLE_CLAUSE) -- a bare date, +# a court, a statute or citation reference, a bare offence name, a style of cause, or +# another of the five outcomes' own clause where the Criminal Code writes the two together +# (the keep, s. 686(2)). Anything the grammar cannot place leaves the reading NOT SETTLED, +# so a wording nobody thought of can no longer clear the test. The denylists stay as a +# second, independent guard, and the vocabulary below is spliced into ALL FIVE of them so +# they cannot drift apart again. +# The custodial and sentencing words are DERIVED from the tables screening already reads a +# written sentence with -- screening._SENT_UNITS and _CANADA_SENTENCE_WORDS -- so a term in +# weeks or in hours cannot be read by one reader and missed by the other (J1-L1). +_SENT_UNIT_LONG = "|".join(sorted((u for u in _SCREENING_SENT_UNITS if len(u) > 1), + key=len, reverse=True)) +_SENT_UNIT_SHORT = "|".join(sorted(u for u in _SCREENING_SENT_UNITS if len(u) == 1)) +# every unit screening reads a term in; the one-letter forms only where a number precedes +# them, so an ordinary word is never read as a unit +_CANADA_TERM_UNIT = rf"\b(?:{_SENT_UNIT_LONG})\b|\b\d+\s*(?:{_SENT_UNIT_SHORT})\b" +_CANADA_TERM_WRITTEN = re.compile( + rf"\b\d+(?:\.\d+)?\s*(?:{_SENT_UNIT_LONG}|{_SENT_UNIT_SHORT})\b|\btime served\b") +# the sentence words rehab itself reads a Canadian sentence with, and the orders a Canadian +# court may make only on a finding of guilt: a conditional sentence order is available only +# 'if a person is convicted of an offence' (Criminal Code s. 742.1), and community service, +# restitution, the victim surcharge, a DNA order, a s. 109 weapons prohibition, a SOIRA +# order and forfeiture are all consequences of one (J1-L5) +_CANADA_SENTENCE_WORD_CONTRA = "|".join(p.pattern for p, _ in _CANADA_SENTENCE_WORDS) +_CANADA_TERM_CONTRA = ( + rf"|{_CANADA_TERM_UNIT}|{_CANADA_SENTENCE_WORD_CONTRA}" + r"|\bhrs?\b|\bhours?\b|\bimprisonment\b|\bjail\b|\bcustody\b|\bpenitentiary\b" + r"|\bprison\b|\bincarcerat\w*|\bsentenc\w*|\bfine[ds]?\b|\bprobation\b|\bparole\b" + r"|\bconcurrent\b|\bconsecutive\b|\btime served\b|\bcso\b|\bcommunity service\b" + r"|\bcsw\b|\brestitution\b|\bsurcharge\b|\bdna order\b|\bsoira\b|\bprohibition\b" + r"|\bforfeit\w*") +# the charge has not ended: Criminal Code s. 579(2) lets proceedings stayed under s. 579(1) +# 'be recommenced ... within one year', and a warrant, a remand, an adjournment or a next +# appearance says the matter is still before the court (J1-L6) +_CANADA_LIVE_CONTRA = ( + r"|\bwarrants?\b|\bbefore the court\b|\badjourn\w*|\bnext appearance\b|\bremand\w*" + r"|\bto be spoken to\b|\bcontinues?\b|\bcontinuing\b|\bset over\b|\bbail\b" + r"|\bpending\b|\boutstanding\b|\bat large\b|\btrial date\b") +# the outcome did not survive: a court of appeal may 'substitute the verdict that in its +# opinion should have been found' (s. 686(3)) and may 'allow the appeal, set aside the +# verdict and ... enter a verdict of guilty' (s. 686(4)(b)(ii)), and a record writes that +# event in many words -- annulled, rescinded, nullified, revoked, undone (J1-L7) +_CANADA_UNDONE_CONTRA = ( + r"|\breinstat\w*|\brestor\w*|\baffirm\w*|\bupheld\b|\bstands?\b|\bsubstitut\w*" + r"|\bannul\w*|\brescind\w*|\bnullif\w*|\brevok\w*|\bdid not survive\b|\bundone\b" + r"|\bset aside\b|\bsets aside\b|\bquash\w*|\bvacat\w*|\boverturn\w*|\breversed\b") +# the clause is scoped to another count, information, indictment, docket, file or matter -- +# s. 686(1)(b)(i) itself contemplates an appellant 'not properly convicted on a count or +# part of the indictment [but] properly convicted on another count' (J1-L9). The existing +# _CANADA_OTHER_COUNTS grammar is spliced in, with the abbreviations a docket entry writes. +_CANADA_SCOPE_CONTRA = ( + rf"|{_CANADA_OTHER_COUNTS.pattern}" + r"|\bcounts?\b|\bcts?\.?\b|\bcharges?\b|\binformations?\b|\bindictments?\b" + r"|\bdockets?\b|\bfiles?\b|\bmatters?\b|\bco-accused\b|\bco-defendants?\b") +# a relief PRESUPPOSES the conviction it would suspend: Criminal Records Act s. 2.1(1) gives +# the Parole Board of Canada 'exclusive jurisdiction ... to order, refuse to order or revoke +# a record suspension', s. 4(1) counts the wait 'after the expiration according to law of +# any sentence ... imposed for an offence' and s. 4.1(1) suspends 'an applicant's record in +# respect of an offence'. IRPA s. 36(3)(b) excepts a conviction only where the record +# suspension 'has not been revoked or ceased to have effect', so a revoked, refused or +# ceased relief is the case the officer must NOT read as no ground (J1-L4). +_CANADA_RELIEF_CONTRA = ( + r"|\brecord suspensions?\b|\brecord susp\b|\bsuspension du casier\b|\bpardon\w*" + r"|\bpardonn\w*|\brs\b|\br\.s\.|\br[eé]habilitation\b|\bclemency\b|\bcommutation\b" + r"|\bexpung\w*|\bparole board\b") +# the five tables' shared floor: every one of them is a superset of this string +_CANADA_SHARED_CONTRA = (_CANADA_TERM_CONTRA + _CANADA_LIVE_CONTRA + _CANADA_UNDONE_CONTRA + + _CANADA_SCOPE_CONTRA + _CANADA_RELIEF_CONTRA) +# What a clause BESIDE a settled outcome may be, and nothing else. Each alternative says +# nothing about how the charge ended: a date; a court; a statute, section or citation +# reference (the clause split breaks 's. 672.11' into 's', '672' and '11', so a bare +# section number is one of these); a style of cause; a bare offence name; how the offence +# was prosecuted. +_CANADA_DATE_TEXT = ( + r"(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{2}|\d{1,2}/\d{1,2}/\d{2,4}" + r"|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.? \d{1,2},? \d{4}" + r"|\d{1,2} (?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.? \d{4}" + r"|\d{4})") +_CANADA_ACT_NAME = ( + r"(?:the )?(?:criminal code(?: of canada)?|c\.?c\.?c?\.?|cdsa" + r"|controlled drugs and substances act|cannabis act|youth criminal justice act|ycja" + r"|criminal records act|immigration and refugee protection act|irpa" + r"|excise act(?:,? 2001)?|customs act|food and drugs act|charter)") +# the number is optional: the clause split breaks 's. 672.11' into 's', '672' and '11' +_CANADA_SECTION = (r"(?:ss?\.?|sections?|subsections?|paras?\.?|paragraphs?|arts?\.?)" + r"(?: ?\d{1,4}(?:\.\d+)*(?:\([0-9a-z]+\))*[a-z]?)?") +# a bare offence name: the charge the entry is about, named again beside its outcome +# ('acquitted; assault'). A name this grammar does not carry is not a licence to settle -- +# it simply leaves the reading NOT SETTLED, which is the strict side. +_CANADA_OFFENCE_NAME = ( + r"(?:simple |aggravated |domestic |common |attempted |possession of |trafficking in )?" + r"(?:assaults?|theft(?: under| over)?|robbery|burglary|break and enter|fraud|forgery" + r"|mischief|arson|trespass\w*|harassment|uttering threats|impaired driving" + r"|impaired operation|dangerous driving|driving over 80|possession|trafficking" + r"|production|importing|smuggling|shoplifting|obstruction|resisting arrest" + r"|failure to appear|failure to comply|breach of probation|breach of recognizance" + r"|murder|manslaughter|kidnapping|sexual assault|extortion|conspiracy|perjury" + r"|weapons? offence|firearms? offence|drug offence)" + r"(?: (?:of|in|with|under|over) (?:[a-z’'$\d,.]+ ?){1,4})?") +_CANADA_COURT = ( + r"(?:[a-z.'’]+ ){0,3}" + r"(?:courts? of appeal|court of justice|superior court(?: of justice)?|provincial court" + r"|supreme court(?: of canada)?|courts? of (?:queen|king)'?s bench|divisional court" + r"|youth justice court|summary conviction appeal court|review board" + r"|ocj|oncj|scj|onca|bcca|abca|skca|mbca|nsca|qcca|scc)" + r"(?: (?:of|for) (?:[a-z.'’]+ ?){1,4})?") +_CANADA_COMPATIBLE_CLAUSE = re.compile( + # a bare date + rf"(?:(?:on|in|dated|date|as of|entered|filed) )?{_CANADA_DATE_TEXT}" + # a court, with or without a preposition + rf"|(?:(?:in|at|before|by|from|per) )?(?:the )?{_CANADA_COURT}" + # a statute, section or citation reference, and the fragments the clause split leaves + rf"|(?:(?:under|pursuant to|contrary to|per|re|see|cf) )?(?:the )?" + rf"(?:{_CANADA_ACT_NAME}(?: {_CANADA_SECTION})?|{_CANADA_SECTION}(?: of {_CANADA_ACT_NAME})?" + rf"|\d{{1,4}}(?:\.\d+)*(?:\([0-9a-z]+\))*[a-z]?)" + # a style of cause + r"|r\.? ?v\.? ?[a-z.'’ -]+" + # a bare party -- what the routing leaves where it cut what was said about them + # ('acquitted; Crown appeal dismissed' keeps the acquittal and the word 'crown'); a + # party's name alone says nothing about how the charge ended + r"|(?:the |his |her |their )?(?:crown|attorney general|a\.?g\.?|prosecutor|prosecution" + r"|accused|offender|defendant|appellant|respondent|applicant|informant|court)" + # how the offence was prosecuted + r"|(?:proceeded |prosecuted )?(?:summarily|by indictment|by way of indictment" + r"|by summary conviction|indictable|hybrid|dual procedure)" + # a bare offence name: the offence the entry is about, named again beside its outcome + rf"|(?:(?:for|on|re) )?(?:an? |the )?{_CANADA_OFFENCE_NAME}") +# Criminal Code s. 672.54: where a verdict of not criminally responsible on account of +# mental disorder is rendered, the court or Review Board makes a DISPOSITION -- 'direct that +# the accused be discharged absolutely', 'discharged subject to such conditions', or +# 'detained in custody in a hospital'. That disposition is the NCR verdict's own +# consequence, not a sentence and not a contradiction of it (s. 672.35: 'the accused shall +# not be found guilty or convicted of the offence'), so the NCR reading keeps it. +_CANADA_REVIEW_BOARD_CLAUSE = re.compile( + r"(?:(?:an?|the) )?" + r"(?:absolute(?:ly)? discharge|conditional(?:ly)? discharge|discharged absolutely" + r"|discharged conditionally|detention order|detained in custody in a hospital" + r"|hospital order|custody order|disposition|no criminal record entered" + r"|(?:[a-z.'’]+ )?review board(?: disposition| hearing| order)?)" + r"(?: (?:by|from|of) the(?: [a-z.'’]+)? review board)?" + r"(?: (?:under|pursuant to)(?: the)?(?: criminal code)?(?: s\.?| section)?" + r" ?672(?:\.\d+)?(?:\([0-9a-z]+\))*)?" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +_CANADA_NCR_CLAUSE = re.compile( + r"(?:(?:the )?(?:accused|offender|defendant|appellant|young person|he|she|they) )?" + r"(?:(?:was|were|is|are|be|been) )?" + r"(?:(?:found|declared|adjudged|held|rendered|entered) )?" + r"(?:(?:a |the )?verdict of )?" + r"(?:not criminally responsible|ncr|ncrmd)" + r"(?: on account of mental disorder)?" + r"(?: (?:verdict|finding))?" + r"(?: (?:was |were )?(?:rendered|entered|returned))?" + r"(?: (?:under|pursuant to)(?: the)?(?: criminal code)?(?: s\.?| section)?" + r"(?: ?[0-9][0-9a-z()]*)?)?" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +_CANADA_NCR_CONTRA = re.compile( + r"\bconvict\w*|\bguilty\b|\bplea\b|\brefus\w*|\bdeni(?:ed|es|al)\b|\bdismiss\w*" + r"|\breject\w*|\babandon\w*|\bwithdraw\w*|\bwithdrawn\b|\bstruck\b|\bdeclin\w*" + r"|\bnot established\b|\bnot made out\b|\bnot pursued\b|\bset aside\b|\bsets aside\b" + r"|\bquash\w*|\bvacat\w*|\boverturn\w*|\breversed\b|\brescind\w*|\bappeal\w*" + r"|\bnew trial\b|\bretrial\b|\bre-trial\b|\btrial\b|\bcounts?\b|\bimprisonment\b" + r"|\bjail\b|\bpenitentiary\b|\bprison\b|\bincarcerat\w*|\bsentenc\w*|\bmonths?\b" + r"|\byears?\b|\bdays?\b|\bfine\b|\bprobation\b|\bapplication\b|\bmotion\b" + r"|\bassessment\b|\bunfit\b|\bfit\b|\bco-accused\b|\bpending\b|\boutstanding\b" + r"|\badjourn\w*|\bremit\w*" + _CANADA_SHARED_CONTRA + _CANADA_EMPTY_VALUE) +_CANADA_SET_ASIDE_CLAUSE = re.compile( + # s. 686(2) in one clause: 'appeal allowed and the conviction quashed'. The prefix reads + # only an appeal ALLOWED -- never one dismissed, refused or allowed as to the sentence. + r"(?:(?:the |his |her |their )?appeals? (?:was |were |is |has been )?" + r"(?:allowed|granted|successful|succeeded) and )?" + r"(?:(?:the |his |her |their )?" + r"(?:convictions?|verdicts?|guilty verdict|findings? of guilt|judgments?|judgements?" + r"|charges?|informations?|indictments?) )?" + r"(?:(?:was|were|is|are|has been|have been|been) )?" + r"(?:quashed|reversed|overturned|set aside|vacated)" + r"(?: (?:on|upon) appeal)?" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +# The s. 686(2) KEEP, and nothing wider (K1, 2026-09-23; J1-L2). s. 686(2) is about the +# CONVICTION alone -- 'Where a court of appeal allows an appeal under paragraph (1)(a), it +# shall quash the conviction and (a) direct a judgment or verdict of acquittal to be +# entered; or (b) order a new trial' -- so only a clause whose noun IS the conviction is one +# outcome written in two clauses. A verdict, judgment, finding of guilt, charge, information +# or indictment set aside beside an acquittal or an NCR verdict is the OPPOSITE: s. 676(1)(a) +# lets the Attorney General appeal 'against a judgment or verdict of acquittal or a verdict +# of not criminally responsible on account of mental disorder', and s. 686(4)(b) lets the +# court 'allow the appeal, set aside the verdict and (i) order a new trial, or (ii) ... enter +# a verdict of guilty'. The noun is MANDATORY here, so a nounless 'quashed', 'reversed' or +# 'set aside' clause is a contradiction, never a keep. +_CANADA_CONVICTION_QUASHED_CLAUSE = re.compile( + r"(?:(?:the |his |her |their )?appeals? (?:was |were |is |has been )?" + r"(?:allowed|granted|successful|succeeded) and )?" + r"(?:the |his |her |their )?convictions? " + r"(?:(?:was|were|is|are|has been|have been|been) )?" + r"(?:quashed|reversed|overturned|set aside|vacated)" + r"(?: (?:on|upon) appeal)?" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +_CANADA_SET_ASIDE_CONTRA = re.compile( + r"\bconvict\w*|\bguilty\b|\bplea\b|\brefus\w*|\bdeni(?:ed|es|al)\b|\bdismiss\w*" + r"|\breject\w*|\babandon\w*|\bwithdraw\w*|\bwithdrawn\b|\bstruck\b|\bdeclin\w*" + r"|\breinstat\w*|\brestor\w*|\bre-enter\w*|\bre-entered\b|\bentered\b|\baffirm\w*" + r"|\bupheld\b|\bstands?\b|\bsubstituted\b|\bmotion\b|\bapplications?\b|\bpetitions?\b" + r"|\bleave\b|\bnotice\b|\brequests?\b|\bcertiorari\b|\bcounts?\b|\bimprisonment\b" + r"|\bjail\b|\bcustody\b|\bpenitentiary\b|\bprison\b|\bincarcerat\w*|\bsentenc\w*" + r"|\bmonths?\b|\byears?\b|\bdays?\b|\bfine\b|\bprobation\b|\bco-accused\b" + r"|\bappeal\w*|\bpending\b|\boutstanding\b|\bnew trial\b|\bretrial\b|\bre-trial\b" + # an appeal allowed only IN PART, or AS TO something, leaves the rest of the judgment + # (J1-L3): s. 686(1)(b)(i) dismisses the appeal on the counts properly convicted + r"|\bin part\b|\bas to\b" + + _CANADA_SHARED_CONTRA + _CANADA_EMPTY_VALUE) +_CANADA_NO_CONVICTION_CLAUSE = re.compile( + r"(?:(?:the |his |her |their )?" + r"(?:accused|offender|defendant|appellant|young person|he|she|they|charges?" + r"|informations?|indictments?|proceedings|crown) )?" + r"(?:(?:was|were|is|are|has been|have been|been) )?" + r"(?:(?:found|entered|directed|declared) )?" + r"(?:acquitted|acquittal|not guilty|withdrawn|withdrew|dismissed|dismissal" + r"|stay of proceedings|proceedings stayed|stayed proceedings|no conviction)" + r"(?: (?:the )?charges?)?" + r"(?: (?:was |were )?(?:entered|directed|recorded))?" + r"(?: (?:on|of) all (?:counts|charges))?" + r"(?: by the crown)?" + r"(?: (?:after|at)(?: a| the)? (?:trial|retrial|re-trial|new trial))?" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?") +_CANADA_NO_CONVICTION_CONTRA = re.compile( + r"\bconvict\w*|\bguilty\b|\bplea\b|\bpleaded\b|\bpled\b|\brefus\w*" + r"|\bdeni(?:ed|es|al)\b|\breject\w*|\bdeclin\w*|\bstruck\b|\bset aside\b" + r"|\bsets aside\b|\bquash\w*|\bvacat\w*|\boverturn\w*|\breversed\b|\breinstat\w*" + r"|\brestor\w*|\brecommenc\w*|\brelaid\b|\bnew (?:information|charge|trial)\b" + r"|\bretrial\b|\bre-trial\b|\bmotion\b|\bapplications?\b|\bpetitions?\b|\bleave\b" + r"|\brequests?\b|\bnotice\b|\bcounts?\b|\bimprisonment\b|\bjail\b|\bcustody\b" + r"|\bpenitentiary\b|\bprison\b|\bincarcerat\w*|\bsentenc\w*|\bmonths?\b|\byears?\b" + r"|\bdays?\b|\bfine\b|\bprobation\b|\bco-accused\b|\bappeal\w*|\bpending\b" + r"|\boutstanding\b|\bstands?\b|\baffirm\w*|\bupheld\b|\bcorporation\b|\bfile\b" + + _CANADA_SHARED_CONTRA + _CANADA_EMPTY_VALUE) +_CANADA_APPEAL_ALLOWED_CLAUSE = re.compile( + r"(?:(?:the |his |her |their )?appeals?" + r"(?: (?:was|were|is|has been|have been))?" + r" (?:allowed|granted|successful|succeeded)" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?" + r"|allow(?:ed|ing) (?:the |his |her |their )?appeals?)") +_CANADA_APPEAL_ALLOWED_CONTRA = re.compile( + r"\bconvict\w*|\bguilty\b|\bplea\b|\brefus\w*|\bdeni(?:ed|es|al)\b|\bdismiss\w*" + r"|\breject\w*|\babandon\w*|\bwithdraw\w*|\bwithdrawn\b|\bdeclin\w*|\breinstat\w*" + r"|\brestor\w*|\baffirm\w*|\bupheld\b|\bstands?\b|\bsubstituted\b|\bvaried\b" + r"|\bas to\b|\bin part\b|\bremit\w*|\bcounts?\b|\bimprisonment\b|\bjail\b" + r"|\bcustody\b|\bpenitentiary\b|\bprison\b|\bincarcerat\w*|\bsentenc\w*" + r"|\bmonths?\b|\byears?\b|\bdays?\b|\bfine\b|\bprobation\b|\bco-accused\b" + r"|\bcivil\b|\bfile\b|\bcross-appeal\b" + + _CANADA_SHARED_CONTRA + _CANADA_EMPTY_VALUE) +_CANADA_NEW_TRIAL_CLAUSE = re.compile( + r"(?:(?:a |the )?(?:new trial|retrial|re-trial)" + r"(?: (?:was|were|is|has been|have been))?" + r" (?:ordered|directed|granted)" + r"(?: (?:on |in )?(?:\d{4}-\d{2}-\d{2}|\d{4}))?" + r"|(?:ordered|directed|granted) (?:a |the )?(?:new trial|retrial|re-trial))") +_CANADA_NEW_TRIAL_CONTRA = re.compile( + r"\bconvict\w*|\bguilty\b|\bplea\b|\brefus\w*|\bdeni(?:ed|es|al)\b|\bdismiss\w*" + r"|\breject\w*|\babandon\w*|\bwithdraw\w*|\bwithdrawn\b|\bstruck\b|\bdeclin\w*" + r"|\breinstat\w*|\brestor\w*|\baffirm\w*|\bupheld\b|\bstands?\b|\bmotion\b" + r"|\bapplications?\b|\bpetitions?\b|\brequests?\b|\bcounts?\b|\bimprisonment\b" + r"|\bjail\b|\bcustody\b|\bpenitentiary\b|\bprison\b|\bincarcerat\w*|\bsentenc\w*" + r"|\bmonths?\b|\byears?\b|\bdays?\b|\bfine\b|\bprobation\b|\bco-accused\b" + r"|\bappeal\w*|\bpending\b|\boutstanding\b|\bstay\b|\bstayed\b|\bcivil\b|\bfile\b" + + _CANADA_SHARED_CONTRA + _CANADA_EMPTY_VALUE) +# The routing's OWN outcome words (screening.record_order writes them into the words it +# hands on; they are CanLex's vocabulary, not the officer's). The only one that means the +# charge ended without a conviction is 'not prosecuted' -- the prosecution declined to try +# it again ('convicted; new trial ordered; Crown declined to retry'). +_CANADA_ROUTED_NO_CONVICTION = re.compile(r"not prosecuted") +# reading -> (the words, the clause the reading may be, what no other clause may say, +# the other readings' own clauses that are no contradiction -- Criminal Code s. 686(2) -- +# and what the NAMING clause must itself be for those keeps to apply at all). The last is +# K1's (J1-L2): s. 686(2) pairs an acquittal or a new trial with the CONVICTION quashed, so +# the set-aside reading keeps them only where it is the conviction that was set aside. +_CANADA_READING_ALLOW = { + # the Part XX.1 disposition s. 672.54 makes the court or Review Board give INSTEAD of a + # sentence is the NCR verdict's own consequence, not a contradiction of it + "ncr": (_CANADA_NCR, _CANADA_NCR_CLAUSE, _CANADA_NCR_CONTRA, + (_CANADA_REVIEW_BOARD_CLAUSE,), None), + # a new trial is NO keep for the set-aside: s. 686(2) quashes the conviction and orders + # EITHER an acquittal OR a new trial, and where it is a new trial the charge is live + # again -- the new-trial reading answers it (pending, NOT SETTLED), not this one + # an appeal ALLOWED is how s. 686(2) quashes the conviction, so an appeal clause that is + # nothing but the allowance keeps it ('appeal allowed; conviction quashed'); the clause + # grammar cannot read an appeal dismissed or one allowed as to the sentence only + # K1 (J1-L2): and only where the clause says it is the CONVICTION that was set aside -- + # 'acquitted; verdict set aside on appeal' is the acquittal destroyed (s. 676(1)(a), + # s. 686(4)(b)), not s. 686(2)'s conviction quashed with an acquittal entered + "set-aside": (_CANADA_SET_ASIDE, _CANADA_SET_ASIDE_CLAUSE, _CANADA_SET_ASIDE_CONTRA, + (_CANADA_NO_CONVICTION_CLAUSE, _CANADA_APPEAL_ALLOWED_CLAUSE), + _CANADA_CONVICTION_QUASHED_CLAUSE), + # K1 (J1-L2): the keep beside a no-conviction word and beside a new trial is the + # CONVICTION quashed and nothing wider -- an acquittal or an NCR verdict set aside on + # appeal is that verdict destroyed (s. 676(1)(a), s. 686(4)(b)), not s. 686(2) + "no-conviction": (_CANADA_NO_CONVICTION, _CANADA_NO_CONVICTION_CLAUSE, + _CANADA_NO_CONVICTION_CONTRA, + (_CANADA_CONVICTION_QUASHED_CLAUSE,), None), + "appeal-allowed": (_CANADA_APPEAL_ALLOWED, _CANADA_APPEAL_ALLOWED_CLAUSE, + _CANADA_APPEAL_ALLOWED_CONTRA, (), None), + "new-trial": (_CANADA_NEW_TRIAL, _CANADA_NEW_TRIAL_CLAUSE, _CANADA_NEW_TRIAL_CONTRA, + (_CANADA_CONVICTION_QUASHED_CLAUSE,), None), +} + + +def canada_reading_stands(reading, disposition): + """Whether one of the five Canadian outcome READINGS is the disposition's + own outcome (the ALLOWLIST of J1, 2026-09-23): exactly one clause names its + words, that clause is nothing but the outcome itself, and no other clause + contradicts it -- except a clause that is another of these outcomes' own + clause where Criminal Code s. 686(2) writes the two together ('conviction + quashed; new trial ordered'). False leaves the reading settling nothing: + the conviction words decide, and where they do not the entry is NOT SETTLED + and stays on the record's counts and its IRPR s. 17 clock. + + K1 (2026-09-23): the second half is an ALLOWLIST too. Every other clause + must be one the grammar can PLACE beside the outcome -- a bare date, a + court, a statute or citation reference, a style of cause, a bare offence + name, or a keep -- as well as carrying nothing the reading's contra table + refuses. A clause the grammar cannot place leaves the reading NOT SETTLED, + so a wording no list happens to carry can no longer clear the test + ('acquitted; 6 weeks', 'acquitted; CSO', 'stay of proceedings; bench + warrant issued', 'conviction quashed; later annulled', 'acquitted; ct 2', + 'acquitted; record suspension revoked 2019' all settled NO CONVICTION).""" + phrase, clause_ok, contra, keep, keep_if = _CANADA_READING_ALLOW[reading] + clauses = canada_clauses(disposition) + named = [c for c in clauses if phrase.search(c)] + if len(named) != 1 or not clause_ok.fullmatch(named[0]): + return False + if keep_if is not None and not keep_if.fullmatch(named[0]): + keep = () + for clause in clauses: + if clause is named[0] or any(k.fullmatch(clause) for k in keep): + continue + if contra.search(clause) or not _CANADA_COMPATIBLE_CLAUSE.fullmatch(clause): + return False + return True + + +def _canada_cue_settles(t, raw=None): + """Whether the shared non-conviction cue table may settle a CANADIAN entry + (J1, 2026-09-23). The table was compiled for US records and reads a word + ANYWHERE in the words ('appeal', 'dismissed', 'pending', 'stayed', + 'quashed', 'reversed'), so on a Canadian entry it settles NO CONVICTION + only where the routed words are ONE clause the Canadian grammar can place: + the record's own no-conviction outcome clause, or the routing's own + 'not prosecuted'. Everything else is NOT SETTLED, and the entry stays on + the record's counts and its IRPR s. 17 clock. + + K1 (J1-L8): 'not prosecuted' is the ROUTING's word, and the rewrite that + writes it replaces the WHOLE record with it. Where the record's own words + (raw) stated a term of imprisonment or scoped an outcome to another count, + those words are gone -- 'convicted; 2 years; new trial ordered; Crown + declined to proceed' settled NO CONVICTION with the 2 years erased -- so + the entry is NOT SETTLED, not a settled 'no conviction'.""" + clauses = canada_clauses(t) + if len(clauses) != 1: + return False + if _CANADA_NO_CONVICTION_CLAUSE.fullmatch(clauses[0]): + return True + if not _CANADA_ROUTED_NO_CONVICTION.fullmatch(clauses[0]): + return False + return not (raw and (_CANADA_TERM_WRITTEN.search(raw) + or _CANADA_OTHER_COUNTS.search(raw))) + + +def canada_disposition(disposition, sentence=None, relief=None, own=None, offence_date=None, + conviction_date=None, today=None, later=None, completed=None): + """What a Canadian entry's words (and the officer's canada_sentence) say. + Returns {conviction: True | False | None, sentence, sentence_from + ('parameter' | 'words' | None), record_suspension, under_appeal}. A + discharge is not a conviction; a sentence, a record suspension or the + record's conviction words mean one; a set-aside means none; an acquittal, + withdrawal, stay or dismissal means none -- unless conviction words stand + beside it, when it names other counts (a conviction) or is unsettled; an + appeal allowed is unsettled; anything else is unsettled. A decided appeal + that left the conviction standing ('appeal dismissed', 'conviction + affirmed on appeal') is a conviction, not a dismissal and not a pending + appeal (screening.strip_appeal_upheld). + + The words are read AFTER the routing the foreign chain applies + (screening.record_order -- the code audit of 2026-09-21, HELD): a refused + relief ('pardon denied', 'motion to set aside conviction dismissed') grants + nothing and the conviction stands; a not-guilty PLEA followed by conviction + words is the conviction; 'appeal allowed, sentence varied' leaves it standing. + A record suspension or pardon the words say is not in force -- refused, + pending, revoked, ceased to have effect (record_suspension_in_force) -- is no + s. 36(3)(b) bar: record_suspension is False and record_suspension_note says + so. Before this, 'convicted; pardon denied' answered NOT A GROUND and + 'convicted; motion to set aside conviction dismissed' NO CONVICTION. + A pardon under the royal prerogative of mercy or Criminal Code s. 748 + (prerogative_pardon) is no record suspension: NOT SETTLED, prerogative_pardon + True and PREROGATIVE_PARDON_TEXT as the note (the verification fixes of + 2026-09-22, LEGAL-6; G2: the conviction stays a conviction, relief_open + 'prerogative'). + + H3 (2026-09-22): the two words that decide the ANSWER -- a discharge + (Criminal Code s. 730(3): not a conviction) and a youth sentence (IRPA + s. 36(3)(e)(iii): no ground) -- are read on an ALLOWLIST + (canada_outcome_stands): the phrase settles only when it is the + disposition's own outcome clause. Where it is not, it is neither the answer + nor the sentence, and the conviction words decide: 'convicted; request for + a conditional discharge refused; 8 months imprisonment' was NOT A + CONVICTION and 'convicted as an adult; application for a youth sentence + refused; 3 years imprisonment' NOT A GROUND, each leaving the record's + s. 17 clock (S17-INT-1, NEW-1); both are now the conviction they name. + The officer's own canada_sentence parameter still wins over the words. + + J1 (2026-09-23): the five outcome readings that still keyed on a word + ANYWHERE -- the NCR verdict (Criminal Code s. 672.35), a set-aside, the + no-conviction words (acquittal, withdrawal, dismissal, stay of + proceedings), an appeal allowed and a new trial ordered -- are read on the + same ALLOWLIST (canada_reading_stands): each settles only as the + disposition's own outcome clause, with no other clause refusing, + reversing, negating, appealing or scoping it to another count, file or + person. The shared non-conviction cue table is held to the same allowlist + on a Canadian entry. Where a reading does not stand it settles nothing: + 'conviction quashed on count 1; convicted on count 2; 2 years', + 'NCRMD finding set aside; conviction entered; 30 months', 'motion for a + stay of proceedings dismissed; sentenced to 90 days' and 'new trial refused + by the Court of Appeal; 2 years' answered NO CONVICTION and 'no s. 36 + ground on this matter'; they are now the conviction they name, or NOT + SETTLED with the deciding question, and either way they stay on the + record's counts and its IRPR s. 17 clock. + + G2 (2026-09-22): the relief is read on the allowlist grammar + (relief_reading), from the entry's own words (own: the disposition before a + relief key was added to it; default disposition) and the relief key + (relief) TOGETHER, and a relief that stands is then placed against the + entry: dated before the offence, the conviction or the sentence's completion + (completed), or after today, it is not this conviction's and is not in force + (REL-11); a 'pardon' dated after + 2012-03-12 is NOT SETTLED (AI-4); and when another conviction on the record + may be later than it (later: the entries, or 'other_record' -- CRA + ss. 7.2(a), 7(a)) it is NOT SETTLED too (REL-5). + + H2 (2026-09-22) opens three more readings: a relief dated before the + Criminal Records Act as the corpus carries it ('relief-before-cra', + G2-REL-B), an UNDATED 'pardon' or 'pardoned', whose date may fall after + 2012-03-12 like a dated one's ('pardon-undated', G2-REL-A), and an undated + record suspension on an entry that carries a date to place it against + ('relief-undated', G2-REL-E). relief_open names the NOT + SETTLED reading ('prerogative', 'pardon-after-2012', 'pardon-undated', + 'relief-before-cra', 'relief-undated', 'later-conviction'; + RELIEF_OPEN_TEXT as the note): each leaves the conviction a conviction + whose relief is open (F1 left a prerogative pardon's conviction None, and + the record's s. 17 clock dropped it); a relief key naming a record + suspension or pardon presupposes the conviction whether or not it stands; + relief_date is the date of a record suspension that stands. The + not-in-force note prints whenever the words name a record suspension or + pardon, or a shorthand for one ('RS'), that does not stand (REL-13).""" + from .screening import (disposition_cues, has_conviction_words, record_order, + strip_appeal_upheld) + raw = strip_appeal_upheld(" ".join((disposition or "").lower().split())) + routed, flags = record_order(disposition or "") + t = strip_appeal_upheld(" ".join(routed.lower().split())) + words = next((s for pat, s in _CANADA_SENTENCE_WORDS if pat.search(t)), None) + # H3 (S17-INT-1, NEW-1): a discharge or youth-sentence word settles the answer only + # when it is the disposition's own outcome clause (canada_outcome_stands, read on the + # record's own words). Where it does not, the word settles nothing: it is not the + # sentence either, and the conviction words below decide. + outcome_open = bool(words) and not canada_outcome_stands(words, raw) + if outcome_open: + words = None + sent = sentence or words + # the relief is read from the record's own words and the relief key (G2), never + # from the routed words (the routing cuts a refused relief clause) + reading = relief_reading(disposition if own is None else own, relief) + rs, relief_open = False, None + if reading["stands"] and relief_dates_stand(reading["date"], offence_date, + conviction_date, today, completed): + # H2 (2026-09-22): each reading the words leave open, strictest first -- a date + # before the Criminal Records Act (G2-REL-B), a 'pardon' whose date may fall after + # 2012-03-12, dated or not (AI-4, G2-REL-A), an undated record suspension the + # entry's own dates cannot place (G2-REL-E), then a later conviction (REL-5) + if relief_before_cra(reading): + relief_open = "relief-before-cra" + elif pardon_after_2012(reading): + relief_open = ("pardon-undated" if reading["date"] is None + else "pardon-after-2012") + elif relief_unplaced(reading, offence_date, conviction_date, completed): + relief_open = "relief-undated" + elif later: + relief_open = "later-conviction" + else: + rs = True + # a pardon under the royal prerogative of mercy or Criminal Code s. 748 is no record + # suspension: NOT SETTLED, each reading shown (the verification fixes of 2026-09-22, + # LEGAL-6 -- 'conditional pardon granted under the Royal prerogative of mercy' answered + # NOT A GROUND under s. 36(3)(b)) + prerogative = not rs and relief_open is None and prerogative_pardon(raw) + if prerogative: + relief_open = "prerogative" + named = bool(_CANADA_RECORD_SUSPENSION.search(raw)) + # K1 (J1-L4): the routing cuts a REFUSED relief clause, so the words the outcome + # readings read may no longer carry the relief the record named + relief_routed_out = named and not bool(_CANADA_RECORD_SUSPENSION.search(t)) + key = _rs_text(relief) + key_named = bool(key) and bool(_CANADA_RECORD_SUSPENSION.search(key) + or _CANADA_RS_ABBREVIATION.search(key) + or _CANADA_RS_RELIEF_WORDS.search(key)) + # the words named a record suspension or pardon (or its shorthand) that does not + # stand: say so + rs_note = (RELIEF_OPEN_TEXT[relief_open] if relief_open else + RECORD_SUSPENSION_NOT_IN_FORCE_TEXT + if not rs and (named or _CANADA_RS_ABBREVIATION.search(raw)) else None) + conv_words = has_conviction_words(t) + # a bare 'appeal allowed' stays unsettled on the Canadian rule (the set-aside + # words the routing writes for it are its own, not the record's). J1: the + # record's OWN words settle an appeal allowed only on the allowlist; the + # routing's own flag is unchanged. + allowed = (bool(_CANADA_APPEAL_ALLOWED.search(t)) + and canada_reading_stands("appeal-allowed", t)) or "appeal-allowed" in flags + ncr = (bool(_CANADA_NCR.search(t)) and canada_reading_stands("ncr", t) + and not conv_words) + # a new trial ordered with no later outcome: the routing leaves 'pending trial' + # (the audit follow-up of 2026-09-21 -- the routed words no longer carried the + # phrase, so 'convicted; appeal allowed; new trial ordered' fell through to NO + # CONVICTION; it is a live charge, NOT SETTLED) + pending = (bool(_CANADA_NEW_TRIAL.search(t)) + and canada_reading_stands("new-trial", t)) or ( + "new-trial" in flags and t.strip(" ;").startswith("pending trial")) + if ncr: + conv = False # Criminal Code s. 672.35 (round 4) + elif sent in ("absolute-discharge", "conditional-discharge"): + conv = False + elif rs or sent or relief_open: + # G2: a relief that is open -- a prerogative pardon (LEGAL-6), a 'pardon' after + # 2012-03-12 (AI-4), a record suspension a later conviction may have ended (REL-5) + # -- is a relief on a conviction: the conviction stays a conviction and only the + # relief is open (the tools read relief_open), so the record's s. 17 clock and its + # counts still read it. (F1 left a prerogative pardon's conviction None, and the + # s. 17 clock dropped it -- PREROG-2; routing more wordings there would have moved + # earliest applications earlier.) + conv = True + elif outcome_open: + # H3 (S17-INT-1, NEW-1): the words name a discharge or a youth sentence that is + # not the disposition's own outcome. Nothing in that wording can make the entry a + # non-conviction: the conviction words decide, and where they do not the entry is + # NOT SETTLED, which keeps it on the record's counts and its IRPR s. 17 clock. + conv = True if conv_words else None + elif key_named and not canada_reading_stands("no-conviction", t): + # G2: a relief key naming a record suspension or pardon (the Board, the Act or + # 'RS') presupposes the conviction, whether or not it stands -- as the key did when + # it was appended to the words and read (REL-2, REL-11, REL-12 no longer apply it) + # K1 (J1-L4): the presupposition is displaced only by a no-conviction reading that + # actually STANDS, never by the mere presence of the words -- 'acquitted; record + # suspension revoked 2019' answered NO CONVICTION while 'convicted; record + # suspension revoked 2019' answered the s. 44(1) report on the same relief. + conv = True + elif pending: + conv = None + elif named and (relief_routed_out or not canada_reading_stands("no-conviction", t)): + # a pardon or record suspension named -- in force or not -- presupposes the + # conviction it would suspend ('pardoned; pardon later ceased to have effect'); + # K1 (J1-L4) as above, and where the ROUTING cut the relief clause out of the words + # the reading reads ('acquitted; record suspension refused 2019' leaves 'acquitted' + # and the bare year), the reading is not reading the record: the presupposition + # stands, and a refused record suspension is the case IRPA s. 36(3)(b) will not + # except ('has not been revoked or ceased to have effect') + conv = True + elif _set_aside(raw if "appeal-allowed" in flags else t): + conv = False + elif allowed or pending: + conv = None + elif _no_conviction_words(t): + # J1 (2026-09-23): the no-conviction words settle NO CONVICTION only as the + # disposition's OWN outcome clause -- 'stay of proceedings on count 2; 6 months on + # count 1', 'acquitted on count 1; 2 years on count 2' and 'the application for a + # stay of proceedings under s. 24(1) was dismissed; 2 years' settled it before. + # Beside conviction words they read as they did (other counts: a conviction; + # otherwise NOT SETTLED). + conv = (True if conv_words and _CANADA_OTHER_COUNTS.search(t) + else None if conv_words + else False if canada_reading_stands("no-conviction", t) else None) + elif conv_words: + conv = True + elif disposition_cues(t) == "non-conviction": + # J1 (2026-09-23): the shared cue table is a word-anywhere reader ('appeal', + # 'dismissed', 'pending', 'stayed', 'quashed', 'reversed' ANYWHERE), compiled for + # US records. On a Canadian entry it settles NO CONVICTION only where the words are + # one clause the Canadian grammar can place (_canada_cue_settles): 'new trial + # refused by the Court of Appeal; 2 years' and 'the request for a re-trial was + # dismissed; 5 months' answered NO CONVICTION on it. Otherwise NOT SETTLED, and the + # entry keeps its s. 17 clock. + # K1 (J1-L8): and never where the routing's 'not prosecuted' rewrite dropped a + # term or a count scope the record's own words carried. + conv = False if _canada_cue_settles(t, raw) else None + else: + conv = None + return {"conviction": conv, "sentence": sent, + "sentence_from": "parameter" if sentence else ("words" if words else None), + "record_suspension": rs, "record_suspension_note": rs_note, + "prerogative_pardon": prerogative, "relief_open": relief_open, + "relief_later": list(later) if relief_open == "later-conviction" else None, + "relief_date": reading["date"] if rs else None, + "ncr": ncr, "pending": pending and conv is None, + "under_appeal": conv is not False and not allowed and _appeal_pending(t)} + + +def canada_std_branch(canada_branch, status): + """The screening tools' branch code for a Canadian answer.""" + if canada_branch in _CANADA_STD_BRANCH: + return _CANADA_STD_BRANCH[canada_branch] + if canada_branch == "not-settled": + return "depends" + if canada_branch == "none" and status == "permanent-resident": + return "not-inadmissible-pr" + return "none" + + +def canada_removal(canada_branch, status): + """(removal, provision) for a Canadian answer: the delegate's deportation + order for a foreign national on s. 36(1)(a)/(2)(a) (IRPR s. 228(1)(a)), + the Immigration Division for a permanent resident on s. 36(1)(a) (IRPR + s. 229(1)(c)); (None, None) when nothing is reportable.""" + if canada_branch not in ("36(1)(a)", "36(2)(a)", "36(2)(a)-two-offences"): + return None, None + if status == "permanent-resident": + return (("id-referral", "IRPR s. 229(1)(c)") if canada_branch == "36(1)(a)" + else (None, None)) + return "md-deportation-order", "IRPR s. 228(1)(a)" + + +_READING_WORDS = { + 0: {True: "maximum below 10 years", False: "maximum of 10 years or more"}, + 1: {"summary-only": "an offence that may only be prosecuted summarily", + "hybrid": "a hybrid or indictable offence"}, + 2: {"le6": "no term of imprisonment of more than six months", + "gt6": "a term of imprisonment of more than six months", + "counted": "the intermittent sentence counted as a term of imprisonment", + "not-counted": "the intermittent sentence not counted"}, +} +_READING_NEEDS = { + 0: {"fact": "canada_max_years", "parameter": "canada_max_years", + "question": "the maximum term the Canadian offence carried at the offence " + "date (s. 36(1)(a): at least 10 years?)"}, + 1: {"fact": "canada_offence_mode", "parameter": "canada_offence_mode", + "question": "may the offence only be prosecuted summarily, or is it hybrid " + "or indictable (s. 36(2)(a); s. 36(3)(a) deems a hybrid " + "indictable)?"}, + 2: {"fact": "custody_imposed_months", "parameter": "custody_imposed_months", + "question": "the term of imprisonment imposed, in months (s. 36(1)(a): " + "more than six months?)"}, +} +_INTERMITTENT_NEED = { + "fact": "intermittent-sentence", "parameter": None, + "question": "whether an intermittent sentence is 'a term of imprisonment of " + "more than six months' (not settled in CanLex)"} + + +_LIMB_WORDS = {"10-year": "the 10-year limb", "six-month": "the six-month limb"} +_APPEAL_WORDS = { + "barred": "no appeal (s. 64(1); 'at least six months', s. 64(2))", + "not-barred-by-s64": "the s. 64(1) bar does not apply (less than six months " + "punished in Canada, s. 64(2))", + "depends": "it turns on the custody imposed (s. 64(2))", + None: "no s. 64 question"} + + +def either_way_note(canada_branch, readings, common): + """The line for a Canadian answer every reading settles on the same ground + (audit of roadmap T2.2): which s. 36(1)(a) limbs each reading meets, and + the ones the answer and the draft allegation rest on.""" + def limbs(ls): + return " and ".join(_LIMB_WORDS[l] for l in ls) or "no limb" + each = "; ".join(f"if {rd['reading']}: {limbs(rd['canada_limbs'])}" for rd in readings) + return (f"SETTLED EITHER WAY -- IRPA s. {canada_branch} in every reading ({each}). " + f"The answer and the draft allegation rest on {limbs(common)}, which " + f"hold{'s' if len(common) == 1 else ''} in every reading.") + + +def assess_canadian(status="foreign-national", count="one", max_years=None, + mode=None, sentence=None, custody_months=None, + record_suspension=False, conviction=True, completed=None, + today=None, clean_record=True, summary_only=None, solely=None): + """A Canadian conviction under IRPA s. 36(1)(a)/(2)(a) (roadmap T2.2). + + max_years: the Canadian offence's maximum at the offence date (99 = life; + None = unknown); summary_only True settles 'below 10 years' without one. + mode: CANADA_MODES or None; sentence: CANADA_SENTENCES or None; + custody_months: the term of imprisonment imposed (None = unknown). + conviction: False for a charge that did not end in a conviction. + + Returns {conviction, canada_branch (CANADA_BRANCH_TEXT), canada_limbs, + branch (the screening tools' code), readings, needs, rehab (the assess() + result the answer rests on), status, provision, explanation, notes, + conditions, caveats, removal, removal_provision, iad_appeal, appeal_note, + custody_counted, earliest_deemed, either_way}. either_way lists the + readings of an answer every reading settles on the same ground (only the + limbs, or a permanent resident's appeal, differ); canada_limbs then holds + the limbs common to all of them.""" + pr = status == "permanent-resident" + base = {"conviction": conviction is not False, "canada_branch": None, + "canada_limbs": [], "branch": None, "readings": [], "either_way": [], "needs": [], + "rehab": None, "status": None, "provision": None, "explanation": "", + "notes": [], "conditions": [], "caveats": [CUSTODY_CAVEAT], + "removal": None, "removal_provision": None, "iad_appeal": None, + "appeal_note": None, "custody_counted": None, "earliest_deemed": None} + + def outcome(cb, explanation, provision, **kw): + out = dict(base, canada_branch=cb, branch=canada_std_branch(cb, status), + explanation=explanation, provision=provision) + out.update(kw) + return out + + if sentence in ("absolute-discharge", "conditional-discharge"): + return outcome("not-a-conviction", DISCHARGE_TEXT, "Criminal Code s. 730(3)", + conviction=False, caveats=[]) + if conviction is False: + return outcome("no-conviction", NOT_CONVICTED_TEXT, "IRPA s. 36(1)(a), (2)(a)", + conviction=False, caveats=[]) + if sentence == "contravention": + return outcome("excluded-contravention", CONTRAVENTION_TEXT, + "IRPA s. 36(3)(e)(i)", caveats=[]) + if sentence == "youth-sentence": + return outcome("excluded-youth", YOUTH_SENTENCE_TEXT, + "IRPA s. 36(3)(e)(ii)-(iii)", caveats=[]) + if record_suspension: + return outcome("record-suspension", RECORD_SUSPENSION_TEXT, + "IRPA s. 36(3)(b)", caveats=[]) + + notes = [] + intermittent = sentence == "intermittent" + if sentence == "conditional-sentence": + custody = 0.0 + notes.append(CONDITIONAL_SENTENCE_NOTE + + (f" (The {custody_months:g} months given are read as the " + "conditional sentence's length, not custody.)" + if custody_months else "")) + elif sentence in _NO_CUSTODY and custody_months is None: + custody = 0.0 + else: + custody = custody_months + if intermittent: + notes.append(INTERMITTENT_NOTE) + lt10 = None if max_years is None else max_years < 10 + if lt10 is None and summary_only: + lt10 = True + + lt_opts = [lt10] if lt10 is not None else [True, False] + mode_opts = [mode] if (mode or pr) else ["summary-only", "hybrid"] + + def custody_opts(lt): + if intermittent and custody is not None and custody > 6: + return [("counted", custody), ("not-counted", 0.0)] + if custody is None: + return [("le6", 0.0), ("gt6", 7.0)] if lt else [(None, None)] + return [(None, custody)] + + combos = [] + for lt in lt_opts: + for m in mode_opts: + for ctag, c in custody_opts(lt): + r = assess("canada-conviction", lt, count, 0, clean_record, + status=status, completed=completed, today=today, + canada_offence_mode=m, custody_imposed_months=c, + solely=solely) + combos.append(((lt, m, ctag), r)) + + def key(r): + return (r["canada_branch"], tuple(r["canada_limbs"]), r["status"]) + + distinct = list(dict.fromkeys(key(r) for _, r in combos)) + # an intermittent term over six months is read both ways: no one figure + # counts toward the six-month limb + counted = None if (intermittent and custody is not None and custody > 6) else custody + + def own_needs(r): + return [_READING_NEEDS[2] if n == "custody_imposed_months" else _READING_NEEDS[1] + for n in dict.fromkeys(r["needs"])] + if len(distinct) == 1: + r = combos[0][1] + removal, rprov = canada_removal(r["canada_branch"], status) + return outcome(r["canada_branch"], r["explanation"], r["provision"], + canada_limbs=r["canada_limbs"], rehab=r, status=r["status"], + notes=notes, conditions=r["conditions"], caveats=r["caveats"], + needs=own_needs(r), + removal=removal, removal_provision=rprov, + iad_appeal=r["iad_appeal"], appeal_note=r["appeal_note"], + custody_counted=counted, earliest_deemed=r["earliest_deemed"]) + + # Not settled: which inputs change the answer (an input matters when the + # set of answers differs from one of its values to another), and each + # reading. + options = [set(lt_opts), set(mode_opts), + {t for lt in lt_opts for t, _ in custody_opts(lt)}] + matter = set() + for d in range(3): + by_value = {} + for dims, r in combos: + by_value.setdefault(dims[d], set()).add(key(r)) + if len(by_value) > 1 and len({frozenset(v) for v in by_value.values()}) > 1: + matter.add(d) + readings = [] + for k in distinct: + group = [(dims, r) for dims, r in combos if key(r) == k] + r = group[0][1] + words = [] + for d in range(3): + vals = {dims[d] for dims, _ in group} + if d in matter and vals != options[d] and None not in vals: + words.append(" or ".join(_READING_WORDS[d][v] for v in sorted(vals, key=str))) + removal, rprov = canada_removal(r["canada_branch"], status) + readings.append({"reading": "; ".join(words) or "any value", + "canada_branch": r["canada_branch"], + "canada_limbs": r["canada_limbs"], + "branch": canada_std_branch(r["canada_branch"], status), + "status": r["status"], "provision": r["provision"], + "removal": removal, "removal_provision": rprov, + "iad_appeal": r["iad_appeal"], + "earliest_deemed": r["earliest_deemed"]}) + needs = [(_INTERMITTENT_NEED if d == 2 and intermittent and custody is not None + else _READING_NEEDS[d]) for d in sorted(matter)] + + # Settled either way (audit of T2.2): every reading gives the same ground + # and status and differs only in which s. 36(1)(a) limbs hold -- a maximum + # not given beside more than six months imposed, or an intermittent term + # beside a 10-year maximum. The answer rests on the limbs that hold in + # every reading; a permanent resident's appeal is 'depends' where the + # readings differ on it. + ground = {(r["canada_branch"], r["status"]) for _, r in combos} + common = [l for l in combos[0][1]["canada_limbs"] + if all(l in r["canada_limbs"] for _, r in combos)] + if len(ground) == 1 and common: + cb0 = combos[0][1]["canada_branch"] + rep = next((r for _, r in combos if r["canada_limbs"] == common), combos[0][1]) + appeals = list(dict.fromkeys(rd["iad_appeal"] for rd in readings)) + if len(appeals) == 1: + iad, appeal_note, need = appeals[0], rep["appeal_note"], [] + else: + iad, need = "depends", needs + appeal_note = ( + "Whether the permanent resident keeps an appeal to the Immigration " + "Appeal Division (IRPA s. 64) turns on " + + "; ".join(n["question"] for n in needs) + ": " + + "; ".join(f"if {rd['reading']}, {_APPEAL_WORDS[rd['iad_appeal']]}" + for rd in readings) + ".") + removal, rprov = canada_removal(cb0, status) + return outcome(cb0, rep["explanation"], rep["provision"], + canada_limbs=common, rehab=rep, status=rep["status"], + notes=notes + [either_way_note(cb0, readings, common)], + conditions=rep["conditions"], caveats=rep["caveats"], + needs=need + own_needs(rep), removal=removal, removal_provision=rprov, + iad_appeal=iad, appeal_note=appeal_note, custody_counted=counted, + earliest_deemed=rep["earliest_deemed"], either_way=readings) + return outcome("not-settled", CANADA_BRANCH_TEXT["not-settled"], None, + readings=readings, needs=needs, notes=notes, + caveats=([] if pr else [_TRP_CAVEAT]) + [CUSTODY_CAVEAT], + custody_counted=counted) + + +# ---- The whole record: IRPR s. 18(2) and s. 18.1 limb by limb (T2.3) ----- +# +# canlex_screen_history holds every entry of a record, so it can test each +# limb of IRPR s. 18(2)(a)-(c) and s. 18.1 against the entries instead of the +# one clean_record boolean that assess() takes (canlex_rehabilitation keeps +# assess() for a single matter; it is unchanged). check_limbs tests one +# paragraph under one reading; assess_record tests every paragraph under every +# reading and gives the record-level answer. Statute-mechanical rules; every +# phrase quoted below is pinned by tests/test_record_limbs.py against +# SOR-2002-227-s18-p1, -p2, -p3 and -s18.1. +# +# Two interpretive questions are NOT settled here (roadmap DECISIONS (a) and +# (b)). Each is run both ways, and where one decides the outcome every reading +# is reported (governing principle 5): +# (a) whether a hybrid equivalent is also a 'summary conviction offence' in +# limbs (iv)/(vi) (and (b)(v)) -- IRPA s. 36(3)(a) deems a hybrid offence +# indictable, but it 'govern[s] subsections (1) to (2.1)' of the Act, and +# s. 18 has no deeming of its own. In both readings a hybrid is +# indictable for the chapeau, (iii) and (vii); +# (b) the anchor of 'within / before the last N years': both readings count +# back from the assessment date; one reads the conviction date (the +# words: 'been convicted'), the other the offence date. +# A conviction leaves 'the last N years' on the day N years have elapsed since +# the day after it (the day-after convention of the s. 18(2) clocks). +# s. 18(2) counts OFFENCES, not occurrences ('more than one offence'); the +# single-occurrence rule of IRPA s. 36(2)(a)/(b) decides whether there is a +# ground at all (the count), and s. 18.1's 'two or more offences'. +# +# Facts the record does not give are run every way they can go: a missing +# conviction date (between the offence date and the sentence completion), an +# unresolved equivalent, a verdict step 1 did not settle, a Canadian offence's +# mode or conviction, and -- NOT settled in CanLex -- whether a Canadian +# conviction under a record suspension, or a youth disposition in a limb that +# carries no YCJA exception, counts. Where one decides a limb the limb is +# UNKNOWN and `needs` names the fact. A Canadian contravention is outside every +# limb (s. 36(3)(e)(i); the limbs that name convictions in Canada except it). + +RECORD_CLASSES = ("indictable-only", "hybrid", "summary-only", "contravention", + "youth", "none") +_ANY_CLASS = ("none", "summary-only", "hybrid", "indictable-only") +_ACT_OFFENCE = ("indictable-only", "hybrid", "summary-only") +_INDICTABLE = ("indictable-only", "hybrid") +_PARAS = ("(a)", "(b)", "(c)") +_MAX_WORLDS = 256 + +# The chapeau of each paragraph, verbatim (SOR-2002-227-s18-p1, -p2). +S18_CHAPEAU = { + "(a)": ("persons who have been convicted outside Canada of no more than one " + "offence that, if committed in Canada, would constitute an indictable " + "offence under an Act of Parliament"), + "(b)": ("persons convicted outside Canada of two or more offences that, if " + "committed in Canada, would constitute summary conviction offences " + "under any Act of Parliament"), + "(c)": ("persons who have committed no more than one 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"), +} +# IRPR s. 18.1, limb by limb (verbatim pieces of SOR-2002-227-s18.1). +S18_1_LIMBS = ( + ("(1) class", "The class of foreign nationals"), + ("(1) two offences", "having been convicted in Canada of two or more offences " + "that may only be prosecuted summarily"), + ("(1) solely", "inadmissible solely on the basis of having been convicted in " + "Canada"), + ("(2) five years", "at least five years since the day after the completion of " + "the imposed sentences"), +) +# Interpretation Act s. 34(1)(c), verbatim (I-21-s34). The Act applies to +# every enactment 'unless a contrary intention appears' (s. 3(1)), and an +# enactment includes a regulation (s. 2(1)). IRPA s. 36(3)(a) is that contrary +# intention for IRPA s. 36 -- 'even if it has been prosecuted summarily' -- +# and whether it reaches IRPR s. 18(2) is roadmap DECISION (a). On the reading +# that it does not, a Canadian hybrid conviction entered on summary +# conviction is not a conviction of 'an indictable offence' in (iii) / (b)(ii). +S34_1_C = ("no person shall be considered to have been convicted of an indictable " + "offence by reason only of having been convicted of the offence on summary " + "conviction") +# How a Canadian hybrid offence was prosecuted (the Crown's election): the +# entry key canada_prosecuted. +PROSECUTED = ("summarily", "indictment") +# The two unsettled readings (roadmap DECISIONS (a), (b)). +HYBRID_READINGS = { + "indictable": ("a hybrid equivalent is an indictable offence only, not also a " + "'summary conviction offence' in (iv)/(vi); a Canadian hybrid " + "conviction is 'an indictable offence' in (iii) even if prosecuted " + "summarily (IRPA s. 36(3)(a) read into s. 18(2))"), + "summary": ("a hybrid equivalent also counts as a 'summary conviction offence' " + "in (iv)/(vi), the paragraph's own conviction included (IRPA " + "s. 36(3)(a) deems a hybrid indictable only for 'subsections (1) to " + "(2.1)' of the Act); a Canadian hybrid conviction entered on summary " + "conviction is then not 'an indictable offence' in (iii) (Interpretation " + "Act s. 34(1)(c): 'no person shall be considered to have been convicted " + "of an indictable offence by reason only of having been convicted of the " + "offence on summary conviction')"), +} +ANCHOR_READINGS = { + "conviction": "'within / before the last N years' read on the conviction date", + "offence": "'within / before the last N years' read on the offence date", +} +RECORD_NOTES = { + "record-suspension": ( + "whether a Canadian conviction under a record suspension counts as 'convicted " + "in Canada' for IRPR s. 18(2) is not settled in CanLex (IRPA s. 36(3)(b) bars " + "inadmissibility based on it; s. 18(2) speaks of convictions)"), + "youth": ( + "whether a youth disposition counts in a s. 18(2) limb that carries no " + "exception for 'an offence under the Youth Criminal Justice Act' (the chapeaux; " + "(iii), (vi) and (vii) of (a) and (c); (b)(ii), (vi) and (vii)) is not settled " + "in CanLex"), + "foreign-youth": ( + "the limbs on convictions outside Canada that except 'an offence under the " + "Youth Criminal Justice Act' ((a)(v), (b)(iv), (c)(v)) read a foreign youth " + "disposition as excepted, by analogy -- a foreign finding is not literally an " + "offence under that Act; not settled in CanLex"), +} +# The limbs that carry the YCJA exception (tests/test_record_limbs.py checks +# this against the corpus text): a youth disposition is excepted there and +# run both ways everywhere else. +YCJA_EXCEPTION_LIMBS = {"(a)": ("iv", "v"), "(b)": ("iii", "iv", "v"), "(c)": ("iv", "v")} + + +def _limb_chunk(key, limb): + if key == "(a)": + return "SOR-2002-227-s18-p1" + if key == "(b)" or (key == "(c)" and limb == "chapeau"): + return "SOR-2002-227-s18-p2" + if key == "(c)": + return "SOR-2002-227-s18-p3" + return "SOR-2002-227-s18.1" + + +def _as_date(v): + if v is None or isinstance(v, _dt.date): + return v + from .screening import parse_date + return parse_date(v) + + +def _need(fact, n, parameter=None, question=None): + out = {"fact": fact, "entry": n, "parameter": parameter} + if question: + out["question"] = question + return out + + +def _normalize(e): + """One record entry as the limb rules read it. Keys: n (the entry label), + jurisdiction ('canada' | 'foreign'), kind ('conviction' | 'act' | 'unknown' + -- a verdict step 1 did not settle), class (RECORD_CLASSES, or None when not + known; 'youth' with youth_class for the offence's own class), classes (the + classes an unresolved entry can be), max_years, offence_date, + conviction_date, completed (the sentence), convicted_by (a date the + conviction is known to precede, e.g. a completion not used for the clock), + absentia, occurrence, record_suspended, conviction_open (Canadian: the + words do not settle a conviction), serious (Canadian: s. 36(1)(a) on its + own; None = not settled), class_need (the need an open class asks), + s17_question (why a conviction may not be 'a subsequent offence' for IRPR + s. 17: 'none' | 'unpaired' | 'canada' | None), prerogative (a Canadian + conviction under a prerogative pardon: 'conditional' when the words name a + conditional one, else 'other'; None when none is named) -- the G3 fixes of + 2026-09-22, read by _s17 only -- and s17_open (the entry's conviction status + is open for IRPR s. 17 although step 1 placed it: a deferred or withheld + adjudication, a juvenile or youthful-offender disposition, a YCJA youth + sentence; H3, 2026-09-22, read by _s17 only).""" + cls = e.get("class") + youth = bool(e.get("youth")) or cls == "youth" + if cls == "youth": + cls = e.get("youth_class") + classes = list(e.get("classes") or ([cls] if cls and cls != "unknown" else _ANY_CLASS)) + mx = e.get("max_years") + where = "canada" if str(e.get("jurisdiction") or "").lower() == "canada" else "foreign" + return {"n": e["n"], "where": where, "kind": e.get("kind") or "conviction", + "classes": classes, "cls": classes[0], "youth": youth, + "contravention": cls == "contravention", + "max_years": mx, "lt10": None if mx is None else mx < 10, + "od": _as_date(e.get("offence_date")), "cd": _as_date(e.get("conviction_date")), + "completed": _as_date(e.get("completed")), + "convicted_by": _as_date(e.get("convicted_by")), + "absentia": bool(e.get("absentia")), "occurrence": e.get("occurrence"), + "record_suspended": bool(e.get("record_suspended")), + "conviction_open": bool(e.get("conviction_open")), + "prosecuted": (e.get("prosecuted") if where == "canada" + and e.get("prosecuted") in PROSECUTED else None), + "serious": e.get("serious", False), "class_need": e.get("class_need"), + "s17_question": e.get("s17_question"), "prerogative": e.get("prerogative"), + "s17_open": bool(e.get("s17_open"))} + + +def _dims(i, e, hyb="indictable"): + """The open facts of entry i under one hybrid reading: [(entry index, + need, [(label, change)])], where change(entry) returns the entry as that + answer makes it, or None when the answer takes it off the record.""" + keep, drop = (lambda c: c), (lambda c: None) + n, out = e["n"], [] + if e["where"] == "canada" and e["record_suspended"]: + out.append((i, _need("record-suspension", n, None, + f"whether entry #{n}, a Canadian conviction under a record " + "suspension, counts as 'convicted in Canada' in IRPR s. 18(2) " + "(not settled in CanLex)"), + [("counted", keep), ("not counted", drop)])) + if e["conviction_open"]: + out.append((i, _need("conviction", n, None, f"whether entry #{n} ended in a conviction"), + [("a conviction", keep), ("not a conviction", drop)])) + if e["youth"]: + out.append((i, _need("youth-disposition", n, None, + f"whether entry #{n}, a youth disposition, counts in a limb that " + "carries no exception for 'an offence under the Youth Criminal " + "Justice Act' (not settled in CanLex)"), + [("counted", keep), ("not counted", drop)])) + if (hyb == "summary" and e["where"] == "canada" and "hybrid" in e["classes"] + and e["prosecuted"] is None and e["kind"] != "act"): + # Interpretation Act s. 34(1)(c), on the reading that IRPA s. 36(3)(a) + # does not reach the Regulations: the Crown's election decides (iii) + out.append((i, _need("canada_prosecuted", n, "canada_prosecuted", + f"whether entry #{n}, a Canadian hybrid offence, was prosecuted " + "summarily or by indictment ('summarily' | 'indictment')"), + [(f"prosecuted {p}", (lambda p_: (lambda x: dict(x, prosecuted=p_)))(p)) + for p in PROSECUTED])) + if e["kind"] == "unknown": + out.append((i, _need("conviction", n, None, + f"whether entry #{n} is a conviction (step 1 is not settled)"), + [("a conviction", lambda c: dict(c, kind="conviction")), + ("a committed act", lambda c: dict(c, kind="act"))])) + if len(e["classes"]) > 1: + need = e["class_need"] or ( + _need("canada_offence_mode", n, "canada_offence_mode", + f"how entry #{n}'s offence may be prosecuted (summary only, hybrid or " + "indictable)") if e["where"] == "canada" else + _need("equivalent", n, None, + f"which Canadian offence entry #{n} equates to, and how it may be " + "prosecuted -- resolve the entry and re-run")) + out.append((i, need, [(c, (lambda c_: (lambda x: dict(x, cls=c_)))(c)) + for c in e["classes"]])) + return out + + +def _anchor_bounds(e, anchor, today): + """(earliest, latest) the entry's anchor date can be: the conviction date, + or under the other reading the offence date. None = no earlier bound.""" + if anchor == "offence": + if e["od"]: + return e["od"], e["od"] + return None, e["cd"] or e["completed"] or e["convicted_by"] or today + if e["cd"]: + return e["cd"], e["cd"] + return e["od"], e["completed"] or e["convicted_by"] or today + + +def _window_end(anchor, years): + """The first day a conviction on `anchor` is no longer 'within the last + `years` years'. None: no earlier bound -- before every window.""" + from .screening import add_years + return None if anchor is None else add_years(anchor + _dt.timedelta(days=1), years) + + +def _limb(lo=None, hi=None, never=False, entries=(), open_=(), na=False): + """A limb in one world: it holds on T iff lo <= T < hi (None = unbounded), + or never; `open` holds the needs when it cannot be computed; `na` when + the paragraph has no subject in this world.""" + return {"lo": lo, "hi": hi, "never": never, "entries": sorted(set(entries), key=str), + "open": list(open_), "na": na} + + +def _none_of(ents): + return _limb(never=True, entries=[e["n"] for e in ents]) if ents else _limb() + + +def _none_within(ents, years): + """'has not within the last N years been convicted': holds from the day the + last of them leaves the window.""" + ends = [(_window_end(e["anchor"], years), e["n"]) for e in ents] + known = [d for d, _ in ends if d is not None] + if not known: + return _limb() + lo = max(known) + return _limb(lo=lo, entries=[n for d, n in ends if d == lo]) + + +def _at_most_one_before(ents, years): + """'has not before the last N years been convicted ... of more than one': + holds until the second of them leaves the window.""" + if len(ents) <= 1: + return _limb() + ends = sorted(((_window_end(e["anchor"], years) or _dt.date.min), str(e["n"]), e["n"]) + for e in ents) + second = ends[1][0] + who = [n for _, _, n in ends[:2]] + if second == _dt.date.min: + return _limb(never=True, entries=who) + return _limb(hi=second, entries=who) + + +def _one_old_at_most(ents, years): + """(iv): none 'within the last N years' and no more than one before them -- + with two or more, one of the two halves always fails.""" + if not ents: + return _limb() + if len(ents) >= 2: + return _limb(never=True, entries=[e["n"] for e in ents]) + return _limb(lo=_window_end(ents[0]["anchor"], years), entries=[ents[0]["n"]]) + + +def _occurrences(ents): + from .screening import occurrence_range + return occurrence_range([(f"#{e['n']}", e["occurrence"], e["od"]) for e in ents]) + + +def _paragraph(key, ents, hyb, status): + """Every limb of one paragraph in one world: {limb: _limb}.""" + from .screening import add_years + F = [e for e in ents if e["where"] == "foreign"] + C = [e for e in ents if e["where"] == "canada"] + fconv = [e for e in F if e["kind"] == "conviction"] + facts = [e for e in F if e["kind"] == "act"] + cconv = [e for e in C if e["kind"] == "conviction"] + + def exc(es): # limbs carrying the contravention / YCJA exception + return [e for e in es if not e["youth"]] + + def summ(es): # 'summary conviction offence' (reading (a) for hybrids) + return [e for e in es if e["cls"] == "summary-only" + or (hyb == "summary" and e["cls"] == "hybrid" + and e.get("prosecuted") != "indictment")] + + def ind(es): # indictable as IRPA s. 36 reads it (s. 36(3)(a)) + return [e for e in es if e["cls"] in _INDICTABLE] + + def ind_reg(es): # 'convicted in Canada of an indictable offence' in s. 18(2) + return [e for e in ind(es) if not (hyb == "summary" and e["cls"] == "hybrid" + and e.get("prosecuted") == "summarily")] + + def act_off(es): # 'an offence under an Act of Parliament' + return [e for e in es if e["cls"] in _ACT_OFFENCE] + + L = {} + if key == "18.1": + S = [e for e in cconv if e["cls"] == "summary-only" and not e["youth"] + and not e["record_suspended"]] + L["(1) class"] = _limb() if status != "permanent-resident" else _limb(never=True) + if len(S) < 2: + L["(1) two offences"] = _limb(never=True, entries=[e["n"] for e in S]) + else: + least, most, pairs = _occurrences(S) + L["(1) two offences"] = ( + _limb(entries=[e["n"] for e in S]) if least >= 2 else + _limb(never=True, entries=[e["n"] for e in S]) if most <= 1 else + _limb(entries=[e["n"] for e in S], + open_=[_need("occurrence_group", e["n"], "occurrence_group", + "whether the summary-only convictions arose out of one " + "occurrence") for e in S])) + others = ind(fconv) + ind(facts) + [e for e in ind(cconv) if not e["record_suspended"]] + others += [e for e in cconv if e["serious"] is True and not e["record_suspended"]] + opens = [_need("canada-serious", e["n"], None, + f"whether entry #{e['n']} is itself s. 36(1)(a) (maximum, custody)") + for e in cconv if e["serious"] is None and not e["record_suspended"]] + fs = [e for e in fconv if e["cls"] == "summary-only"] + if len(fs) >= 2: + least, most, _ = _occurrences(fs) + if least >= 2: + others += fs + elif most > 1: + opens += [_need("occurrence_group", e["n"], "occurrence_group") for e in fs] + L["(1) solely"] = (_none_of(others) if others else _limb(open_=opens)) + if len(S) >= 2 and all(e["completed"] for e in S): + L["(2) five years"] = _limb(lo=add_years(max(e["completed"] for e in S) + + _dt.timedelta(days=1), 5), + entries=[max(S, key=lambda e: e["completed"])["n"]]) + elif len(S) >= 2: + L["(2) five years"] = _limb(open_=[_need("sentence_completed", e["n"], + "sentence_completed") + for e in S if not e["completed"]]) + else: + L["(2) five years"] = _limb(na=True) + return L + + if key == "(b)": + s = [e for e in fconv if e["cls"] == "summary-only"] + L["chapeau"] = (_limb(entries=[e["n"] for e in s]) if len(s) >= 2 + else _limb(never=True, entries=[e["n"] for e in s])) + if len(s) < 2: + L["i"] = _limb(na=True) + elif all(e["completed"] for e in s): + last = max(s, key=lambda e: e["completed"]) + L["i"] = _limb(lo=add_years(last["completed"] + _dt.timedelta(days=1), 5), + entries=[last["n"]]) + else: + L["i"] = _limb(open_=[_need("sentence_served" if e["absentia"] else + "sentence_completed", e["n"], + None if e["absentia"] else "sentence_completed") + for e in s if not e["completed"]]) + L["ii"] = _none_of(ind_reg(cconv)) + L["iii"] = _none_within(exc(act_off(cconv)), 5) + L["iv"] = _none_within(exc(act_off(fconv)), 5) + L["v"] = _at_most_one_before(exc(summ(cconv)), 5) + L["vi"] = _none_of(ind(fconv)) + L["vii"] = _none_of(ind(facts)) + return L + + subjects = ind(fconv) if key == "(a)" else ind(facts) + subj = subjects[0] if len(subjects) == 1 else None + L["chapeau"] = (_limb(entries=[subj["n"]]) if subj else + _limb(never=True, entries=[e["n"] for e in subjects])) + if subj is None: + L["i"] = L["ii"] = _limb(na=True) + else: + L["i"] = (_limb(open_=[_need("maximum", subj["n"], None, + f"the Canadian maximum for entry #{subj['n']} at the " + "offence date")]) if subj["lt10"] is None else + _limb(entries=[subj["n"]]) if subj["lt10"] else + _limb(never=True, entries=[subj["n"]])) + start = subj["completed"] if key == "(a)" else subj["od"] + if start is None: + fact = ("offence_date" if key == "(c)" else + "sentence_served" if subj["absentia"] else "sentence_completed") + L["ii"] = _limb(open_=[_need(fact, subj["n"], + None if fact == "sentence_served" else fact)]) + else: + L["ii"] = _limb(lo=add_years(start + _dt.timedelta(days=1), 10), + entries=[subj["n"]]) + L["iii"] = _none_of(ind_reg(cconv)) + L["iv"] = _one_old_at_most(exc(summ(cconv)), 10) + L["v"] = _none_within(exc(act_off(fconv)), 10) + L["vi"] = _at_most_one_before(summ(fconv), 10) + L["vii"] = (_none_of([e for e in ind(facts) if e is not subj]) if key == "(a)" + else _none_of(ind(fconv))) + return L + + +def _holds(l, today): + if l["na"]: + return "n/a" + if l["never"]: + return "fail" + if l["open"]: + return "unknown" + ok = (l["lo"] is None or today >= l["lo"]) and (l["hi"] is None or today < l["hi"]) + return "pass" if ok else "fail" + + +def _outcome(limbs, today): + """(code, date) of a paragraph in one world: 'deemed' (on date), 'future' + (from date, if nothing is added to the record), 'never' or 'open'.""" + ls = [l for l in limbs.values() if not l["na"]] + if any(l["never"] for l in ls): + return "never", None + if any(l["open"] for l in ls): + return "open", None + lo = max((l["lo"] for l in ls if l["lo"]), default=None) + hi = min((l["hi"] for l in ls if l["hi"]), default=None) + if (lo and hi and lo >= hi) or (hi and today >= hi): + return "never", None + if lo is None or today >= lo: + return "deemed", lo + return "future", lo + + +def _today_code(code): + return {"deemed": "deemed", "open": "open"}.get(code, "not") + + +def _evaluate(ents, today, status, hyb, anchor, keys=_PARAS + ("18.1",)): + """Every paragraph under one reading, over every way the open facts can + go. Returns {key: {limbs: [...], outcome, deemed_on, exact, needs, + projection_open}} plus 'too_open' when there are too many open facts.""" + dims = [d for i, e in enumerate(ents) for d in _dims(i, e, hyb)] + size = 1 + for d in dims: + size *= len(d[2]) + if size > _MAX_WORLDS: + needs = [d[1] for d in dims] + # no limb table: only whether a paragraph's subject can be on the record + can = {"(a)": _could(ents, "foreign", ("conviction", "unknown"), _INDICTABLE, 1), + "(b)": _could(ents, "foreign", ("conviction", "unknown"), ("summary-only",), 2), + "(c)": _could(ents, "foreign", ("act", "unknown"), _INDICTABLE, 1), + "18.1": _could(ents, "canada", ("conviction",), ("summary-only",), 2)} + return {k: {"limbs": [], "outcome": "open", "deemed_on": None, "exact": False, + "needs": needs, "projection_open": True, "too_open": True, + "chapeau_holds": can[k]} for k in keys} + worlds = [] + for combo in itertools.product(*[range(len(d[2])) for d in dims]): + conc = list(ents) + for (i, _, alts), c in zip(dims, combo): + if conc[i] is not None: + conc[i] = alts[c][1](conc[i]) + base = [c for c in conc if c is not None and not c["contravention"]] + bounds = {id(c): _anchor_bounds(c, anchor, today) for c in base} + + def run(early=frozenset(), base=base, bounds=bounds, all_early=False): + ce = [dict(c, anchor=bounds[id(c)][0 if (all_early or c["n"] in early) else 1]) + for c in base] + return {k: _paragraph(k, ce, hyb, status) for k in keys} + undated = [c["n"] for c in base if bounds[id(c)][0] != bounds[id(c)][1]] + worlds.append({"combo": combo, "late": run(), "early": run(all_early=True), + "run": run, "undated": undated}) + out = {} + for k in keys: + names = list(worlds[0]["late"][k]) + limbs = [] + for name in names: + evs = [w[x][k][name] for w in worlds for x in ("early", "late")] + sts = {_holds(l, today) for l in evs} - {"n/a"} + st = ("n/a" if not sts else sts.pop() if len(sts) == 1 else "unknown") + live = [l for l in evs if not l["na"] and not l["open"]] + keyset = {(l["lo"], l["hi"], l["never"]) for l in live} + never = bool(live) and all(l["never"] for l in live) + los = [l["lo"] for l in live if l["lo"] and not l["never"]] + his = [l["hi"] for l in live if l["hi"] and not l["never"]] + lneeds = [n for l in evs for n in l["open"]] + # which open fact decides this limb + for di, (i, need, alts) in enumerate(dims): + by = {} + for w in worlds: + for x in ("early", "late"): + by.setdefault(w["combo"][di], set()).add(_holds(w[x][k][name], today)) + if len({frozenset(v) for v in by.values()}) > 1: + lneeds.append(need) + if any(_holds(w["early"][k][name], today) != _holds(w["late"][k][name], today) + for w in worlds): + lneeds += [_need("conviction_date" if anchor == "conviction" else + "offence_date", n, + "conviction_date" if anchor == "conviction" else "offence_date") + for n in sorted({n for w in worlds for n in w["undated"]}, key=str) + if n in {m for l in evs for m in l["entries"]}] + limbs.append({"limb": name, "status": st, + "entries": sorted({n for l in evs for n in l["entries"]}, key=str), + "from": max(los).isoformat() if los else None, + "until": min(his).isoformat() if his else None, + "never": never, "exact": len(keyset) <= 1, + "chunk": _limb_chunk(k, name), + "needs": _dedupe(lneeds) if st == "unknown" else []}) + outs = [(w, x, _outcome(w[x][k], today)) for w in worlds for x in ("early", "late")] + codes = {_today_code(c) for _, _, (c, _) in outs} + dates = {d for _, _, (c, d) in outs if c in ("deemed", "future")} + allc = {c for _, _, (c, _) in outs} + if codes == {"deemed"}: + outcome = "deemed" + elif codes == {"not"}: + outcome = "not-deemed" + else: + outcome = "open" + deemed_on = (max(dates) if dates and outcome != "open" + and allc <= {"deemed", "future"} else None) + needs = [] + if outcome == "open": + for w in worlds: + for x in ("early", "late"): + if _outcome(w[x][k], today)[0] == "open": + needs += [n for l in w[x][k].values() for n in l["open"]] + for di, (i, need, alts) in enumerate(dims): + by = {} + for w, x, (c, _) in outs: + by.setdefault(w["combo"][di], set()).add(_today_code(c)) + if len({frozenset(v) for v in by.values()}) > 1: + needs.append(need) + for w in worlds: + late = _today_code(_outcome(w["late"][k], today)[0]) + if late == _today_code(_outcome(w["early"][k], today)[0]): + continue + hit = [n for n in w["undated"] + if _today_code(_outcome(w["run"](frozenset([n]))[k], today)[0]) != late] + for n in hit or w["undated"]: + needs.append(_need("conviction_date" if anchor == "conviction" else + "offence_date", n, + "conviction_date" if anchor == "conviction" + else "offence_date")) + out[k] = {"limbs": limbs, "outcome": outcome, "deemed_on": deemed_on, + "exact": len(dates) <= 1 and not ("never" in allc and "future" in allc), + "projection_open": (outcome == "not-deemed" and "never" in allc + and "future" in allc), + "needs": _dedupe(needs), "too_open": False, + "chapeau_holds": next((l["status"] for l in limbs + if l["limb"] in ("chapeau", "(1) two offences")), + None) != "fail"} + return out + + +def _could(ents, where, kinds, classes, least): + """Whether at least `least` entries could be the subject of a paragraph + (jurisdiction, kind and class), counting every class an open entry can + be -- used only when there are too many open facts for a limb table.""" + return sum(1 for e in ents if e["where"] == where and e["kind"] in kinds + and set(e["classes"]) & set(classes) and not e["contravention"]) >= least + + +def _dedupe(needs): + seen, out = set(), [] + for n in needs: + k = (n["fact"], str(n["entry"])) + if k not in seen: + seen.add(k) + out.append(n) + return out + + +def _limb_text(key, name): + """The condition as printed: the chapeau verbatim, (i)-(vii) as _CONDITIONS + renders them (checked against the corpus by tests/test_rehab.py).""" + if key == "18.1": + return dict(S18_1_LIMBS)[name] + if name == "chapeau": + return S18_CHAPEAU[key] + return conditions_for(f"IRPR s. 18(2){key}")[_LIMB_NUMS.index(name)] + + +_LIMB_NUMS = ("i", "ii", "iii", "iv", "v", "vi", "vii") + + +def check_limbs(paragraph, record, today=None, status="foreign-national", + hybrid_reading="indictable", anchor="conviction"): + """The limbs of one paragraph -- '(a)', '(b)', '(c)' of IRPR s. 18(2), or + '18.1' -- under one reading, over every way the record's open facts can + go. Each limb: {limb, text, status ('pass' | 'fail' | 'unknown' | 'n/a'), + entries (the entries that decide it), from / until (the date it starts or + stops holding), never, exact (False when an open fact moves the date; the + date shown is the latest it can be), chunk (the IRPR chunk id), needs}.""" + today = _as_date(today) or _dt.date.today() + ents = [_normalize(e) for e in record] + r = _evaluate(ents, today, status, hybrid_reading, anchor, keys=(paragraph,))[paragraph] + for l in r["limbs"]: + l["text"] = _limb_text(paragraph, l["limb"]) + return r["limbs"] + + +def _para_label(key): + return "IRPR s. 18.1" if key == "18.1" else f"IRPR s. 18(2){key}" + + +def _s17_is_open(e): + """A record entry whose conviction status the words leave open, for the IRPR + s. 17 clock: a foreign verdict step 1 did not settle, or a conviction in + Canada the words do not settle -- except one under a prerogative pardon the + words call conditional, which is counted as the conviction it is (Criminal + Code s. 748(2) grants a conditional pardon 'to any person who has been + convicted of an offence'; s. 748(3) speaks of a free pardon only). + + A prerogative pardon not shown to be conditional is open whatever the + conviction field says: since G2 (2026-09-22) canada_disposition keeps such + an entry a conviction (relief_open 'prerogative') instead of leaving the + conviction unsettled, and the merge of G2 and G3 lost its second reading. + The headline stays the strict date either way; only the reading without the + entry comes back. + + H3 (2026-09-22): an entry whose conviction status step 1 placed but did not + settle is open too (s17_open, set by the caller) -- a deferred or withheld + adjudication, a juvenile or youthful-offender disposition, a YCJA youth + sentence. IRPR s. 17 excepts only 'an offence designated as a contravention + under the Contraventions Act or an offence under the Young Offenders Act', + so every other matter bears on the five years on the reading that it is a + conviction, and that reading is the headline; the favourable placement (a + committed act, or the entry off the clock) is a `readings` entry. Before: + a permanent resident's deferred or juvenile adjudication left the clock + entirely (S17-INT-5), a foreign national's ran from its offence date a year + before its completion with no second reading (S17-INT-6), and a YCJA youth + sentence was off the clock, making the favourable reading the headline + (S17-INT-7).""" + if e["contravention"]: + return False + if e["s17_open"]: + return True + if e["youth"]: + return False + if e["where"] == "foreign" and e["kind"] == "unknown": + return True + if e["kind"] != "conviction" or e["prerogative"] == "conditional": + return False + return bool(e["conviction_open"] or e["prerogative"]) + + +def _s17_act_matter(e, status, conv_basis): + """Whether a foreign entry, read as a committed act, is a matter IRPR s. + 17(b) runs from: an act whose Canadian equivalent may be indictable (IRPA + s. 36(2)(c)). On the conviction basis, where committed acts are counted + since the G3 fixes of 2026-09-22 (S17-1), a permanent resident's act whose + maximum is known to be under 10 years is left out: s. 36(1)(c) is the only + act ground that reaches a permanent resident, and an act is no conviction, + so it cannot restart the five years either. On the act basis every such act + counts, as before (the stricter reading).""" + if e["where"] != "foreign" or not set(e["classes"]) & set(_INDICTABLE): + return False + return not (conv_basis and status == "permanent-resident" and e["lt10"] is True) + + +def _s17(ents, today, status="foreign-national"): + """The IRPR s. 17 application clock over the whole record. IRPR s. 17: five + years '(a) after the completion of an imposed sentence' for the matters in + IRPA s. 36(1)(b) and (2)(b), '(b) after committing an offence' for those in + s. 36(1)(c) and (2)(c), each 'if the person has not been convicted of a + subsequent offence other than' a contravention or a Young Offenders Act + offence (roadmap T1.9: a conviction of any kind restarts it). + + The G3 fixes of 2026-09-22 (HELD). The clock runs from the LATEST of: + every conviction's completion (contraventions excepted, and youth + dispositions unless they may be the ground -- _s17_entries), + every committed act's offence date that s. 17(b) runs from (on the + conviction basis too -- S17-1: a TX burglary completed 2012 and a TX simple + assault pending from 2023-05-01 answered ELIGIBLE TO APPLY 2017-01-01; the + act's five years run to 2028-05-01), and every entry whose conviction status + is open read the strictest way, as a conviction (PREROG-3: a later + 'DISPOSITION UNKNOWN' entry was left off the clock; PREROG-2: a later + conviction in Canada under a prerogative pardon was too, and one the words + call conditional is counted plainly), and a foreign youth disposition that + may itself be the ground (its own s. 17(a) period; the exception reaches only + a subsequent offence). A later conviction still moves an act, as on the act + basis (LEGAL-7). The headline date is the strictest any + reading allows; `readings` gives the clock entry's other readings (one per + open entry, leaving out only that entry -- AI-5 -- or, for a pairing that + says the conviction has no federal equivalent, every such conviction, the + same legal question). A conviction with no completion date, an open entry + with none, and a committed act with no offence date (S17-2: it was dropped, + leaving a settled date) leave the date open: they are listed in `undated`, + `undated_open` and `undated_acts` for the caller to settle, and no reading + is given. `act_from` is the latest act date (S17-3: the caller's second + reading and floor ran from the deeming clock instead). + + The H3 fixes of 2026-09-22. An entry whose conviction status step 1 placed + but did not settle -- a deferred or withheld adjudication, a juvenile or + youthful-offender disposition, a YCJA youth sentence -- is an OPEN entry + read as a conviction (_s17_is_open), so the headline is its completion and + the favourable placement (its offence date as a committed act, or the entry + off the clock) is a `readings` entry: a permanent resident's had left the + clock silently (S17-INT-5), a foreign national's ran from the offence date a + year before the completion with no second reading (S17-INT-6), and a YCJA + youth sentence was off the clock, so the favourable reading was the headline + (S17-INT-7). On the act basis with no dated act the completions are ranked + instead of returning no date at all, so ADDING a matter to a record can no + longer make the answer more favourable (REG-3).""" + from .screening import add_years + fconv = [e for e in ents if e["where"] == "foreign" and e["kind"] == "conviction" + and len(e["classes"]) == 1 and not e["youth"]] + ground = [e for e in fconv if e["cls"] in _INDICTABLE + and (status != "permanent-resident" or e["lt10"] is False)] + if status != "permanent-resident" and \ + len([e for e in fconv if e["cls"] == "summary-only"]) >= 2: + ground = ground or fconv + acts = [e for e in ents if e["where"] == "foreign" and e["kind"] == "act" + and set(e["classes"]) & set(_INDICTABLE) and e["od"]] + foreign_off = ground or (not acts and [ + e for e in ents if e["where"] == "foreign" and e["kind"] == "conviction" + and set(e["classes"]) & set(_ACT_OFFENCE)]) + conv_basis = bool(foreign_off) + counted, opens, m_acts = _s17_entries(ents, status, conv_basis) + conv_like = [e for e in ents if any(e is x for x in counted + opens)] + + def is_open(e): + return any(e is o for o in opens) + dated_acts = [e for e in m_acts if e["od"]] + last_act = max(dated_acts, key=lambda e: e["od"], default=None) + base = {"basis": "foreign-conviction" if conv_basis else "foreign-act", + "undated": [e["n"] for e in counted if not e["completed"]], + "undated_open": [e["n"] for e in opens if not e["completed"]], + "undated_acts": [e["n"] for e in m_acts if not e["od"]], + "open": [e["n"] for e in opens], + "act_from": last_act["od"] if last_act else None, + "act_entry": last_act["n"] if last_act else None, "readings": []} + # H3 (REG-3, 2026-09-22): the act basis with no dated act used to return NO date and + # to forget its undated entries, so ADDING a matter to a record made the answer more + # favourable (NOT SETTLED -> ELIGIBLE TO APPLY 2024-06-15). The completions on the + # record are ranked here as on the conviction basis; only the entries that genuinely + # lack a date (undated, undated_open, undated_acts) leave the date open, below. + # on a tie the basis's own matter wins: a conviction on the conviction basis, the + # act on the act basis (a later conviction moves the act only when completed after it) + rank = {("completion", False): 3, ("completion", True): 2, ("offence", False): 1} \ + if conv_basis else \ + {("offence", False): 3, ("completion", False): 2, ("completion", True): 1} + terms = ([(e["completed"], rank[("completion", is_open(e))], e, "completion") + for e in conv_like if e["completed"]] + + [(e["od"], rank[("offence", False)], e, "offence") for e in dated_acts]) + top = max(terms, key=lambda t: (t[0], t[1]), default=None) + out = dict(base, entry=top[2]["n"] if top else None, **{"from": top[0] if top else None}, + from_kind=top[3] if top else None, + apply_on=add_years(top[0], 5) if top else None) + if not conv_basis: + # 'moved' says a later conviction moved the committed act: with no dated act + # there is nothing to move (REG-3) + out["moved"] = bool(dated_acts) and top is not None and top[3] == "completion" + if top is None or out["undated"] or out["undated_open"] or out["undated_acts"]: + return out + out["readings"] = _s17_other_readings(terms, top, counted, is_open(top[2]), status, + conv_basis) + return out + + +def _s17_entries(ents, status, conv_basis): + """(counted, opens, acts): the entries on the IRPR s. 17 clock -- the + convictions counted as they stand, the entries whose conviction status is + open (read the strictest way, as convictions), and the committed acts s. + 17(b) runs from. A foreign youth disposition that may itself be the ground + is a matter with its own s. 17(a) period (the exception reaches only a + SUBSEQUENT offence), so it is on the clock, as the second reading had + counted it through the engaged convictions (G3, 2026-09-22). + + H3 (2026-09-22, S17-INT-7): the youth exception is a READING, not a rule. + IRPR s. 17 excepts 'an offence under the Young Offenders Act'; IRPA + s. 36(3)(e)(ii) and (iii) except a Young Offenders Act finding of guilt and + a YCJA youth sentence from being a GROUND -- two different tests, so a + youth disposition is an open entry (_s17_is_open): on the clock for the + headline, with the excepted date as a `readings` entry. One the officer's + record shows to be a contravention stays out, the one exception s. 17 + states in its own words. An open entry carries BOTH its dated terms -- its + sentence completion, read as the conviction it may be (s. 17(a)), and, where + s. 17(b) would run from it, its offence date, read as the committed act it + may be -- because the headline is the latest date ANY reading allows: an + entry whose completion preceded its offence date had moved the headline + three months earlier once it became open.""" + opens = [e for e in ents if _s17_is_open(e)] + counted = [e for e in ents if e["kind"] == "conviction" and not e["contravention"] + and not any(e is o for o in opens)] + acts = [e for e in ents if _s17_act_matter(e, status, conv_basis) + and (e["kind"] == "act" or (e["od"] and any(e is o for o in opens)))] + return counted, opens, acts + + +def _s17_other_readings(terms, top, counted, c_open, status, conv_basis): + """The clock entry's other readings, each leaving out only that entry (AI-5) + -- or, for a pairing that says the conviction has no federal equivalent, + every such conviction, the same legal question. An entry whose conviction + status is open reads as a committed act (when s. 17(b) would run from it) + or off the clock (PREROG-2/3). Only a reading whose date is earlier than + the headline is given; the headline stays the strictest (G3, 2026-09-22).""" + from .screening import add_years + c = top[2] + others = [t for t in terms if t[2] is not c] + alts = [] + if top[3] == "completion" and c_open: + act_ok = c["where"] == "foreign" and c["od"] and _s17_act_matter(c, status, conv_basis) + if act_ok: + alts.append(("a-committed-act", others + [(c["od"], 0, c, "offence")], [])) + # H3 (S17-INT-7): a youth disposition always gets the excepted reading too -- + # the date the record gives if s. 17's Young Offenders Act exception reaches it + if not act_ok or c["s17_question"] or c["youth"]: + alts.append(("not-a-subsequent-offence", others, [])) + elif top[3] == "completion" and c["s17_question"]: + group = [c] + ([e for e in counted if e is not c and e["s17_question"] == "none"] + if c["s17_question"] == "none" else []) + ts = [t for t in terms if not any(t[2] is g for g in group)] + alts.append(("not-a-subsequent-offence", ts, group[1:])) + readings = [] + for key, ts, group in alts: + t = max(ts, key=lambda t: (t[0], t[1]), default=None) + if t is None or t[0] >= top[0]: + continue + readings.append({ + "key": key, "entry": c["n"], "from": t[0], "from_entry": t[2]["n"], + "from_kind": t[3], "apply_on": add_years(t[0], 5), + "also": [g["n"] for g in group if g["completed"] and g["completed"] > t[0]]}) + return readings + + +def _s17_public(s17): + """The s. 17 clock as assess_record returns it: dates as YYYY-MM-DD.""" + def iso(d): + return d.isoformat() if d else None + return {"basis": s17["basis"], "entry": s17["entry"], "from": iso(s17["from"]), + "from_kind": s17["from_kind"], "apply_on": iso(s17["apply_on"]), + "undated": s17["undated"], "undated_open": s17["undated_open"], + "undated_acts": s17["undated_acts"], "open": s17["open"], + "act_from": iso(s17["act_from"]), "act_entry": s17["act_entry"], + "moved": s17.get("moved", False), + "readings": [dict(r, **{"from": iso(r["from"]), "apply_on": iso(r["apply_on"])}) + for r in s17["readings"]]} + + +def _reading_answer(res, s17, today, status, ents): + """status / provision / explanation / earliest dates for one reading.""" + shown = [k for k in _PARAS if res[k]["chapeau_holds"]] + deemed = [k for k in shown if res[k]["outcome"] == "deemed"] + opened = [k for k in shown if res[k]["outcome"] == "open"] + apply_on = s17["apply_on"] + apply_iso = apply_on.isoformat() if apply_on else None + can_apply = apply_on is not None and today >= apply_on + # G3 (2026-09-22): the clock entry says what it runs from -- an act can set it on + # the conviction basis too (S17-1), and an entry whose conviction status is open + # is read as a conviction (PREROG-3) + since = ("the commission of the act" if s17.get("from_kind") == "offence" else + "the completion of the sentence for the most recent entry that may be a " + "conviction" if s17.get("entry") in (s17.get("open") or ()) else + "the completion of the sentence for the most recent conviction") + app = (f"The person is eligible to APPLY for individual rehabilitation (IRPA " + f"s. 36(3)(c); IRPR s. 17): five years have passed since {since} " + f"(#{s17['entry']}; {apply_iso})." if can_apply else + f"Eligibility to apply for individual rehabilitation (IRPR s. 17) is reached " + f"on {apply_iso}, five years after {since} (#{s17['entry']})." if apply_on else + "The IRPR s. 17 application date needs the sentence-completion dates.") + + def fails(k): + bad = [l for l in res[k]["limbs"] if l["status"] == "fail"] + return "; ".join(f"{'the chapeau' if l['limb'] == 'chapeau' else '(' + l['limb'] + ')'} " + "fails" + (f" (entr{'y' if len(l['entries']) == 1 else 'ies'} " + + ", ".join(f"#{n}" for n in l["entries"]) + ")" + if l["entries"] else "") + for l in bad) + if deemed: + k = deemed[0] + r = res[k] + on = r["deemed_on"].isoformat() if r["deemed_on"] else None + return {"status": "deemed-rehabilitated", "provision": _para_label(k), + "clock": "day-after", "earliest_deemed": on, "earliest_apply": apply_iso, + "exact": r["exact"], "paragraph": _para_label(k), + "explanation": (f"Every condition of {_para_label(k)} holds on " + f"{today.isoformat()} (limb table): deemed rehabilitated " + f"from {on}{'' if r['exact'] else ' at the latest'} -- the " + "matters it covers cannot ground inadmissibility under " + "IRPA s. 36(3)(c).")} + if opened: + k = opened[0] + unknown = [l for l in res[k]["limbs"] if l["status"] == "unknown"] + qs = "; ".join(n.get("question") or f"{n['fact']} for #{n['entry']}" + for n in res[k]["needs"]) or "an open fact" + lead = (("limb" + ("s " if len(unknown) != 1 else " ") + + ", ".join(f"({l['limb']})" if l["limb"] != "chapeau" + else "the chapeau" for l in unknown) + + f" turn{'' if len(unknown) != 1 else 's'} on {qs}") if unknown else + (f"the record has too many open facts to run every way " + f"({len(res[k]['needs'])}) -- {qs}")) + return {"status": "undetermined", "provision": f"{_para_label(k)}; IRPR s. 17", + "clock": "s17", "earliest_deemed": None, "earliest_apply": apply_iso, + "exact": False, "paragraph": _para_label(k), + "explanation": (f"Deemed rehabilitation under {_para_label(k)} is NOT " + f"SETTLED: {lead}. " + app)} + future = [k for k in shown if res[k]["deemed_on"] and not res[k]["projection_open"]] + if future: + k = min(future, key=lambda k: res[k]["deemed_on"]) + r = res[k] + on = r["deemed_on"].isoformat() + waiting = [l for l in r["limbs"] if l["status"] == "fail"] + why = "; ".join(f"({l['limb']}) holds from {l['from']}" for l in waiting if l["from"]) + return {"status": "eligible-to-apply" if can_apply else "not-yet", + "provision": f"{_para_label(k)}; IRPR s. 17", "clock": "s17", + "earliest_deemed": on, "earliest_apply": apply_iso, "exact": r["exact"], + "paragraph": _para_label(k), + "explanation": (f"Deemed rehabilitation under {_para_label(k)} is not yet " + f"reached ({why}); it is reached on {on}" + + ("" if r["exact"] else " at the latest") + + " if nothing is added to the record. " + app)} + if shown: + why = "; ".join(f"{_para_label(k)}: {fails(k)}" for k in shown if fails(k)) + lead = f"Deemed rehabilitation is not available on this record ({why})." + prov = f"{_para_label(shown[0])}; IRPR s. 17" + para = _para_label(shown[0]) + else: + n_ind = sorted({e["n"] for e in ents if e["where"] == "foreign" + and e["kind"] == "conviction" and e["cls"] in _INDICTABLE}, key=str) + lead = ("Deemed rehabilitation is not available on this record: IRPR s. 18(2) " + "covers no more than one indictable-equivalent conviction ((a)), two or more " + "summary-equivalent convictions and nothing indictable ((b)), or no more " + "than one committed act ((c))" + + (f"; entries {', '.join(f'#{n}' for n in n_ind)} are indictable-equivalent " + "convictions" if len(n_ind) > 1 else "") + ".") + prov, para = "IRPA s. 36(3)(c); IRPR s. 17", None + # no deeming, and no s. 17 date: whether the person may apply yet is open + return {"status": ("eligible-to-apply" if can_apply else + "not-yet" if apply_on else "undetermined"), "provision": prov, + "clock": "s17", "earliest_deemed": None, "earliest_apply": apply_iso, + "exact": True, "paragraph": para, "explanation": lead + " " + app} + + +def _public(res, key): + r = res[key] + limbs = [dict(l, text=_limb_text(key, l["limb"])) for l in r["limbs"]] + return {"paragraph": _para_label(key), "key": key, "limbs": limbs, + "outcome": r["outcome"], + "deemed_on": r["deemed_on"].isoformat() if r["deemed_on"] else None, + "exact": r["exact"], "projection_open": r["projection_open"], + "needs": r["needs"]} + + +def assess_record(record, today=None, status="foreign-national", + hybrid_reading=None, anchor=None): + """The whole record under IRPR s. 18(2)(a)-(c), s. 18.1 and s. 17 (roadmap + T2.3). record: a list of entries (see _normalize for the keys; dates as + datetime.date or YYYY-MM-DD). hybrid_reading ('indictable' | 'summary') + and anchor ('conviction' | 'offence') force one reading of roadmap + DECISIONS (a) / (b); None runs both and reports every reading that + changes the outcome. + + Returns {status ('deemed-rehabilitated' | 'eligible-to-apply' | 'not-yet' + | 'undetermined' -- an open fact decides a limb | 'not-settled' -- the + readings differ | 'not-inadmissible-pr' | None -- no foreign ground on the + resolved entries), provision, explanation, clock, earliest_deemed, + earliest_deemed_exact, earliest_apply, paragraph, paragraphs (the + paragraphs whose chapeau can hold, limb by limb, under the first + reading), readings (None, or each reading that gives a different + answer, with its paragraphs), dates_differ (readings that agree on the + status but not the date), s18_1, s17 {basis, entry, from, apply_on, + undated -- convictions with no completion date: the caller settles + them}, deem_clock, needs, warnings}. The s. 17 date is computed from the + completion dates given; `s17.undated` lists the convictions without one.""" + today = _as_date(today) or _dt.date.today() + ents = [_normalize(e) for e in record] + s17 = _s17(ents, today, status) + hybs = [hybrid_reading] if hybrid_reading else ["indictable", "summary"] + anchors = [anchor] if anchor else ["conviction", "offence"] + runs = [] + for h in hybs: + for a in anchors: + res = _evaluate(ents, today, status, h, a) + runs.append({"hybrid": h, "anchor": a, "res": res, + "answer": _reading_answer(res, s17, today, status, ents)}) + first = runs[0] + res, ans = first["res"], first["answer"] + shown = [k for k in _PARAS if res[k]["chapeau_holds"]] + definite = [e for e in ents if e["where"] == "foreign" and len(e["classes"]) == 1 + and e["kind"] != "unknown" and not e["youth"]] + ind = [e for e in definite if e["cls"] in _INDICTABLE] + summ = [e for e in definite if e["kind"] == "conviction" and e["cls"] == "summary-only"] + out = {"paragraphs": [_public(res, k) for k in shown], + "s18_1": _public(res, "18.1") if res["18.1"]["chapeau_holds"] else None, + "s17": _s17_public(s17), + "readings": None, "dates_differ": None, "warnings": []} + subj = {"(a)": "completed", "(b)": "completed", "(c)": "od"} + clk = None + if ans["paragraph"] and ans["paragraph"] != "IRPA s. 36(3)(c)": + k = ans["paragraph"][-3:] + pool = ([e for e in ind if e["kind"] == "conviction"] if k == "(a)" else summ + if k == "(b)" else [e for e in ind if e["kind"] == "act"]) + dates = [e[subj[k]] for e in pool if e[subj[k]]] + clk = max(dates) if dates else None + out["deem_clock"] = clk.isoformat() if clk else None + # a permanent resident: s. 36(1) only. An entry that is serious whatever its + # open facts (conviction or act alike) goes on to the limbs; an entry whose + # maximum, equivalent or youth status is open leaves the ground open. + sure = [e for e in ents if e["where"] == "foreign" and not e["youth"] + and len(e["classes"]) == 1 and e["cls"] in _INDICTABLE and e["lt10"] is False] + pr_open = [e for e in ents if e["where"] == "foreign" and e["lt10"] is not True + and set(e["classes"]) & set(_INDICTABLE) and e not in ind] + pr_open += [e for e in ind if e["lt10"] is None] + if status == "permanent-resident" and not sure and pr_open: + pr_needs = _dedupe( + [_need("youth-disposition", e["n"], None, + f"whether entry #{e['n']}, a youth disposition, counts") if e["youth"] else + e["class_need"] or _need("equivalent", e["n"], None, + f"which Canadian offence entry #{e['n']} equates to " + "-- resolve the entry and re-run") + if len(e["classes"]) > 1 else + _need("maximum", e["n"], None, + f"the Canadian maximum for entry #{e['n']} at the offence date") + for e in pr_open if e["youth"] or len(e["classes"]) > 1 or e["lt10"] is None]) + out.update(status="undetermined", provision="IRPA s. 36(1)(b)/(c)", + explanation=("Under s. 36 a permanent resident is inadmissible only under " + "s. 36(1): a foreign matter with a Canadian maximum of 10 years " + "or more. Whether " + + ", ".join(f"#{e['n']}" for e in pr_open) + + " is such a matter is NOT SETTLED -- " + + "; ".join(n.get("question") or n["fact"] for n in pr_needs) + + ". If none is, there is nothing to rehabilitate; if one is, " + "IRPR s. 18(2) and s. 17 apply to it (re-run with the fact)."), + clock=None, earliest_deemed=None, earliest_deemed_exact=True, + earliest_apply=None, paragraph=None, needs=pr_needs) + return out + if status == "permanent-resident" and not sure: + out.update(status="not-inadmissible-pr", provision="IRPA s. 36(1)(b)/(c); s. 36(2)", + explanation=("Under s. 36 a permanent resident is inadmissible only under " + "s. 36(1): no foreign matter on this record carries a " + "Canadian maximum of 10 years or more, so there is nothing to " + "rehabilitate."), + clock=None, earliest_deemed=None, earliest_deemed_exact=True, + earliest_apply=None, paragraph=None, needs=[]) + return out + # IRPA s. 36(2)(b) counts two summary-equivalent offences only if 'not + # arising out of a single occurrence' (the critic's point): on one + # occurrence there is no ground; an open occurrence is flagged and asked. + one_occ, occ_needs = False, [] + if len(summ) >= 2 and status != "permanent-resident": + least, most, pairs = _occurrences(summ) + one_occ = most <= 1 + if least < 2 and not one_occ: + occ_needs = [_need("occurrence_group", e["n"], "occurrence_group", + "whether the summary-equivalent convictions arose out of one " + "occurrence") for e in summ] + out["warnings"].append( + "whether " + ", ".join(f"{a} and {b}" for a, b in pairs) + " arose out of " + "one occurrence is open: IRPA s. 36(2)(b) counts two offences only if 'not " + "arising out of a single occurrence', so on one occurrence they ground nothing " + "and there is nothing to rehabilitate (give occurrence_group)") + if not ind and (len(summ) < 2 or one_occ) and not any( + e["where"] == "foreign" and (len(e["classes"]) > 1 or e["kind"] == "unknown" + or e["youth"]) for e in ents): + out.update(status=None, provision=None, clock=None, earliest_deemed=None, + earliest_deemed_exact=True, earliest_apply=None, paragraph=None, + needs=[], + explanation=("No foreign conviction or committed act on this record grounds " + "IRPA s. 36(1)(b)/(c) or (2)(b)/(c), so there is nothing to " + "rehabilitate under s. 18(2).")) + return out + if any(not l["exact"] and (l["from"] or l["until"]) for r in runs for k in _PARAS + if r["res"][k]["chapeau_holds"] for l in r["res"][k]["limbs"]) \ + or not all(r["answer"]["exact"] for r in runs): + out["warnings"].append( + "a conviction date not given: the 'within / before the last N years' limbs read " + "each such conviction between its offence date and its sentence completion, and " + "the dates shown are the latest they can be (give conviction_date to fix them)") + fy = [e["n"] for e in ents if e["where"] == "foreign" and e["youth"] and e["kind"] != "act"] + if fy: + out["warnings"].append( + f"entr{'y' if len(fy) == 1 else 'ies'} " + ", ".join(f"#{n}" for n in fy) + + f" {'is a foreign youth disposition' if len(fy) == 1 else 'are foreign youth dispositions'}: " + + RECORD_NOTES["foreign-youth"]) + groups = {} + for r in runs: + groups.setdefault(r["answer"]["status"], []).append(r) + if len(groups) > 1: + # a reading CanLex does not settle decides the answer (principle 5) + matter = [] + for dim, vals in (("hybrid", hybs), ("anchor", anchors)): + if len(vals) > 1 and len({frozenset(r["answer"]["status"] for r in runs + if r[dim] == v) for v in vals}) > 1: + matter.append(dim) + readings = [] + for st, rs in groups.items(): + r = rs[0] + words = [HYBRID_READINGS[r["hybrid"]] if d == "hybrid" else ANCHOR_READINGS[r["anchor"]] + for d in matter if len({x[d] for x in rs}) == 1] + readings.append({"reading": "; ".join(words) or "any reading", + "hybrid": r["hybrid"] if "hybrid" in matter else None, + "anchor": r["anchor"] if "anchor" in matter else None, + "status": st, "provision": r["answer"]["provision"], + "earliest_deemed": r["answer"]["earliest_deemed"], + "earliest_apply": r["answer"]["earliest_apply"], + "explanation": r["answer"]["explanation"], + "paragraphs": [_public(r["res"], k) for k in _PARAS + if r["res"][k]["chapeau_holds"]]}) + out["readings"] = readings + brief = " / ".join(f"{x['reading']}: {x['status'].upper().replace('-', ' ')}" + for x in readings) + needs = _dedupe([n for r in runs for k in _PARAS for n in r["res"][k]["needs"]] + + occ_needs) + out.update(status="not-settled", provision=f"{ans['paragraph'] or 'IRPR s. 18(2)'}; " + "IRPR s. 17", clock="s17", earliest_deemed=None, + earliest_deemed_exact=False, earliest_apply=ans["earliest_apply"], + paragraph=ans["paragraph"], needs=needs, + explanation=("NOT SETTLED -- the answer turns on how IRPR s. 18(2) is " + "read, which CanLex does not settle (roadmap DECISIONS): " + + brief + ". Each reading's limbs are shown.")) + return out + others = [{"reading": "; ".join(w for w in ( + HYBRID_READINGS[r["hybrid"]] if r["hybrid"] != first["hybrid"] else None, + ANCHOR_READINGS[r["anchor"]] if r["anchor"] != first["anchor"] else None) if w), + "hybrid": r["hybrid"], "anchor": r["anchor"], + "earliest_deemed": r["answer"]["earliest_deemed"]} + for r in runs[1:] + if r["answer"]["earliest_deemed"] != ans["earliest_deemed"]] + out["dates_differ"] = others or None + needs = _dedupe([n for k in _PARAS for n in res[k]["needs"] + if k in shown and res[k]["outcome"] == "open"] + + (occ_needs if ans["status"] != "deemed-rehabilitated" else [])) + out.update(status=ans["status"], provision=ans["provision"], + explanation=ans["explanation"], clock=ans["clock"], + earliest_deemed=ans["earliest_deemed"], earliest_deemed_exact=ans["exact"], + earliest_apply=ans["earliest_apply"], paragraph=ans["paragraph"], + needs=needs) + return out + + +# ---- canlex://methodology/rehabilitation -------------------------------- + +# (corpus chunk id, verbatim phrase): every quotation in METHODOLOGY. The +# tests check each phrase is in its chunk and in the text below. +METHODOLOGY_QUOTES = [ + ("SOR-2002-227-s17", "after the completion of an imposed sentence"), + ("SOR-2002-227-s17", "after committing an offence"), + ("SOR-2002-227-s17", "if the person has not been convicted of a subsequent offence"), + ("SOR-2002-227-s18-p1", "at least 10 years have elapsed since the day after the completion of the imposed sentence"), + ("SOR-2002-227-s18-p2", "at least five years have elapsed since the day after the completion of the imposed sentences"), + ("SOR-2002-227-s18.1", "The class of foreign nationals"), + ("SOR-2002-227-s18.1", "offences that may only be prosecuted summarily"), + ("SOR-2002-227-s18.1", "at least five years since the day after the completion of the imposed sentences"), + ("I-2.5-s36-p1", "A foreign national is inadmissible on grounds of criminality"), + ("I-2.5-s36-p1", "for which a term of imprisonment of more than six months has been imposed"), + ("I-2.5-s36-p1", "not arising out of a single occurrence"), + ("I-2.5-s36-p2", "is deemed to be an indictable offence, even if it has been prosecuted summarily"), + ("I-2.5-s36-p2", "the matters referred to in paragraphs (1)(b) and (c) and (2)(b) and (c)"), + ("I-2.5-s36-p2", "a record suspension has been ordered and has not been revoked or ceased to have effect under the Criminal Records Act"), + ("I-2.5-s64", "punished in Canada by a term of imprisonment of at least six months"), + ("I-2.5-s37", "A permanent resident or a foreign national"), + ("SOR-2002-227-s17", "subsequent offence"), + ("I-21-s37", "any period of twelve consecutive months"), + ("I-21-s28", "if that month has no day with that number, the last day of that month"), + ("I-21-s3", "unless a contrary intention appears"), + ("I-21-s34", "no person shall be considered to have been convicted of an indictable offence by reason only of having been convicted of the offence on summary conviction"), + ("policy-imm-5312-11", "You are eligible to apply for rehabilitation: not applicable"), + ("policy-imm-5312-13", "you are eligible to apply for rehabilitation on March 13, 2008"), +] + +METHODOLOGY = """\ +REHABILITATION METHODOLOGY -- what canlex_rehabilitation (step 3 of the IRPA s. 36 screening) applies + +These are statute-mechanical rules. Every quotation is from the corpus chunk named beside it (read it with canlex_get_section); nothing here is curated legal opinion, and the officer's determination governs. + +1. The two clocks +- IRPR s. 17 (SOR-2002-227-s17) is the period to APPLY for individual rehabilitation under IRPA s. 36(3)(c): five years "after the completion of an imposed sentence" (convictions, s. 36(1)(b)/(2)(b)) or "after committing an offence" (committed acts, s. 36(1)(c)/(2)(c)). The tool counts from that date itself, so the application date is the fifth anniversary, as Guide 5312's worked examples do (policy-imm-5312-13: a jail term ending March 13, 2003 means "you are eligible to apply for rehabilitation on March 13, 2008"). +- IRPR s. 18(2) (deemed rehabilitation) and s. 18.1 (the in-Canada exemption) count from the day after: "at least 10 years have elapsed since the day after the completion of the imposed sentence" (SOR-2002-227-s18-p1), "at least five years have elapsed since the day after the completion of the imposed sentences" (SOR-2002-227-s18-p2), "at least five years since the day after the completion of the imposed sentences" (SOR-2002-227-s18.1). These end one day later than the s. 17 clock. +- A year is "any period of twelve consecutive months" (Interpretation Act s. 37(1), I-21-s37), and a period of months ends on the day with the same number or, "if that month has no day with that number, the last day of that month" (s. 28(c), I-21-s28). Five years after 29 February 2016 is 28 February 2021. Each status is decided by comparing the assessment date with the very date the answer prints, so they always agree, and the Elapsed line shows the clock of the provision reported. +- The s. 17 period holds only "if the person has not been convicted of a subsequent offence" other than a contravention or a Young Offenders Act offence. CanLex reads that literally: a later conviction restarts the five years from the completion of its sentence. Whether a later conviction with no Canadian federal equivalent is a "subsequent offence" is NOT settled -- where it decides the date, canlex_screen_history gives both readings -- and a later conviction with no completion date leaves the application date open (the answer gives a lower bound, not a date). + +2. Which provision +- s. 17 (application): any matter in s. 36(1)(b),(c) or (2)(b),(c), serious criminality included, which can never be deemed rehabilitated. +- s. 18(2)(a), (b), (c) (deemed, no application): one foreign conviction or one committed act with a Canadian maximum below 10 years (10 years, day-after clock), or two or more summary-equivalent foreign convictions (five years), each subject to the seven conditions (i)-(vii) of its paragraph. canlex_rehabilitation takes one clean_record answer for those conditions and prints every limb to check; canlex_screen_history tests each limb against the record's entries and prints a limb table -- the entry that decides each limb, and the date it starts or stops holding. +- s. 18.1 (exemption from s. 36(2)(a)): "The class of foreign nationals" inadmissible solely for two or more in-Canada convictions for "offences that may only be prosecuted summarily". A permanent resident is never in it, and a hybrid offence never counts toward it. + +3. Permanent residents +- s. 36(2) opens "A foreign national is inadmissible on grounds of criminality" (I-2.5-s36-p1): criminality, and its two-offence limbs, never reach a permanent resident. Under s. 36, a PR is inadmissible only under s. 36(1): a foreign matter with a Canadian maximum of at least 10 years, or a Canadian conviction with such a maximum or "for which a term of imprisonment of more than six months has been imposed". Anything else grounds nothing against a PR under s. 36, and there is nothing to rehabilitate. Organized criminality (IRPA s. 37, I-2.5-s37) reaches "A permanent resident or a foreign national" alike; it is a separate ground that CanLex does not assess. +- The appeal to the Immigration Appeal Division: s. 64(2) (I-2.5-s64) sets a different threshold for a Canadian conviction, a crime "punished in Canada by a term of imprisonment of at least six months". Exactly six months meets s. 64(2) but not s. 36(1)(a). A conviction with a 10-year maximum and a shorter or non-custodial sentence is serious criminality, but the s. 64(1) bar does not apply to it. + +4. Canadian convictions: the record-suspension route +- Rehabilitation under s. 36(3)(c) covers only "the matters referred to in paragraphs (1)(b) and (c) and (2)(b) and (c)" (I-2.5-s36-p2), never a Canadian conviction under s. 36(1)(a) or (2)(a). The cure is a record suspension: inadmissibility may not rest on a conviction for which "a record suspension has been ordered and has not been revoked or ceased to have effect under the Criminal Records Act" (s. 36(3)(b)). +- A hybrid offence "is deemed to be an indictable offence, even if it has been prosecuted summarily" (s. 36(3)(a)), so for a foreign national one hybrid or indictable Canadian conviction engages s. 36(2)(a) on its own. Two offences count only if "not arising out of a single occurrence". +- The tool needs canada_offence_mode to tell an offence that may only be prosecuted summarily from a hybrid prosecuted summarily, and custody_imposed_months for the six-month limb. Without them it keeps its footing, prints the condition it assumed, and lists the missing input in 'needs'. + +5. Where official guidance departs from the Regulations +- Guide 5312 (policy-imm-5312-11) says of two or more summary-equivalent foreign convictions "You are eligible to apply for rehabilitation: not applicable". The Regulations do not exclude them: s. 36(3)(c) reaches s. 36(2)(b) matters, and s. 17(a) prescribes five years after completion for them. On a clean record the two differ by one day (s. 18(2)(b) deems rehabilitation the day after the s. 17 date); the difference matters where a s. 18(2)(b) condition fails and an application is the only route. The tool reports the statutory date and flags the Guide's reading as a point to confirm with IRCC. + +6. Not decided by the tool (where one of these decides the answer, read it both ways) +- Whether an intermittent sentence, or pre-sentence custody credited at sentencing, counts toward "more than six months" (a conditional sentence does not: Tran v. Canada (Public Safety and Emergency Preparedness), 2017 SCC 50, in the case-law corpus). +- Whether a hybrid equivalent counts as a summary conviction offence inside s. 18(2)(a)(iv)/(vi), since s. 36(3)(a) governs only IRPA s. 36. On that reading a Canadian hybrid conviction entered on summary conviction is not an indictable conviction in (iii) or (b)(ii) either: the Interpretation Act applies to every enactment "unless a contrary intention appears" (s. 3(1), I-21-s3), and s. 34(1)(c) (I-21-s34) says "no person shall be considered to have been convicted of an indictable offence by reason only of having been convicted of the offence on summary conviction". canlex_screen_history asks for canada_prosecuted where the Crown's election decides. +- The anchor date of "within the last 10 years" and "before the last 10 years" (assessment date; conviction or offence date). +- Whether two convictions entered the same day arise out of a single occurrence. +- Whether a Canadian conviction under a record suspension, or a youth disposition in a limb that carries no YCJA exception, counts in s. 18(2). canlex_screen_history runs both ways and marks a limb they decide UNKNOWN. Where a limb on convictions outside Canada does carry the exception ((a)(v), (b)(iv), (c)(v)), a foreign youth disposition is read as excepted by analogy, and the answer says so. +""" + + +def methodology_text(): + """The canlex://methodology/rehabilitation resource body.""" + return METHODOLOGY