From fcbec889de0b7e45542e1dc182226761f0404272 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 17:58:51 -0400 Subject: [PATCH 01/14] Apply the Income Support remunerative work and JSA conditions SSCBA 1992 s.124(1)(c) bars Income Support when the claimant is engaged in remunerative work or the other member of a couple is: paid work of 16 hours a week or more for the claimant (IS Regs 1987 reg 5(1)) and 24 for the partner (reg 5(1A)). A person within Sch 1B para 4, a carer, is not treated as engaged in it (reg 6(4)(c)). The partner's threshold applies to whoever is not the claimant, so the gate tests it for each candidate claimant. s.124(1)(f) bars it when the claimant is entitled to a jobseeker's allowance of either kind, or the partner (or the couple) to income-based JSA. The partner's contribution-based JSA does not bar it. Income-based JSA follows the income-related ESA pattern: the award on the claimant's and partner's reported amounts after the JSA capital test, through a new income_related_jsa_award helper that jsa_income also uses, or a jsa_income entered directly, taken to be theirs. - Parameters gov.dwp.income_support.eligibility.remunerative_work claimant_hours (24, 16 from 7 April 1992) and partner_hours (24, 16 from 7 April 1992, 24 from 7 October 1996), from legislation.gov.uk point-in-time texts of reg 5. - income_support_remunerative_work_hours: weekly_hours, enterable directly to leave out the employments reg 6(1) excludes (reg 5(6)). - 17 YAML cases hand-computed from the law; 6 fail on the previous gate. - The Hypothesis differential oracle now reads (c) and (f), and both properties draw hours, JSA and direct jsa_income. Co-Authored-By: Claude Opus 5.5 --- ...-remunerative-work-jsa-conditions.fixed.md | 2 + .../remunerative_work/claimant_hours.yaml | 21 + .../remunerative_work/partner_hours.yaml | 26 ++ .../income_support_work_and_jsa.yaml | 390 ++++++++++++++++++ ...t_income_support_eligibility_properties.py | 159 +++++-- .../tests/test_legacy_benefit_parameters.py | 18 + .../gov/dwp/income_support_eligible.py | 57 ++- .../income_support_remunerative_work_hours.py | 30 ++ .../variables/gov/dwp/jsa_income.py | 18 +- 9 files changed, 669 insertions(+), 52 deletions(-) create mode 100644 changelog.d/is-remunerative-work-jsa-conditions.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/claimant_hours.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/partner_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml create mode 100644 policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py diff --git a/changelog.d/is-remunerative-work-jsa-conditions.fixed.md b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md new file mode 100644 index 0000000000..71cf3b633e --- /dev/null +++ b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md @@ -0,0 +1,2 @@ +- Bar Income Support when the claimant is engaged in remunerative work, 16 hours a week or more of paid work, or the partner is, 24 hours or more (SSCBA 1992 s.124(1)(c); IS Regs 1987 reg 5(1) and (1A), with 24 hours for both before 7 April 1992 and 16 for the partner until 6 October 1996). A carer within Sch 1B para 4, claimant or partner, is not treated as engaged in remunerative work (reg 6(4)(c)). The hours come from the new `income_support_remunerative_work_hours`, which is weekly hours and can be entered directly to leave out work that reg 6(1) excludes, such as child minding at home or a councillor's duties. +- Bar Income Support when the claimant is entitled to a jobseeker's allowance of either kind, or the partner to income-based JSA (s.124(1)(f)). The partner's contribution-based JSA does not bar it. Income-based JSA is the award on the claimant's and partner's reported amounts after the JSA capital test; a `jsa_income` entered directly is taken to be theirs. diff --git a/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/claimant_hours.yaml b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/claimant_hours.yaml new file mode 100644 index 0000000000..47bc76463d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/claimant_hours.yaml @@ -0,0 +1,21 @@ +description: Income Support treats a claimant as engaged in remunerative work at this many weekly hours of paid work or more. +values: + 1988-04-11: + value: 24 + reference: + - title: Income Support (General) Regulations 1987 reg. 5(1) (as made) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5/1988-04-11 + 1992-04-07: + value: 16 + reference: + - title: Income Support (General) Regulations 1987 reg. 5(1) (as at 7 April 1992) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5/1992-04-07 +metadata: + unit: hour + period: week + label: Income Support claimant remunerative work hours + reference: + - title: Social Security Contributions and Benefits Act 1992 s. 124(1)(c) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/124 + - title: Income Support (General) Regulations 1987 reg. 5(1) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/partner_hours.yaml b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/partner_hours.yaml new file mode 100644 index 0000000000..07a98fd682 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/remunerative_work/partner_hours.yaml @@ -0,0 +1,26 @@ +description: Income Support treats a claimant's partner as engaged in remunerative work at this many weekly hours of paid work or more. +values: + 1988-04-11: + value: 24 + reference: + - title: Income Support (General) Regulations 1987 reg. 5(1) (as made) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5/1988-04-11 + 1992-04-07: + value: 16 + reference: + - title: Income Support (General) Regulations 1987 reg. 5(1) (as at 7 April 1992) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5/1992-04-07 + 1996-10-07: + value: 24 + reference: + - title: Income Support (General) Regulations 1987 reg. 5(1A), inserted by SI 1996/1944 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5/1996-10-07 +metadata: + unit: hour + period: week + label: Income Support partner remunerative work hours + reference: + - title: Social Security Contributions and Benefits Act 1992 s. 124(1)(c) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/124 + - title: Income Support (General) Regulations 1987 reg. 5(1A) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/5 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml new file mode 100644 index 0000000000..5bfa9a8677 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml @@ -0,0 +1,390 @@ +# Income Support remunerative work and Jobseeker's Allowance conditions. +# SSCBA 1992 s.124(1): a person is entitled to Income Support if +# (c) "he is not engaged in remunerative work and, if he is a member of a +# couple, the other member is not so engaged"; and +# (f) "he is not entitled to a jobseeker's allowance and, if he is a member +# of a couple, the other member of the couple is not, and the couple are +# not, entitled to an income-based jobseeker's allowance". +# IS Regs 1987 reg 5(1): remunerative work is paid work "for not less than 16 +# hours a week"; reg 5(1A) makes that 24 hours for "any partner of the +# claimant". Reg 6(4)(c): "a person to whom paragraph 4 of Schedule 1B +# applies" (a carer) "shall not be treated as engaged in remunerative work". +# +# The claimant is the claimant or partner who reports the existing award (see +# income_support_claimant_partner_gates.yaml). Hours are annual hours_worked; +# the weekly figure is hours_worked / 52, so 832 annual hours is 16 a week. +# These cases test the model's bounded gate: caring is is_carer_for_benefits, +# JSA and Income Support are reported awards, and the means test is out of +# scope. 2025 is used because the model pays Income Support until 2025-26. + +# s.124(1)(c): the claimant's remunerative work, 16 hours (reg 5(1)). + +- name: A lone parent working 15 hours a week is not in remunerative work + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + is_parent: true + income_support_reported: 1_000 + hours_worked: 780 + child: + age: 3 + is_claimant_or_partner: false + benunits: + family: + members: [parent, child] + income_support_assessable_capital: 0 + output: + # 780 / 52 = 15 hours, below 16. + income_support_remunerative_work_hours: [15, 0] + income_support_eligible: true + +- name: A lone parent working 16 hours a week is in remunerative work + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + is_parent: true + income_support_reported: 1_000 + hours_worked: 832 + child: + age: 3 + is_claimant_or_partner: false + benunits: + family: + members: [parent, child] + income_support_assessable_capital: 0 + output: + # 832 / 52 = 16 hours: "not less than 16 hours a week". + income_support_remunerative_work_hours: [16, 0] + income_support_eligible: false + +- name: A carer working 30 hours a week is not treated as in remunerative work + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + hours_worked: 1_560 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + # 30 hours, but reg 6(4)(c): Sch 1B para 4 applies to the carer. + income_support_remunerative_work_hours: 30 + income_support_eligible: true + +- name: Hours entered for the remunerative work test replace weekly hours + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + is_parent: true + income_support_reported: 1_000 + hours_worked: 1_560 + # For example a councillor's duties, which reg 6(1)(j) and reg 5(6) + # leave out of the count. + income_support_remunerative_work_hours: 0 + child: + age: 3 + is_claimant_or_partner: false + benunits: + family: + members: [parent, child] + income_support_assessable_capital: 0 + output: + weekly_hours: [30, 0] + income_support_eligible: true + +# s.124(1)(c): the partner's remunerative work, 24 hours (reg 5(1A)). + +- name: A partner working 20 hours a week does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + hours_worked: 1_040 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + # 1,040 / 52 = 20 hours: remunerative work for a claimant (16), but not + # for a partner (24). + income_support_eligible: true + +- name: A partner working 24 hours a week bars the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + hours_worked: 1_248 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + # 1,248 / 52 = 24 hours: the partner is so engaged. + income_support_eligible: false + +- name: The 24-hour test applies to whichever partner does not have the award + period: 2025 + input: + people: + worker: + age: 40 + is_claimant_or_partner: true + hours_worked: 1_040 + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [worker, carer] + income_support_assessable_capital: 0 + output: + # The carer, listed second, has the award and is the claimant; the + # worker's 20 hours are tested against the partner's 24. + income_support_eligible: true + +- name: A partner who is a carer is not treated as in remunerative work + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + hours_worked: 2_080 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + # 40 hours, but reg 6(4)(c) applies to any person within Sch 1B para 4. + income_support_eligible: true + +- name: An excluded member's remunerative work does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + hours_worked: 2_080 + benunits: + family: + members: [carer, other_adult] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +# s.124(1)(f): the claimant's JSA of either kind. + +- name: The claimant's contribution-based JSA bars the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + jsa_contrib_reported: 3_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + jsa_contrib: 3_000 + income_support_eligible: false + +- name: The claimant's income-based JSA bars the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + jsa_income_reported: 3_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + output: + jsa_income: 3_000 + income_support_eligible: false + +# s.124(1)(f): the partner's income-based JSA, not contribution-based JSA. + +- name: The partner's contribution-based JSA does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + jsa_contrib_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + jsa_contrib: [0, 3_000] + income_support_eligible: true + +- name: The partner's income-based JSA bars the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + output: + jsa_income: 3_000 + income_support_eligible: false + +- name: A reported income-based JSA award that tariff income extinguishes does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 200 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 10_000 + jsa_income_assessable_capital: 10_000 + output: + # JSA Regs 1996 reg 116: £1 a week for each £250 over £6,000, so + # ceil(4,000 / 250) = 16 a week, 16 x 52 = £832 a year, more than the + # £200 reported. No income-based JSA is payable; capital is within the + # Income Support limit of £16,000. + jsa_income_tariff_income: 832 + jsa_income: 0 + income_support_eligible: true + +- name: An excluded member's JSA does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + jsa_income_reported: 3_000 + jsa_contrib_reported: 3_000 + benunits: + family: + members: [carer, other_adult] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + output: + # The benefit unit's reported award is still paid; it is the excluded + # member's own award, not the claimant's or partner's. + jsa_income: 3_000 + income_support_eligible: true + +- name: Income-based JSA entered directly is taken to be the claimant's or partner's + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + jsa_income: 3_000 + output: + income_support_eligible: false + +- name: A zero income-based JSA entered directly overrides the reported amounts + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + jsa_income: 0 + output: + income_support_eligible: true diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index a291783973..27f10c1b2d 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -10,27 +10,30 @@ - adding a member who is neither the claimant, the partner nor a child or young person in the family (an adult outside the family, or a child placed by a local authority, IS reg 16(4)) never changes income_support_eligible, - whatever that member's age, ESA, Income Support or caring; + whatever that member's age, ESA, JSA, Income Support, caring or work; - income_support_eligible equals a family-by-family reading of the model's gate: one of the claimant and partner reports Income Support, is under state pension age, is a carer (or a lone parent of a child aged 5 or under, - the model's reading of Sch 1B para 1) and has no contributory ESA - (s.124(1)(aa), (e), (h)); neither has income-related ESA (s.124(1)(h)), - meaning the award on their reported amounts after the ESA capital test, or - an esa_income entered directly; and capital is within the Income Support - limit. + the model's reading of Sch 1B para 1), has no contributory ESA or JSA, is + not a non-carer working 16 hours a week or more, and has no other member + of the couple who is a non-carer working 24 hours or more (s.124(1)(aa), + (c), (e), (f), (h); IS Regs 1987 regs 5(1), 5(1A) and 6(4)(c)); neither + has income-related ESA or income-based JSA (s.124(1)(h), (f)), meaning the + award on their reported amounts after that benefit's capital test, or an + esa_income or jsa_income entered directly; and capital is within the + Income Support limit. The second property is a reference check of the bounded model gate, not of -legal entitlement: caring, ESA and Income Support are the model's reported -or proxy inputs, it reads state pension age from the model (is_SP_age), and -the means test is out of scope. +legal entitlement: caring, work hours, ESA, JSA and Income Support are the +model's reported or proxy inputs, it reads state pension age from the model +(is_SP_age), and the means test is out of scope. Roles are given explicitly (is_claimant_or_partner), so the properties test the eligibility rule rather than the role inference; the inferred case is covered in income_support_claimant_partner_gates.yaml. Each example builds many families in one simulation, in separate households and benefit units. Capital is either entered as each benefit unit's assessable capital or as -household savings, which both capital tests read. +household savings, which all three capital tests read. """ import math @@ -46,14 +49,34 @@ @st.composite def adult_inputs(draw, min_age=18): - return { + inputs = { "age": draw(st.integers(min_age, 90)), "receives_carer_benefit": draw(st.booleans()), "care_hours": draw(st.sampled_from([0, 34, 35])), "esa_income_reported": draw(st.sampled_from([0, 0, 200, 3_000])), "esa_contrib_reported": draw(st.sampled_from([0, 0, 3_000])), "income_support_reported": draw(st.sampled_from([0, 1_000, 1_000])), + # 0, 15, 16, 20, 24 and 40 hours a week. + "hours_worked": draw(st.sampled_from([0, 0, 780, 832, 1_040, 1_248, 2_080])), + "jsa_contrib_reported": draw(st.sampled_from([0, 0, 0, 3_000])), + "jsa_income_reported": draw(st.sampled_from([0, 0, 0, 200, 3_000])), } + if draw(st.integers(0, 2)) == 0: + # A typical award holder: a working-age carer with Income Support and + # no other benefit or paid work. Without these, the conditions + # together leave few families eligible, and each must be tested from + # both sides. + inputs.update( + age=draw(st.integers(min_age, max(min_age, 60))), + receives_carer_benefit=True, + income_support_reported=1_000, + esa_income_reported=0, + esa_contrib_reported=0, + hours_worked=draw(st.sampled_from([0, 780])), + jsa_contrib_reported=0, + jsa_income_reported=0, + ) + return inputs @st.composite @@ -100,29 +123,38 @@ def excluded_members(draw): } +@st.composite +def direct_award(draw, n): + """An award entered directly for each of n families, or None.""" + if draw(st.integers(0, 3)) == 0: + return draw(st.lists(st.sampled_from([0, 3_000]), min_size=n, max_size=n)) + return None + + @st.composite def input_settings(draw, n): - """How capital and income-related ESA are entered for n families. + """How capital, income-related ESA and income-based JSA are entered. Capital goes in as assessable capital or as household savings. When - esa_income is entered directly, it is entered for every family, so it is - a simulation input and the formula does not run. + esa_income or jsa_income is entered directly, it is entered for every + family, so it is a simulation input and the formula does not run. """ capital_as_savings = draw(st.booleans()) - esa_income = ( - draw(st.lists(st.sampled_from([0, 3_000]), min_size=n, max_size=n)) - if draw(st.integers(0, 3)) == 0 - else None - ) - return capital_as_savings, esa_income + return capital_as_savings, draw(direct_award(n)), draw(direct_award(n)) -def label(units, capital_as_savings, esa_income): +def label(units, capital_as_savings, esa_income, jsa_income): """Record which input surfaces an example reaches (--hypothesis-show-statistics).""" event( "capital as household savings" if capital_as_savings else "assessable capital" ) event("esa_income entered directly" if esa_income else "esa_income calculated") + event("jsa_income entered directly" if jsa_income else "jsa_income calculated") + adults = [a for family_adults, *_ in units for a in family_adults] + if any(a["hours_worked"] >= 832 for a in adults): + event("claimant or partner works 16 hours or more") + if any(a["jsa_contrib_reported"] or a["jsa_income_reported"] for a in adults): + event("claimant or partner reports JSA") extras = [extra for *_, extra in units if extra is not None] if any(e.get("is_looked_after_by_local_authority") for e in extras): event("placed child added") @@ -130,10 +162,12 @@ def label(units, capital_as_savings, esa_income): e.get("income_support_reported") or e.get("esa_income_reported") for e in extras ): event("added member reports IS or ESA") + if any(e.get("hours_worked") or e.get("jsa_income_reported") for e in extras): + event("added member works or reports income-based JSA") -def situation(units, capital_as_savings, esa_income): - label(units, capital_as_savings, esa_income) +def situation(units, capital_as_savings, esa_income, jsa_income): + label(units, capital_as_savings, esa_income, jsa_income) people, benunits, households = {}, {}, {} for i, (adults, dependants, capital, extra) in enumerate(units): members = [(m, True) for m in adults] + [(m, False) for m in dependants] @@ -152,8 +186,11 @@ def situation(units, capital_as_savings, esa_income): else: benunits[f"b{i}"]["income_support_assessable_capital"] = {YEAR: capital} benunits[f"b{i}"]["esa_income_assessable_capital"] = {YEAR: capital} + benunits[f"b{i}"]["jsa_income_assessable_capital"] = {YEAR: capital} if esa_income is not None: benunits[f"b{i}"]["esa_income"] = {YEAR: esa_income[i % len(esa_income)]} + if jsa_income is not None: + benunits[f"b{i}"]["jsa_income"] = {YEAR: jsa_income[i % len(jsa_income)]} return {"people": people, "benunits": benunits, "households": households} @@ -170,11 +207,13 @@ def situation(units, capital_as_savings, esa_income): @SETTINGS @given(FAMILIES, st.data()) def test_excluded_member_never_changes_is_eligibility(drawn, data): - capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) + capital_as_savings, esa_income, jsa_income = data.draw(input_settings(len(drawn))) without = [(*family, None) for family, _ in drawn] with_extra = [(*family, extra) for family, extra in drawn] sim = Simulation( - situation=situation(without + with_extra, capital_as_savings, esa_income) + situation=situation( + without + with_extra, capital_as_savings, esa_income, jsa_income + ) ) eligible = sim.calculate("income_support_eligible", YEAR) if eligible.any(): @@ -192,10 +231,30 @@ def test_excluded_member_never_changes_is_eligibility(drawn, data): assert eligible[i] == eligible[k + i], drawn[i] -def reference_eligibility(adults, dependants, capital, esa_income, sp_age, parameters): +def tariff_income(capital, rules): + """Annual tariff income: £1 a week for each £250 (or part) over £6,000.""" + excess = max(0, capital - rules.tariff_income.threshold) + return ( + math.ceil(excess / rules.tariff_income.step) * rules.tariff_income.amount * 52 + ) + + +def income_related_award(reported, capital, rules): + """Whether a reported award survives a legacy benefit's capital test.""" + return ( + reported > 0 + and capital <= rules.limit + and reported > tariff_income(capital, rules) + ) + + +def reference_eligibility( + adults, dependants, capital, esa_income, jsa_income, sp_age, parameters +): """The model's Income Support gate, read family by family.""" IS = parameters.gov.dwp.income_support - ESA = parameters.gov.dwp.ESA.income.capital + WORK = IS.eligibility.remunerative_work + JSA = parameters.gov.dwp.JSA.income child_ages = [d["age"] for d in dependants if d["age"] < 16] lone_parent_with_young_child = ( len(adults) == 1 @@ -204,32 +263,43 @@ def reference_eligibility(adults, dependants, capital, esa_income, sp_age, param <= IS.eligibility.lone_parent_youngest_child_age_limit ) - def is_claimant(adult, over_qualifying_age): - carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 + def carer(adult): + return adult["receives_carer_benefit"] or adult["care_hours"] >= 35 + + def works(adult, threshold): + # IS Regs 1987 reg 5(1) and (1A); reg 6(4)(c) for a carer. + return not carer(adult) and adult["hours_worked"] / 52 >= threshold + + def is_claimant(i): + adult, others = adults[i], adults[:i] + adults[i + 1 :] return ( adult["income_support_reported"] > 0 - and (carer or lone_parent_with_young_child) - and not over_qualifying_age + and (carer(adult) or lone_parent_with_young_child) + and not sp_age[i] and adult["esa_contrib_reported"] == 0 + and adult["jsa_contrib_reported"] == 0 + and not works(adult, WORK.claimant_hours) + and not any(works(other, WORK.partner_hours) for other in others) ) if esa_income is not None: income_related_esa = esa_income > 0 else: - reported_esa = sum(a["esa_income_reported"] for a in adults) - tariff = ( - math.ceil( - max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step - ) - * ESA.tariff_income.amount - * 52 + income_related_esa = income_related_award( + sum(a["esa_income_reported"] for a in adults), + capital, + parameters.gov.dwp.ESA.income.capital, ) - income_related_esa = ( - reported_esa > 0 and capital <= ESA.limit and reported_esa > tariff + if jsa_income is not None: + income_based_jsa = jsa_income > 0 + else: + income_based_jsa = JSA.active and income_related_award( + sum(a["jsa_income_reported"] for a in adults), capital, JSA.capital ) return ( - any(is_claimant(a, s) for a, s in zip(adults, sp_age)) + any(is_claimant(i) for i in range(len(adults))) and not income_related_esa + and not income_based_jsa and capital <= IS.means_test.capital.limit ) @@ -237,9 +307,11 @@ def is_claimant(adult, over_qualifying_age): @SETTINGS @given(FAMILIES, st.data()) def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): - capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) + capital_as_savings, esa_income, jsa_income = data.draw(input_settings(len(drawn))) units = [(*family, extra) for family, extra in drawn] - sim = Simulation(situation=situation(units, capital_as_savings, esa_income)) + sim = Simulation( + situation=situation(units, capital_as_savings, esa_income, jsa_income) + ) eligible = sim.calculate("income_support_eligible", YEAR) if eligible.any(): event("some family eligible") @@ -252,6 +324,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): dependants, capital, None if esa_income is None else esa_income[i], + None if jsa_income is None else jsa_income[i], sp_age[start : start + len(adults)], parameters, ) diff --git a/policyengine_uk/tests/test_legacy_benefit_parameters.py b/policyengine_uk/tests/test_legacy_benefit_parameters.py index 0cf46eea5e..ae1a373b66 100644 --- a/policyengine_uk/tests/test_legacy_benefit_parameters.py +++ b/policyengine_uk/tests/test_legacy_benefit_parameters.py @@ -12,3 +12,21 @@ def test_jsa_hours_parameter_still_exposed(): params = CountryTaxBenefitSystem().parameters assert params.gov.dwp.JSA.hours.single("2025") == 16 + + +def test_income_support_remunerative_work_hours_by_date(): + # IS Regs 1987 reg 5(1): 24 hours as made, 16 from 7 April 1992; reg 5(1A), + # inserted from 7 October 1996, makes it 24 for the partner. + work = CountryTaxBenefitSystem().parameters.gov.dwp.income_support.eligibility + work = work.remunerative_work + expected = { + "1990-01-01": (24, 24), + "1992-04-06": (24, 24), + "1992-04-07": (16, 16), + "1996-10-06": (16, 16), + "1996-10-07": (16, 24), + "2025-01-01": (16, 24), + } + for date, (claimant, partner) in expected.items(): + assert work.claimant_hours(date) == claimant, date + assert work.partner_hours(date) == partner, date diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index ab8f7b3904..03dc77d0fd 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award class income_support_eligible(Variable): @@ -13,9 +14,13 @@ class income_support_eligible(Variable): "new claim, so the claimant is the one of the claimant and partner " "who has the existing award (income_support_reported). They must be " "under the qualifying age for State Pension Credit, fall within a " - "prescribed category (a carer, or a lone parent of a young child) " - "and not be entitled to Employment and Support Allowance. Neither " - "the claimant nor the partner may be entitled to income-related ESA. " + "prescribed category (a carer, or a lone parent of a young child), " + "not be engaged in remunerative work (16 hours a week or more, unless " + "a carer) and not be entitled to Employment and Support Allowance or " + "Jobseeker's Allowance. The partner must not be engaged in " + "remunerative work (24 hours a week or more, unless a carer). Neither " + "the claimant nor the partner may be entitled to income-related ESA " + "or income-based JSA. " "A member of the benefit unit who is neither the claimant, the " "partner nor a child or young person in the family (such as a " "non-dependent adult) does not affect eligibility." @@ -24,6 +29,8 @@ class income_support_eligible(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/1992/4/section/124", "https://www.legislation.gov.uk/uksi/1987/1967/regulation/4ZA", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/5", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/6", "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", "https://www.legislation.gov.uk/uksi/1987/1968/regulation/4", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/6A", @@ -68,11 +75,40 @@ def formula(benunit, period, parameters): # Reading Pension Credit here would make a dependency cycle through # Working Tax Credit. under_qualifying_age = ~person("is_SP_age", period) + # s.124(1)(c): neither the claimant nor the other member of a couple + # is engaged in remunerative work: paid work of at least 16 hours a + # week for the claimant (IS Regs 1987 reg 5(1)) and 24 for the + # partner (reg 5(1A)). A person to whom Sch 1B para 4 applies, a carer, + # is not treated as engaged in it, whether claimant or partner + # (reg 6(4)(c)). The partner's threshold applies to whoever is not + # the claimant, so it is tested for each candidate claimant against + # the other member of the couple. + WORK = IS.eligibility.remunerative_work + hours = person("income_support_remunerative_work_hours", period) + not_treated_as_working = person("is_carer_for_benefits", period) + works_as_claimant = ~not_treated_as_working & (hours >= WORK.claimant_hours) + works_as_partner = ( + claimant_or_partner + & ~not_treated_as_working + & (hours >= WORK.partner_hours) + ) + other_member_works = ( + benunit.project(benunit.sum(works_as_partner)) - works_as_partner + ) > 0 + # s.124(1)(f): the claimant is not entitled to a jobseeker's allowance + # of either kind. Income-based JSA is tested below for both of them. + no_contributory_jsa = person("jsa_contrib", period) <= 0 # s.124(1)(h): the claimant is not entitled to an employment and # support allowance of either kind ... no_contributory_esa = person("esa_contrib", period) <= 0 claimant = ( - has_award & prescribed_category & under_qualifying_age & no_contributory_esa + has_award + & prescribed_category + & under_qualifying_age + & no_contributory_esa + & no_contributory_jsa + & ~works_as_claimant + & ~other_member_works ) # ... and the other member of a couple is not entitled to an # income-related allowance. An income-related allowance covers the @@ -89,9 +125,22 @@ def formula(benunit, period, parameters): person("esa_income_reported", period) * claimant_or_partner ) income_related_esa = income_related_esa_award(benunit, period, reported) > 0 + # s.124(1)(f): neither the claimant nor the other member of a couple + # is, and the couple are not, entitled to an income-based jobseeker's + # allowance. As for ESA: the award on their reported amounts after + # the same capital test as jsa_income, or a jsa_income entered + # directly, taken to be theirs. + if "jsa_income" in benunit.simulation.input_variables: + income_based_jsa = benunit("jsa_income", period) > 0 + else: + reported = benunit.sum( + person("jsa_income_reported", period) * claimant_or_partner + ) + income_based_jsa = income_related_jsa_award(benunit, period, reported) > 0 capital = benunit("income_support_assessable_capital", period) return ( benunit.any(claimant) & ~income_related_esa + & ~income_based_jsa & (capital <= IS.means_test.capital.limit) ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py new file mode 100644 index 0000000000..88da1ee588 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class income_support_remunerative_work_hours(Variable): + value_type = float + entity = Person + label = "Weekly hours of paid work for the Income Support remunerative work test" + documentation = ( + "Hours a week of work for which payment is made or which is done in " + "expectation of payment, averaged where they fluctuate (IS Regs 1987 " + "reg 5(1) and (2)). The model takes them to be weekly_hours, the " + "average over the year of usual hours in paid jobs. Hours in the " + "employments reg 6(1) lists do not count (reg 5(6)): child minding at " + "home, charitable or voluntary work, a training allowance scheme, the " + "self-employment route, retained firefighting and the other duties in " + "Sch 8 para 7(1), a councillor's duties, foster and other placement " + "care, and sports award activity. The model's inputs do not identify " + "those employments, so to apply them enter this variable directly, as " + "for days of leave or illness (reg 5(3A)). Reg 6(4)(c), which takes a " + "carer out of remunerative work, is applied in income_support_eligible." + ) + definition_period = YEAR + unit = "hour" + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/5", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/6", + ) + + def formula(person, period, parameters): + return person("weekly_hours", period) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index 96e91a94f6..c584664475 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -1,6 +1,18 @@ from policyengine_uk.model_api import * +def income_related_jsa_award(benunit, period, reported_award): + """Income-based JSA paid on a reported award: the award less tariff + income from capital, or nothing if the scheme is not active or the + benefit unit fails the capital test (jsa_income_eligible).""" + JSA = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.JSA + if not JSA.income.active: + return benunit.empty_array() + tariff_income = benunit("jsa_income_tariff_income", period) + eligible = benunit("jsa_income_eligible", period) + return where(eligible, max_(0, reported_award - tariff_income), 0) + + class jsa_income(Variable): value_type = float entity = BenUnit @@ -13,9 +25,5 @@ class jsa_income(Variable): unit = GBP def formula(benunit, period, parameters): - if not parameters(period).gov.dwp.JSA.income.active: - return benunit.empty_array() reported_award = add(benunit, period, ["jsa_income_reported"]) - tariff_income = benunit("jsa_income_tariff_income", period) - eligible = benunit("jsa_income_eligible", period) - return where(eligible, max_(0, reported_award - tariff_income), 0) + return income_related_jsa_award(benunit, period, reported_award) From 1ca0ba8da130822fb4c2bbdad872f92b8eb3d14c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 18:08:26 -0400 Subject: [PATCH 02/14] Test that more work or JSA never makes a family Income Support eligible s.124(1)(c) and (f) only bar a claim, so raising any claimant's or partner's hours or JSA cannot turn an ineligible family eligible. The new Hypothesis property builds each family twice, the second time with more paid work or JSA, and checks eligibility never rises. Co-Authored-By: Claude Opus 5.5 --- ...t_income_support_eligibility_properties.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 27f10c1b2d..ea2c0cacc8 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -21,7 +21,9 @@ has income-related ESA or income-based JSA (s.124(1)(h), (f)), meaning the award on their reported amounts after that benefit's capital test, or an esa_income or jsa_income entered directly; and capital is within the - Income Support limit. + Income Support limit; +- raising any claimant's or partner's hours or JSA never makes a family + eligible, because (c) and (f) only ever bar a claim. The second property is a reference check of the bounded model gate, not of legal entitlement: caring, work hours, ESA, JSA and Income Support are the @@ -330,3 +332,43 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): ) assert eligible[i] == expected, units[i] start += len(adults) + len(dependants) + 1 + + +@st.composite +def more_work_or_jsa(draw, adults): + """The same adults with more paid work or JSA (s.124(1)(c), (f)).""" + more = [dict(adult) for adult in adults] + for adult in more: + adult["hours_worked"] += draw(st.sampled_from([0, 260, 832, 1_248])) + adult["jsa_contrib_reported"] += draw(st.sampled_from([0, 0, 3_000])) + adult["jsa_income_reported"] += draw(st.sampled_from([0, 0, 3_000])) + return more + + +@SETTINGS +@given(st.lists(families(), min_size=1, max_size=8), st.data()) +def test_more_work_or_jsa_never_makes_a_family_eligible(drawn, data): + """(c) and (f) only ever bar a claim: raising any claimant's or partner's + hours or JSA cannot turn an ineligible family eligible.""" + capital_as_savings, esa_income, jsa_income = data.draw(input_settings(len(drawn))) + jsa_income = None if jsa_income is None else jsa_income * 2 + more = [ + (data.draw(more_work_or_jsa(adults)), dependants, capital) + for adults, dependants, capital in drawn + ] + units = [(*family, None) for family in drawn + more] + sim = Simulation( + situation=situation( + units, + capital_as_savings, + None if esa_income is None else esa_income * 2, + jsa_income, + ) + ) + eligible = sim.calculate("income_support_eligible", YEAR) + if eligible[: len(drawn)].any(): + event("some family eligible before") + if (eligible[: len(drawn)] & ~eligible[len(drawn) :]).any(): + event("more work or JSA removed eligibility") + for i in range(len(drawn)): + assert not (eligible[len(drawn) + i] and not eligible[i]), (drawn[i], more[i]) From 7072d343cc9c078d988bdff241eb926deba39aed Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:18:25 -0400 Subject: [PATCH 03/14] Respect a jsa_income entered directly for its own period and branch Review r1 of #2025 (finding 1): the gate detected a direct jsa_income through Simulation.input_variables. That list is fixed at construction and ignores the period, so it missed an input set later with set_input or on a branch, and treated an input for one year as an input for every year. An excluded member's report then counted in a year with no direct input. entered_directly (policyengine_uk/utils/inputs.py) reads core's record of explicit inputs, (variable, branch, period) in _user_input_keys, for the branches the simulation can see, and requires the value still be stored. #2013 adopts the same helper for esa_income. Also from the review: - jsa_income returns zero before reading reports when income-based JSA is not active, as before the helper was introduced (finding 4); - income_support_remunerative_work_hours says weekly_hours is a usual week, not the reg 5(2)/(3B) average for fluctuating hours (finding 3); - YAML cases for a partner at 23 hours, inferred roles with an excluded grandparent, two caring award holders with contribution-based JSA, and a direct jsa_income for another year; pytest regressions for late, branch and other-year inputs (finding 5). Co-Authored-By: Claude Opus 5.5 --- ...-remunerative-work-jsa-conditions.fixed.md | 4 +- .../income_support_work_and_jsa.yaml | 154 ++++++++++++++++++ .../test_income_support_direct_inputs.py | 113 +++++++++++++ policyengine_uk/utils/inputs.py | 29 ++++ .../gov/dwp/income_support_eligible.py | 5 +- .../income_support_remunerative_work_hours.py | 24 +-- .../variables/gov/dwp/jsa_income.py | 2 + 7 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 policyengine_uk/tests/test_income_support_direct_inputs.py create mode 100644 policyengine_uk/utils/inputs.py diff --git a/changelog.d/is-remunerative-work-jsa-conditions.fixed.md b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md index 71cf3b633e..d4e46c1057 100644 --- a/changelog.d/is-remunerative-work-jsa-conditions.fixed.md +++ b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md @@ -1,2 +1,2 @@ -- Bar Income Support when the claimant is engaged in remunerative work, 16 hours a week or more of paid work, or the partner is, 24 hours or more (SSCBA 1992 s.124(1)(c); IS Regs 1987 reg 5(1) and (1A), with 24 hours for both before 7 April 1992 and 16 for the partner until 6 October 1996). A carer within Sch 1B para 4, claimant or partner, is not treated as engaged in remunerative work (reg 6(4)(c)). The hours come from the new `income_support_remunerative_work_hours`, which is weekly hours and can be entered directly to leave out work that reg 6(1) excludes, such as child minding at home or a councillor's duties. -- Bar Income Support when the claimant is entitled to a jobseeker's allowance of either kind, or the partner to income-based JSA (s.124(1)(f)). The partner's contribution-based JSA does not bar it. Income-based JSA is the award on the claimant's and partner's reported amounts after the JSA capital test; a `jsa_income` entered directly is taken to be theirs. +- Bar Income Support when the claimant is engaged in remunerative work, 16 hours a week or more of paid work, or the partner is, 24 hours or more (SSCBA 1992 s.124(1)(c); IS Regs 1987 reg 5(1) and (1A), with 24 hours for both before 7 April 1992 and 16 for the partner until 6 October 1996). A carer within Sch 1B para 4, claimant or partner, is not treated as engaged in remunerative work (reg 6(4)(c)). The hours come from the new `income_support_remunerative_work_hours`, which is usual weekly hours and can be entered directly to average fluctuating hours or to leave out work that reg 6(1) excludes, such as child minding at home or a councillor's duties. +- Bar Income Support when the claimant is entitled to a jobseeker's allowance of either kind, or the partner to income-based JSA (s.124(1)(f)). The partner's contribution-based JSA does not bar it. Income-based JSA is the award on the claimant's and partner's reported amounts after the JSA capital test; a `jsa_income` entered directly for the year, when the simulation is built or later, or on a branch, is taken to be theirs. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml index 5bfa9a8677..ff8de9474c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml @@ -388,3 +388,157 @@ jsa_income: 0 output: income_support_eligible: true + +# Boundaries and roles. + +- name: A partner working 23 hours a week does not bar the claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 40 + is_claimant_or_partner: true + hours_worked: 1_196 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + # 1,196 / 52 = 23 hours, below the partner's 24. + income_support_eligible: true + +- name: With inferred roles, a partner working 24 hours bars the claim and an excluded grandparent does not count + period: 2025 + input: + people: + mother: + age: 34 + is_parent: true + is_benunit_head: true + receives_carer_benefit: true + income_support_reported: 1_000 + father: + age: 36 + is_parent: true + hours_worked: 1_248 + child: + age: 8 + grandparent: + age: 71 + hours_worked: 2_080 + jsa_income_reported: 3_000 + benunits: + family: + members: [mother, father, child, grandparent] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + output: + is_claimant_or_partner: [true, true, false, false] + # The father, the other member of the couple, works 24 hours. + income_support_eligible: false + +- name: With inferred roles and a partner under 24 hours, an excluded grandparent's work and JSA do not bar the claim + period: 2025 + input: + people: + mother: + age: 34 + is_parent: true + is_benunit_head: true + receives_carer_benefit: true + income_support_reported: 1_000 + father: + age: 36 + is_parent: true + hours_worked: 1_040 + child: + age: 8 + grandparent: + age: 71 + hours_worked: 2_080 + jsa_income_reported: 3_000 + benunits: + family: + members: [mother, father, child, grandparent] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + output: + is_claimant_or_partner: [true, true, false, false] + income_support_eligible: true + +- name: When both partners care and report Income Support, one without JSA can claim + period: 2025 + input: + people: + first: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + jsa_contrib_reported: 3_000 + second: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [first, second] + income_support_assessable_capital: 0 + output: + # The second partner meets every claimant condition; the first's + # contribution-based JSA bars only the first as claimant (s.124(1)(f)). + income_support_eligible: true + +- name: When both partners care and report Income Support but both have contribution-based JSA, neither can claim + period: 2025 + input: + people: + first: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + jsa_contrib_reported: 3_000 + second: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + jsa_contrib_reported: 3_000 + benunits: + family: + members: [first, second] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: Income-based JSA entered directly for another year does not apply to this one + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + family: + members: [carer, other_adult] + income_support_assessable_capital: 0 + jsa_income_assessable_capital: 0 + jsa_income: + 2024: 0 + output: + # Only the excluded adult reports income-based JSA in 2025; the input + # entered for 2024 says nothing about 2025. + income_support_eligible: true diff --git a/policyengine_uk/tests/test_income_support_direct_inputs.py b/policyengine_uk/tests/test_income_support_direct_inputs.py new file mode 100644 index 0000000000..73a2834634 --- /dev/null +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -0,0 +1,113 @@ +"""A directly entered income-based JSA counts for its own period and branch. + +income_support_eligible takes a jsa_income entered directly to be the +claimant's or partner's award (SSCBA 1992 s.124(1)(f)), and otherwise reads +their reported awards after the jsa_income capital screen. Whether jsa_income +was entered is decided by entered_directly, per period and branch, so an +input set after the simulation is built, or on a branch, is respected, and an +input for one year does not apply to another. YAML cases cover inputs given +when the simulation is built (income_support_work_and_jsa.yaml). +""" + +from policyengine_uk import Simulation +from policyengine_uk.utils.inputs import entered_directly + +YEAR = 2025 + + +def carer_family(benunit_inputs=None, other_adult=None): + """A carer aged 40 with an Income Support award, and nothing else.""" + people = { + "carer": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + } + } + if other_adult is not None: + people["other_adult"] = other_adult + members = list(people) + return Simulation( + situation={ + "people": people, + "benunits": { + "family": { + "members": members, + "income_support_assessable_capital": {YEAR: 0}, + "jsa_income_assessable_capital": {YEAR: 0}, + **(benunit_inputs or {}), + } + }, + "households": {"home": {"members": members}}, + } + ) + + +def eligible(simulation, year=YEAR): + return bool(simulation.calculate("income_support_eligible", year)[0]) + + +def test_carer_family_is_eligible_without_jsa(): + assert eligible(carer_family()) + + +def test_jsa_income_set_after_construction_bars_the_claim(): + simulation = carer_family() + simulation.set_input("jsa_income", YEAR, [3_000]) + assert simulation.calculate("jsa_income", YEAR)[0] == 3_000 + assert not eligible(simulation) + + +def test_jsa_income_set_after_the_gate_was_calculated_bars_the_claim(): + simulation = carer_family() + assert eligible(simulation) + simulation.set_input("jsa_income", YEAR, [3_000]) + simulation.delete_arrays("income_support_eligible") + assert not eligible(simulation) + + +def test_jsa_income_set_on_a_branch_bars_the_claim_on_that_branch_only(): + simulation = carer_family() + branch = simulation.get_branch("with_jsa", clone_system=False) + branch.set_input("jsa_income", YEAR, [4_000]) + assert not eligible(branch) + assert eligible(simulation) + + +def test_zero_jsa_income_set_after_construction_overrides_reports(): + simulation = carer_family( + other_adult={ + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "jsa_income_reported": {YEAR: 3_000}, + } + ) + assert not eligible(simulation) + simulation.set_input("jsa_income", YEAR, [0]) + simulation.delete_arrays("income_support_eligible") + assert eligible(simulation) + + +def test_jsa_income_entered_for_another_year_does_not_apply(): + excluded_adult_with_jsa = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "jsa_income_reported": {YEAR: 3_000}, + } + simulation = carer_family( + benunit_inputs={"jsa_income": {YEAR - 1: 0}}, + other_adult=excluded_adult_with_jsa, + ) + assert eligible(simulation) + assert not entered_directly(simulation.benunit, "jsa_income", YEAR) + assert entered_directly(simulation.benunit, "jsa_income", YEAR - 1) + + +def test_entered_directly_is_false_for_a_calculated_value(): + simulation = carer_family() + simulation.calculate("jsa_income", YEAR) + assert not entered_directly(simulation.benunit, "jsa_income", YEAR) + assert entered_directly( + simulation.benunit, "income_support_assessable_capital", YEAR + ) diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py new file mode 100644 index 0000000000..899a344c6d --- /dev/null +++ b/policyengine_uk/utils/inputs.py @@ -0,0 +1,29 @@ +"""Whether a variable was entered directly as a simulation input.""" + +from policyengine_core import periods + + +def entered_directly(population, variable, period): + """Whether ``variable`` was set as an input for ``period``. + + The input counts if it was set on this simulation's branch or a branch it + inherits from, at construction or later with ``set_input``. The value + must also still be stored. ``Simulation.input_variables`` cannot answer + this: it is fixed when the simulation is built and ignores the period, so + it misses later inputs and treats an input for one year as an input for + every year. Core records each explicit input as (variable, branch, + period) in ``_user_input_keys``, which is what ``to_input_dataframe`` + reads; this reads the same record. + """ + simulation = population.simulation + period = periods.period(period) + if hasattr(simulation, "_get_visible_branch_names"): + branches = set(simulation._get_visible_branch_names()) + else: + branches = {getattr(simulation, "branch_name", "default"), "default"} + if period not in simulation.get_holder(variable).get_known_periods(): + return False + return any( + name == variable and branch in branches and periods.period(key) == period + for name, branch, key in getattr(simulation, "_user_input_keys", ()) + ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 03dc77d0fd..54e4de18ea 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award @@ -129,8 +130,8 @@ def formula(benunit, period, parameters): # is, and the couple are not, entitled to an income-based jobseeker's # allowance. As for ESA: the award on their reported amounts after # the same capital test as jsa_income, or a jsa_income entered - # directly, taken to be theirs. - if "jsa_income" in benunit.simulation.input_variables: + # directly for this period, taken to be theirs. + if entered_directly(benunit, "jsa_income", period): income_based_jsa = benunit("jsa_income", period) > 0 else: reported = benunit.sum( diff --git a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py index 88da1ee588..8a299797a6 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py +++ b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py @@ -8,16 +8,20 @@ class income_support_remunerative_work_hours(Variable): documentation = ( "Hours a week of work for which payment is made or which is done in " "expectation of payment, averaged where they fluctuate (IS Regs 1987 " - "reg 5(1) and (2)). The model takes them to be weekly_hours, the " - "average over the year of usual hours in paid jobs. Hours in the " - "employments reg 6(1) lists do not count (reg 5(6)): child minding at " - "home, charitable or voluntary work, a training allowance scheme, the " - "self-employment route, retained firefighting and the other duties in " - "Sch 8 para 7(1), a councillor's duties, foster and other placement " - "care, and sports award activity. The model's inputs do not identify " - "those employments, so to apply them enter this variable directly, as " - "for days of leave or illness (reg 5(3A)). Reg 6(4)(c), which takes a " - "carer out of remunerative work, is applied in income_support_eligible." + "reg 5(1) and (2)). The model takes them to be weekly_hours, a usual " + "week's hours in paid jobs (the Enhanced FRS records usual weekly " + "hours across current jobs). That is not the average reg 5(2) and " + "(3B) require for fluctuating hours, over a recognisable cycle or the " + "five weeks before the claim, so for such work enter this variable " + "directly. Hours in the employments reg 6(1) lists do not count " + "(reg 5(6)): child minding at home, charitable or voluntary work, a " + "training allowance scheme, the self-employment route, retained " + "firefighting and the other duties in Sch 8 para 7(1), a councillor's " + "duties, foster and other placement care, and sports award activity. " + "The model's inputs do not identify those employments either, nor " + "days of leave or illness (reg 5(3A)); enter the hours directly to " + "apply them. Reg 6(4)(c), which takes a carer out of remunerative " + "work, is applied in income_support_eligible." ) definition_period = YEAR unit = "hour" diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index c584664475..600faf9faa 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -25,5 +25,7 @@ class jsa_income(Variable): unit = GBP def formula(benunit, period, parameters): + if not parameters(period).gov.dwp.JSA.income.active: + return benunit.empty_array() reported_award = add(benunit, period, ["jsa_income_reported"]) return income_related_jsa_award(benunit, period, reported_award) From 6337f31418e0fecf2dae0e44f55513bda5475ca7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:19:48 -0400 Subject: [PATCH 04/14] Drop the duplicate entered_directly import from the merge Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/income_support_eligible.py | 1 - 1 file changed, 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 3d6e5d6c21..d659f7b43a 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,7 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award -from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award From 48abd88178f9d717ddce8e5048711b50e2415096 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:54:10 -0400 Subject: [PATCH 05/14] Keep the record of direct inputs in step with stored values Review r2 of #2025: entered_directly read core's _user_input_keys, but in policyengine-core 3.32.9 that record drifts from storage. - clone() copies the holders' stored arrays but shares the record, so an input set on a clone, or on a parent after a branch was made, was recorded against arrays it never reached. An excluded adult's report then barred Income Support on the original or the earlier branch. - delete_arrays() removes arrays but keeps their record, so a formula result calculated later for the same period looked like an input. The UK Simulation (and so Microsimulation) now gives each clone its own copy of the record and drops the entries for deleted arrays. Both changes only remove stale or foreign entries; they also correct what core's own _invalidate_all_caches and to_input_dataframe read from the record. entered_directly now asks about the stored value the simulation reads: the first visible branch (own, parents', default) that stores one, as the holder reads. It no longer falls back silently when core's record is missing. test_entered_directly_lifecycle.py covers both awards (jsa_income, esa_income) with an excluded adult reporting the award. Six of its sixteen cases fail without the Simulation changes. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 40 +++++ .../tests/test_entered_directly_lifecycle.py | 149 ++++++++++++++++++ policyengine_uk/utils/inputs.py | 39 ++--- 3 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 policyengine_uk/tests/test_entered_directly_lifecycle.py diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index eee6214d44..397667cef7 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -222,6 +222,46 @@ def reset_calculations(self): if variable not in self.input_variables: self.delete_arrays(variable) + def clone(self, *args, **kwargs) -> "Simulation": + """Clone the simulation, with its own record of explicit inputs. + + Core's ``clone`` copies the holders' stored arrays but shares + ``_user_input_keys``, the (variable, branch, period) record of values + set with ``set_input``. An input set afterwards on the clone (or on a + branch, which is a clone) was then recorded against the original's + own stored arrays too. Each copy keeps its own record here, matching + its own storage. ``entered_directly`` reads that record. + """ + new = super().clone(*args, **kwargs) + new._user_input_keys = set(getattr(self, "_user_input_keys", ())) + return new + + def delete_arrays(self, variable: str, period=None) -> None: + """Delete stored values, and forget that deleted inputs were inputs. + + Core removes the arrays but keeps their ``_user_input_keys`` entries, + so a formula result calculated later for the same period would be + taken for a user input. The entries for the variable, on the + branches whose storage this deletes and in the deleted periods, are + dropped with the arrays. + """ + super().delete_arrays(variable, period) + keys = getattr(self, "_user_input_keys", None) + if not keys: + return + if period is not None: + period = period_(period) + branches = set(self._get_visible_branch_names()) + self._user_input_keys = { + key + for key in keys + if not ( + key[0] == variable + and key[1] in branches + and (period is None or period.contains(period_(key[2]))) + ) + } + def get_known_variables(self): variables = [] for variable in self.tax_benefit_system.variables: diff --git a/policyengine_uk/tests/test_entered_directly_lifecycle.py b/policyengine_uk/tests/test_entered_directly_lifecycle.py new file mode 100644 index 0000000000..caa5517ffc --- /dev/null +++ b/policyengine_uk/tests/test_entered_directly_lifecycle.py @@ -0,0 +1,149 @@ +"""A direct award stays tied to the stored value it was entered as. + +income_support_eligible takes an income-related ESA or income-based JSA +entered directly (esa_income, jsa_income) to be the claimant's or partner's +award, and otherwise reads only their reported awards (SSCBA 1992 +s.124(1)(f), (h)). entered_directly decides which, from core's record of +explicit inputs. These cases follow that record through deletion, clones and +branches, with an adult outside the couple who reports £3,000 of the award: +whenever the stored award is a formula result, that adult's report must not +bar the claim. +""" + +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.utils.inputs import entered_directly + +YEAR = 2025 + +AWARDS = { + "jsa_income": ("jsa_income_reported", "jsa_income_assessable_capital"), + "esa_income": ("esa_income_reported", "esa_income_assessable_capital"), +} + + +def family(award): + """A caring award holder and an excluded adult who reports the award.""" + reported, capital = AWARDS[award] + members = ["carer", "other_adult"] + return Simulation( + situation={ + "people": { + "carer": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + }, + "other_adult": { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + reported: {YEAR: 3_000}, + }, + }, + "benunits": { + "family": { + "members": members, + "income_support_assessable_capital": {YEAR: 0}, + capital: {YEAR: 0}, + } + }, + "households": {"home": {"members": members}}, + } + ) + + +def eligible(simulation): + simulation.delete_arrays("income_support_eligible") + return bool(simulation.calculate("income_support_eligible", YEAR)[0]) + + +def direct(simulation, award): + return entered_directly(simulation.benunit, award, YEAR) + + +@pytest.mark.parametrize("award", AWARDS) +def test_the_excluded_adults_report_never_bars_the_claim(award): + simulation = family(award) + # The award's own formula counts the excluded adult's report ... + assert simulation.calculate(award, YEAR)[0] == 3_000 + # ... but the gate reads only the claimant's and partner's. + assert not direct(simulation, award) + assert eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_deleted_input_does_not_make_a_later_formula_result_direct(award): + simulation = family(award) + simulation.set_input(award, YEAR, [0]) + assert direct(simulation, award) and eligible(simulation) + simulation.delete_arrays(award) + assert not direct(simulation, award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + assert not direct(simulation, award) + assert eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_deleted_input_then_the_gate_reads_reports(award): + simulation = family(award) + simulation.set_input(award, YEAR, [3_000]) + assert not eligible(simulation) + simulation.delete_arrays(award) + assert eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_an_input_on_a_clone_does_not_reach_the_original(award): + simulation = family(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + clone = simulation.clone() + clone.set_input(award, YEAR, [0]) + assert clone._user_input_keys is not simulation._user_input_keys + assert direct(clone, award) + assert not direct(simulation, award) + assert eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_parent_input_does_not_reach_an_earlier_branch(award): + simulation = family(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + branch = simulation.get_branch("before", clone_system=False) + simulation.set_input(award, YEAR, [3_000]) + # The parent's input is its own; the branch still stores the formula + # result it copied, which counts the excluded adult. + assert direct(simulation, award) and not eligible(simulation) + assert not direct(branch, award) + assert eligible(branch) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_branch_made_after_a_parent_input_inherits_it(award): + simulation = family(award) + simulation.set_input(award, YEAR, [3_000]) + branch = simulation.get_branch("after", clone_system=False) + assert direct(branch, award) + assert not eligible(branch) + + +@pytest.mark.parametrize("award", AWARDS) +def test_nested_branches_read_the_nearest_stored_input(award): + simulation = family(award) + outer = simulation.get_branch("outer", clone_system=False) + outer.set_input(award, YEAR, [3_000]) + inner = outer.get_branch("inner", clone_system=False) + assert direct(inner, award) and not eligible(inner) + inner.set_input(award, YEAR, [0]) + assert direct(inner, award) and eligible(inner) + assert not eligible(outer) + assert not direct(simulation, award) and eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_period_given_as_a_string_is_the_same_year(award): + simulation = family(award) + simulation.set_input(award, YEAR, [0]) + assert entered_directly(simulation.benunit, award, str(YEAR)) + assert not entered_directly(simulation.benunit, award, str(YEAR + 1)) diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py index 899a344c6d..828ce93aaa 100644 --- a/policyengine_uk/utils/inputs.py +++ b/policyengine_uk/utils/inputs.py @@ -4,26 +4,27 @@ def entered_directly(population, variable, period): - """Whether ``variable`` was set as an input for ``period``. + """Whether the value of ``variable`` for ``period`` is a user input. - The input counts if it was set on this simulation's branch or a branch it - inherits from, at construction or later with ``set_input``. The value - must also still be stored. ``Simulation.input_variables`` cannot answer - this: it is fixed when the simulation is built and ignores the period, so - it misses later inputs and treats an input for one year as an input for - every year. Core records each explicit input as (variable, branch, - period) in ``_user_input_keys``, which is what ``to_input_dataframe`` - reads; this reads the same record. + The simulation reads the value from the first of its visible branches + (its own, then its parents', then the default) that stores one, so this + asks whether that stored value was set with ``set_input``, when the + simulation was built or later. ``Simulation.input_variables`` cannot + answer this: it is fixed when the simulation is built and ignores the + period, so it misses later inputs and treats an input for one year as an + input for every year. + + Core records each explicit input as (variable, branch, period) in + ``_user_input_keys``. The UK ``Simulation`` keeps that record in step + with storage: a clone gets its own copy, and ``delete_arrays`` drops the + entries for the arrays it deletes, so a formula result stored later is + not taken for an input. """ simulation = population.simulation period = periods.period(period) - if hasattr(simulation, "_get_visible_branch_names"): - branches = set(simulation._get_visible_branch_names()) - else: - branches = {getattr(simulation, "branch_name", "default"), "default"} - if period not in simulation.get_holder(variable).get_known_periods(): - return False - return any( - name == variable and branch in branches and periods.period(key) == period - for name, branch, key in getattr(simulation, "_user_input_keys", ()) - ) + keys = simulation._user_input_keys + stored = set(simulation.get_holder(variable).get_known_branch_periods()) + for branch in simulation._get_visible_branch_names(): + if (branch, period) in stored: + return (variable, branch, period) in keys + return False From 8b9fe3f2aca083a14e3708efd33a3b1627d1589e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:54:10 -0400 Subject: [PATCH 06/14] Describe the reg 5(2) hours more precisely Review r2 of #2025: reg 5(2) takes expected weekly hours or, for fluctuating hours, a complete recognisable cycle or the five weeks before the claim (or a more accurate period), and reg 5(7) counts paid meal breaks. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/income_support_remunerative_work_hours.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py index 8a299797a6..4b24b59aad 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py +++ b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py @@ -10,9 +10,11 @@ class income_support_remunerative_work_hours(Variable): "expectation of payment, averaged where they fluctuate (IS Regs 1987 " "reg 5(1) and (2)). The model takes them to be weekly_hours, a usual " "week's hours in paid jobs (the Enhanced FRS records usual weekly " - "hours across current jobs). That is not the average reg 5(2) and " - "(3B) require for fluctuating hours, over a recognisable cycle or the " - "five weeks before the claim, so for such work enter this variable " + "hours across current jobs). Reg 5(2) and (3B) instead take the " + "hours expected in a week or, where they fluctuate, the average over " + "a complete recognisable cycle or the five weeks before the claim (or " + "another period that gives a more accurate average), and reg 5(7) " + "counts paid meal breaks; for such work enter this variable " "directly. Hours in the employments reg 6(1) lists do not count " "(reg 5(6)): child minding at home, charitable or voluntary work, a " "training allowance scheme, the self-employment route, retained " From 4d5137078555fdd8429d3b7f188340707346559b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:18:59 -0400 Subject: [PATCH 07/14] Name reg 5(3B)'s excluded periods and reg 5(7)'s refreshment time Review r3 of #2025 (finding 7): a school-year cycle leaves out school holidays and other periods the person is not required to work; reg 5(2)(b)(ii)'s five weeks run to the claim or a superseding decision; paid refreshment time counts with meal breaks. Co-Authored-By: Claude Opus 5.5 --- .../dwp/income_support_remunerative_work_hours.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py index 4b24b59aad..3c20225649 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py +++ b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py @@ -10,12 +10,14 @@ class income_support_remunerative_work_hours(Variable): "expectation of payment, averaged where they fluctuate (IS Regs 1987 " "reg 5(1) and (2)). The model takes them to be weekly_hours, a usual " "week's hours in paid jobs (the Enhanced FRS records usual weekly " - "hours across current jobs). Reg 5(2) and (3B) instead take the " - "hours expected in a week or, where they fluctuate, the average over " - "a complete recognisable cycle or the five weeks before the claim (or " - "another period that gives a more accurate average), and reg 5(7) " - "counts paid meal breaks; for such work enter this variable " - "directly. Hours in the employments reg 6(1) lists do not count " + "hours across current jobs). Reg 5(2) instead takes the hours " + "expected in a week or, where they fluctuate, the average over a " + "complete recognisable cycle or the five weeks before the claim or a " + "superseding decision (or another period that gives a more accurate " + "average). A school-year cycle leaves out school holidays and other " + "periods the person is not required to work (reg 5(3B)), and paid " + "meal and refreshment time counts (reg 5(7)). For such work enter " + "this variable directly. Hours in the employments reg 6(1) lists do not count " "(reg 5(6)): child minding at home, charitable or voluntary work, a " "training allowance scheme, the self-employment route, retained " "firefighting and the other duties in Sch 8 para 7(1), a councillor's " From bfff081f4dbb29d3989528a5d39bf05e02751d25 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:25:41 -0400 Subject: [PATCH 08/14] Describe the value rule for a jsa_income in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/is-remunerative-work-jsa-conditions.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/is-remunerative-work-jsa-conditions.fixed.md b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md index d4e46c1057..00b7b693eb 100644 --- a/changelog.d/is-remunerative-work-jsa-conditions.fixed.md +++ b/changelog.d/is-remunerative-work-jsa-conditions.fixed.md @@ -1,2 +1,2 @@ - Bar Income Support when the claimant is engaged in remunerative work, 16 hours a week or more of paid work, or the partner is, 24 hours or more (SSCBA 1992 s.124(1)(c); IS Regs 1987 reg 5(1) and (1A), with 24 hours for both before 7 April 1992 and 16 for the partner until 6 October 1996). A carer within Sch 1B para 4, claimant or partner, is not treated as engaged in remunerative work (reg 6(4)(c)). The hours come from the new `income_support_remunerative_work_hours`, which is usual weekly hours and can be entered directly to average fluctuating hours or to leave out work that reg 6(1) excludes, such as child minding at home or a councillor's duties. -- Bar Income Support when the claimant is entitled to a jobseeker's allowance of either kind, or the partner to income-based JSA (s.124(1)(f)). The partner's contribution-based JSA does not bar it. Income-based JSA is the award on the claimant's and partner's reported amounts after the JSA capital test; a `jsa_income` entered directly for the year, when the simulation is built or later, or on a branch, is taken to be theirs. +- Bar Income Support when the claimant is entitled to a jobseeker's allowance of either kind, or the partner to income-based JSA (s.124(1)(f)). The partner's contribution-based JSA does not bar it. Income-based JSA is the award on the claimant's and partner's reported amounts after the JSA capital test. A `jsa_income` that holds anything the reported amounts do not give (neither that award on everyone's reports nor their plain total), such as an award entered directly or a reform that removes it, is taken to be theirs. From 8411f168cf6f25d68d4dda1c2e118e2158913183 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:01:58 -0400 Subject: [PATCH 09/14] Name the jsa_income screen in the gate's JSA comment The award on the reported amounts is whatever screen jsa_income applies (jsa_income_eligible), so the comment stays true when that screen gains conditions. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/income_support_eligible.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 9905df0d4c..6ff9fb67f3 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -147,8 +147,9 @@ def formula(benunit, period, parameters): # s.124(1)(f): neither the claimant nor the other member of a couple # is, and the couple are not, entitled to an income-based jobseeker's # allowance, read the same way: the award on the claimant's and - # partner's reported amounts after the jsa_income capital test, unless - # jsa_income holds something the reported amounts do not give. + # partner's reported amounts after the screen jsa_income applies + # (jsa_income_eligible), unless jsa_income holds something the + # reported amounts do not give. jsa_income = benunit("jsa_income", period) jsa_reported_total = add(benunit, period, ["jsa_income_reported"]) jsa_award_on_all_reports = income_related_jsa_award( From ffe3a5800ac459cd7e2cbb13f335ef4faa8a06b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:04:27 -0400 Subject: [PATCH 10/14] Name the jsa_income_eligible screen in the JSA helper's docstring Matches #2013's wording for income_related_esa_award (44d59d909). Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/jsa_income.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index 600faf9faa..58dad5288d 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -4,7 +4,7 @@ def income_related_jsa_award(benunit, period, reported_award): """Income-based JSA paid on a reported award: the award less tariff income from capital, or nothing if the scheme is not active or the - benefit unit fails the capital test (jsa_income_eligible).""" + benefit unit fails the screen in jsa_income_eligible.""" JSA = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.JSA if not JSA.income.active: return benunit.empty_array() From 8ac874694fa14e993af3de4c5ce3eb1cdb5d674c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 04:11:16 -0400 Subject: [PATCH 11/14] Compare JSA awards at storage precision; state the value convention's boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r4 of #2025: - jsa_income is stored as float32, while the report sum and the helper's award are float64. With large reports (£65,536.01 + £65,536.00) the formula's own award looked entered and barred the claim. Both references are now cast to the stored dtype before comparison. - Under the value convention, another member's report can change how a directly entered award is read. The variable documentation and the property module now state that boundary and the half-penny tolerance instead of claiming invariance for every input. test_income_support_direct_inputs.py pins the intended transitions: an excluded adult's report equal to a direct award, and a claimant's report that explains it. Co-Authored-By: Claude Opus 5.5 --- .../test_income_support_direct_inputs.py | 90 +++++++++++++++++++ ...t_income_support_eligibility_properties.py | 10 +++ .../gov/dwp/income_support_eligible.py | 19 +++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_direct_inputs.py b/policyengine_uk/tests/test_income_support_direct_inputs.py index 2a571f52e2..159e67bf9e 100644 --- a/policyengine_uk/tests/test_income_support_direct_inputs.py +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -160,3 +160,93 @@ def test_the_raw_reported_total_is_read_through_the_reports(): # A different award entered directly is the couple's. simulation.set_input("jsa_income", YEAR, [4_000]) assert not eligible(simulation) + + +def three_person_family(other_adults, benunit_inputs=None, capital=0): + """A caring award holder and the given adults, none claimant or partner.""" + people = { + "carer": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + } + } + for i, report in enumerate(other_adults): + people[f"other_{i}"] = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "jsa_income_reported": {YEAR: report}, + } + members = list(people) + return Simulation( + situation={ + "people": people, + "benunits": { + "family": { + "members": members, + "income_support_assessable_capital": {YEAR: capital}, + "jsa_income_assessable_capital": {YEAR: capital}, + **(benunit_inputs or {}), + } + }, + "households": {"home": {"members": members}}, + } + ) + + +def test_large_reports_are_compared_at_storage_precision(): + # jsa_income is stored as float32: £65,536.01 + £65,536.00 is kept as + # £131,072.00, while the float64 sum of the reports is £131,072.0078. The + # formula's own award, and the raw total set as disable_simulated_benefits + # sets it (the model's own sum of the reports), must still be read as what + # the reports give, so the excluded adults' awards do not count. + simulation = three_person_family([65_536.01, 65_536.00]) + assert simulation.calculate("jsa_income", YEAR)[0] == 131_072 + assert eligible(simulation) + raw_total = simulation.calculate("jsa_income_reported", YEAR, map_to="benunit") + simulation.set_input("jsa_income", YEAR, raw_total) + assert eligible(simulation) + + +def test_an_excluded_adults_report_can_explain_a_direct_award(): + # Intended (the value convention): a direct £4,000 that no report + # explains is the couple's, and bars the claim. Once an adult outside + # the couple reports exactly £4,000, the reports explain it and say it is + # that adult's, so it no longer bars the claim. + entered = {"jsa_income": {YEAR: 4_000}} + assert not eligible(three_person_family([], benunit_inputs=entered)) + assert eligible(three_person_family([4_000], benunit_inputs=entered)) + + +def test_a_claimants_report_can_explain_a_direct_award(): + # Intended: a direct £200 with £10,000 of capital and no reports is the + # claimant's, and bars the claim. If the claimant reports exactly £200, + # the raw total explains it, and the claimant's £200 is extinguished by + # tariff income of £832 a year, so it no longer bars the claim. + claimant_reports = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + } + + def single(report): + person = {**claimant_reports, "jsa_income_reported": {YEAR: report}} + return Simulation( + situation={ + "people": {"carer": person}, + "benunits": { + "family": { + "members": ["carer"], + "income_support_assessable_capital": {YEAR: 10_000}, + "jsa_income_assessable_capital": {YEAR: 10_000}, + "jsa_income": {YEAR: 200}, + } + }, + "households": {"home": {"members": ["carer"]}}, + } + ) + + assert not eligible(single(0)) + assert eligible(single(200)) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index e194f6c817..07c519a210 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -28,6 +28,16 @@ - raising any claimant's or partner's hours or JSA never makes a family eligible, because (c) and (f) only ever bar a claim. +The first and third hold for every input except at the value convention's +boundary. A directly entered esa_income or jsa_income that equals what the +reported amounts give is read through them, so a change to another member's +report, or to the claimant's own, can change how the entered award is read. +The generators stay off that boundary. They enter £4,000, which no generated +total of reports can equal, or £0, whose two readings agree: where £0 is +what the reports give, they give no award to bar the claim either. +test_income_support_direct_inputs.py tests the boundary by example, with +the intended results. + The second property is a reference check of the bounded model gate, not of legal entitlement: caring, work hours, ESA, JSA and Income Support are the model's reported or proxy inputs, it reads state pension age from the model diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index b1d52bfe6b..9d2fe46c7f 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -25,7 +25,13 @@ class income_support_eligible(Variable): "the claimant nor the partner may be entitled to income-related ESA " "or income-based JSA. An adult in the benefit unit who is neither the " "claimant nor the partner (such as a non-dependent adult) does not " - "affect eligibility." + "affect eligibility, except in one case. An award entered directly as " + "esa_income or jsa_income is taken to be the claimant's or partner's " + "unless it equals, to within half a penny at the precision the award " + "is stored in, the award all the reported " + "amounts give or their plain total; then the reported amounts decide " + "whose it is. So another member's report can change how a directly " + "entered award is read." ) definition_period = YEAR reference = ( @@ -161,9 +167,16 @@ def formula(benunit, period, parameters): period, benunit.sum(person("jsa_income_reported", period) * claimant_or_partner), ) + # Compare in the stored award's precision: the holder keeps it as + # float32, while the sums are float64. jsa_as_reported = np.isclose( - jsa_income, jsa_award_on_all_reports, rtol=0, atol=0.005 - ) | np.isclose(jsa_income, jsa_reported_total, rtol=0, atol=0.005) + jsa_income, + jsa_award_on_all_reports.astype(jsa_income.dtype), + rtol=0, + atol=0.005, + ) | np.isclose( + jsa_income, jsa_reported_total.astype(jsa_income.dtype), rtol=0, atol=0.005 + ) income_based_jsa = where( jsa_as_reported, jsa_award_on_claimant_or_partner_reports > 0, From 2c234931f9f0d3138753563610514455b45bccf2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:41:09 -0400 Subject: [PATCH 12/14] State the value convention for any stored award; pin its tolerance and direct-award modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r5 of #2025 (APPROVE WITH NITS): - The documentation and property module describe the convention for a stored esa_income or jsa_income however it was supplied (entered or replaced by a reform), compared within half a penny after rounding to the stored precision. - test_the_half_penny_tolerance_endpoints pins £100.004 (explained) and £100.006 (the couple's) against a direct £100. - test_direct_awards_match_the_reference_deterministically checks both direct-award modes against the oracle for a fixed carer; it kills a mutant that ignores direct ESA, which the 25-example differential let survive. Co-Authored-By: Claude Opus 5.5 --- .../test_income_support_direct_inputs.py | 11 ++++ ...t_income_support_eligibility_properties.py | 52 +++++++++++++++++-- .../gov/dwp/income_support_eligible.py | 14 ++--- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_direct_inputs.py b/policyengine_uk/tests/test_income_support_direct_inputs.py index 159e67bf9e..d16b417fe8 100644 --- a/policyengine_uk/tests/test_income_support_direct_inputs.py +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -250,3 +250,14 @@ def single(report): assert not eligible(single(0)) assert eligible(single(200)) + + +def test_the_half_penny_tolerance_endpoints(): + # A direct £100 against an excluded adult's report: within half a penny + # (£100.004, stored as £100.00399...) the reports explain it and say it + # is that adult's; beyond it (£100.006), or with no report, it is the + # couple's and bars the claim. + entered = {"jsa_income": {YEAR: 100}} + assert not eligible(three_person_family([0], benunit_inputs=entered)) + assert eligible(three_person_family([100.004], benunit_inputs=entered)) + assert not eligible(three_person_family([100.006], benunit_inputs=entered)) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 7185ccdcbb..2c6c7a76c1 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -29,9 +29,11 @@ eligible, because (c) and (f) only ever bar a claim. The first and third hold for every input except at the value convention's -boundary. A directly entered esa_income or jsa_income that equals what the -reported amounts give is read through them, so a change to another member's -report, or to the claimant's own, can change how the entered award is read. +boundary. A stored esa_income or jsa_income (entered directly or replaced by +a reform) that equals what the reported amounts give, to within half a penny +after rounding to the precision it is stored in, is read through them. So a +change to another member's report, or to the claimant's own, can change how +that award is read. The generators stay off that boundary. They enter £4,000, which no generated total of reports can equal, or £0, whose two readings agree: where £0 is what the reports give, they give no award to bar the claim either. @@ -525,3 +527,47 @@ def test_more_work_or_jsa_never_makes_a_family_eligible(drawn, data): event("more work or JSA removed eligibility") for i in range(len(drawn)): assert not (eligible[len(drawn) + i] and not eligible[i]), (drawn[i], more[i]) + + +# One fixed example: situation() records Hypothesis events, so it runs inside +# a test with a single, constant draw. +@settings(max_examples=1, deadline=None, derandomize=True) +@given(st.just(None)) +def test_direct_awards_match_the_reference_deterministically(_): + """Both direct-award modes, for an otherwise eligible carer: a direct + income-related ESA or income-based JSA of £4,000, which no report + explains, bars the claim; a direct £0 lifts a reported award's bar. + Random draws reach these combinations only some of the time.""" + carer = { + "age": 40, + "receives_carer_benefit": True, + "care_hours": 0, + "esa_income_reported": 0, + "esa_contrib_reported": 0, + "income_support_reported": 1_000, + "hours_worked": 0, + "jsa_contrib_reported": 0, + "jsa_income_reported": 0, + } + reporting_carer = { + **carer, + "esa_income_reported": 3_000, + "jsa_income_reported": 3_000, + } + cases = [ + (carer, 4_000, 0, False), + (carer, 0, 4_000, False), + (carer, 0, 0, True), + (reporting_carer, 0, 0, True), + (reporting_carer, 4_000, 0, False), + ] + units = [([dict(adult)], [], 0, None) for adult, *_ in cases] + sim = Simulation( + situation=situation(units, False, [c[1] for c in cases], [c[2] for c in cases]) + ) + eligible = sim.calculate("income_support_eligible", YEAR) + parameters = sim.tax_benefit_system.parameters(YEAR) + for i, (adult, esa, jsa, expected) in enumerate(cases): + reference = reference_eligibility([adult], [], 0, esa, jsa, [False], parameters) + assert reference == expected, (adult, esa, jsa) + assert eligible[i] == expected, (adult, esa, jsa) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 29d3fc78a3..77dc38b11d 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -25,13 +25,13 @@ class income_support_eligible(Variable): "the claimant nor the partner may be entitled to income-related ESA " "or income-based JSA. An adult in the benefit unit who is neither the " "claimant nor the partner (such as a non-dependent adult) does not " - "affect eligibility, except in one case. An award entered directly as " - "esa_income or jsa_income is taken to be the claimant's or partner's " - "unless it equals, to within half a penny at the precision the award " - "is stored in, the award all the reported " - "amounts give or their plain total; then the reported amounts decide " - "whose it is. So another member's report can change how a directly " - "entered award is read." + "affect eligibility, except in one case. A stored esa_income or " + "jsa_income, whether entered directly or replaced by a reform, is " + "taken to be the claimant's or partner's unless it equals, to within " + "half a penny after rounding to the precision it is stored in, the " + "award all the reported amounts give or their plain total; then the " + "reported amounts decide whose it is. So another member's report can " + "change how such an award is read." ) definition_period = YEAR reference = ( From f159918cac872eb6f13685c635100ef1a85212d2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 18:45:05 -0400 Subject: [PATCH 13/14] Address the after-merge review: the (g) path and half-penny endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The monotonicity docstrings now say why more JSA cannot help through s.124(1)(g): reported income-based JSA ends the inferred SI 2019/37 saving only for a couple in which neither member reports Income Support, and such a family has no award holder. - The JSA formula's-residual test cross-references the couple's-residual test, as main's ESA test does, and notes the residual comes from core summing the reports in float64. - New tests pin the couple's award tolerance at half a penny for JSA and ESA (£52.004 is no award, £52.006 bars the claim). Each fails when its guard is set to > 0. Co-Authored-By: Claude Opus 5.5 --- .../test_income_support_direct_inputs.py | 19 +++++++++++++++++++ ...t_income_support_eligibility_properties.py | 12 ++++++++++-- ...est_income_support_esa_entered_directly.py | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_direct_inputs.py b/policyengine_uk/tests/test_income_support_direct_inputs.py index e988754000..6f519f99c0 100644 --- a/policyengine_uk/tests/test_income_support_direct_inputs.py +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -347,6 +347,10 @@ def test_the_formulas_residual_is_no_award(): # £52 of tariff income the formula leaves a sub-penny residual. That # residual is no award, so it does not bar the claim. residual = reporting_family(10.01, 41.99) + # A residual above zero, so this case (and the couple's residual in + # test_the_couples_residual_is_no_award_beside_another_members_report) + # exercises the half-penny rule rather than an exact zero. The residual + # exists because core sums the reports in float64. assert 0 < residual.calculate("jsa_income", YEAR)[0] < 0.005 assert eligible(residual) @@ -373,3 +377,18 @@ def test_the_couples_residual_is_no_award_beside_another_members_report(): penny = reporting_family(52.01) assert 0.005 < penny.calculate("jsa_income", YEAR)[0] < 0.015 assert not eligible(penny) + + +def test_the_couples_award_tolerance_is_half_a_penny(): + # Beside an excluded adult's £3,000, jsa_income is a real award that the + # reports explain, so the couple's own award decides. £52.004 against + # £52 of tariff income leaves them £0.004, which is no award; £52.006 + # leaves £0.006, which bars the claim. + def couple_award(report): + return reporting_family(report, other_adults=[3_000]) + + below, above = couple_award(52.004), couple_award(52.006) + assert below.calculate("jsa_income", YEAR)[0] > 2_999 + assert eligible(below) + assert above.calculate("jsa_income", YEAR)[0] > 2_999 + assert not eligible(above) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 205c5f7ec1..051faf0ab6 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -27,7 +27,13 @@ taken to be on State Pension Credit (s.124(1)(g)); and capital is within the Income Support limit; - raising any claimant's or partner's hours or JSA never makes a family - eligible, because (c) and (f) only ever bar a claim. + eligible. (c) and (f) only ever bar a claim. The one other path is (g): + income-based JSA reported by the claimant or partner ends the inferred + SI 2019/37 saving (has_mixed_age_couple_pension_credit_saving), which can + take a mixed-age couple out of the Pension Credit age conditions and so + lift the (g) bar. That inference already fails when either of them + reports Income Support, and a couple in which neither does has no award + holder, so it is never eligible. The first and third hold for every input except at the value convention's boundary. A stored esa_income or jsa_income (entered directly or replaced by @@ -548,7 +554,9 @@ def more_work_or_jsa(draw, adults): @given(st.lists(families(), min_size=1, max_size=8), st.data()) def test_more_work_or_jsa_never_makes_a_family_eligible(drawn, data): """(c) and (f) only ever bar a claim: raising any claimant's or partner's - hours or JSA cannot turn an ineligible family eligible.""" + hours or JSA cannot turn an ineligible family eligible. More JSA can lift + the (g) bar only for a couple with no Income Support report, which is + never eligible (see the module docstring).""" capital_as_savings, esa_income, jsa_income = data.draw(input_settings(len(drawn))) jsa_income = None if jsa_income is None else jsa_income * 2 more = [ diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index 7d2aab8efe..bf19537350 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -256,6 +256,24 @@ def test_the_couples_residual_is_no_award_beside_another_members_report(): assert not eligible(penny) +def test_the_couples_award_tolerance_is_half_a_penny(): + # Beside an excluded adult's £3,000, esa_income is a real award that the + # reports explain, so the couple's own award decides. £52.004 against + # £52 of tariff income leaves them £0.004, which is no award; £52.006 + # leaves £0.006, which bars the claim. + capital = {"esa_income_assessable_capital": {YEAR: 6_250}} + + def couple_award(report): + carer = {**CARER, "esa_income_reported": {YEAR: report}} + return simulation({"carer": carer, "other_adult": EXCLUDED_ADULT}, capital) + + below, above = couple_award(52.004), couple_award(52.006) + assert below.calculate("esa_income", YEAR)[0] > 2_999 + assert eligible(below) + assert above.calculate("esa_income", YEAR)[0] > 2_999 + assert not eligible(above) + + def test_the_tolerance_is_half_a_penny(): # An entered £100 is read through an excluded adult's report of £100.004 # (within half a penny) but not £100.006, which leaves it the couple's. From 198adeaa964cdf8363c7aca416652e05f993dacd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:33:17 -0400 Subject: [PATCH 14/14] Narrow the (g)-path sentence to the Housing Benefit route Confirmatory review (job 20261008-184614-is-work-jsa-review-after-merge-r2, finding 1): reported income-based JSA ends only the Housing Benefit route to the inferred SI 2019/37 saving; reported Pension Credit keeps the saving on its own (has_mixed_age_couple_pension_credit_saving). Docstring only. Co-Authored-By: Claude Opus 5.5 --- .../test_income_support_eligibility_properties.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 051faf0ab6..2ad7e658bb 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -28,12 +28,13 @@ the Income Support limit; - raising any claimant's or partner's hours or JSA never makes a family eligible. (c) and (f) only ever bar a claim. The one other path is (g): - income-based JSA reported by the claimant or partner ends the inferred - SI 2019/37 saving (has_mixed_age_couple_pension_credit_saving), which can - take a mixed-age couple out of the Pension Credit age conditions and so - lift the (g) bar. That inference already fails when either of them - reports Income Support, and a couple in which neither does has no award - holder, so it is never eligible. + income-based JSA reported by the claimant or partner can end the Housing + Benefit route to the inferred SI 2019/37 saving + (has_mixed_age_couple_pension_credit_saving) when neither member reports + Pension Credit, which can take a mixed-age couple out of the Pension + Credit age conditions and so lift the (g) bar. That Housing Benefit route + already fails when either of them reports Income Support, and a couple in + which neither does has no award holder, so it is never eligible. The first and third hold for every input except at the value convention's boundary. A stored esa_income or jsa_income (entered directly or replaced by