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..6d4690e875 --- /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 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` 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. As for ESA, an amount within half a penny of zero is no award, so a float residual does not bar the claim. 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..ff8de9474c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_work_and_jsa.yaml @@ -0,0 +1,544 @@ +# 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 + +# 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_entered_directly_lifecycle.py b/policyengine_uk/tests/test_entered_directly_lifecycle.py new file mode 100644 index 0000000000..8da15defc3 --- /dev/null +++ b/policyengine_uk/tests/test_entered_directly_lifecycle.py @@ -0,0 +1,139 @@ +"""The award the gate reads follows the stored value through any lifecycle. + +income_support_eligible reads income-related ESA and income-based JSA from +the claimant's and partner's reported awards (SSCBA 1992 s.124(1)(f), (h)), +unless esa_income or jsa_income holds a different award from the one all +the reported amounts give, which is then taken to be the couple's. The rule +depends only on the value the simulation reads, so it holds through +deletion, clones and branches. Each family has an adult outside the couple +who reports £3,000 of the award: whenever the stored award is what the +reports give, that adult's report must not bar the claim. +""" + +import pytest + +from policyengine_uk import Simulation + +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]) + + +@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 eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_an_entered_award_equal_to_the_reports_is_read_through_them(award): + # Intended: an award that is exactly what the reports give is read + # through the reports, which say it is the excluded adult's. + simulation = family(award) + simulation.set_input(award, YEAR, [3_000]) + assert eligible(simulation) + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_deleted_input_then_a_formula_result_reads_reports(award): + simulation = family(award) + simulation.set_input(award, YEAR, [0]) + assert eligible(simulation) + simulation.set_input(award, YEAR, [4_000]) + assert not eligible(simulation) + simulation.delete_arrays(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + 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, [4_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, [4_000]) + assert not eligible(clone) + 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, [4_000]) + assert not eligible(simulation) + # The branch still stores the formula result it copied. + 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, [4_000]) + branch = simulation.get_branch("after", clone_system=False) + assert not eligible(branch) + + +@pytest.mark.parametrize("award", AWARDS) +def test_nested_branches_read_the_nearest_stored_award(award): + simulation = family(award) + outer = simulation.get_branch("outer", clone_system=False) + outer.set_input(award, YEAR, [4_000]) + inner = outer.get_branch("inner", clone_system=False) + assert not eligible(inner) + inner.set_input(award, YEAR, [0]) + assert eligible(inner) + assert not eligible(outer) + assert eligible(simulation) 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..6f519f99c0 --- /dev/null +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -0,0 +1,394 @@ +"""A jsa_income that differs from the reported award is the couple's award. + +income_support_eligible reads income-based JSA (SSCBA 1992 s.124(1)(f)) from +the claimant's and partner's reported awards after the jsa_income capital +screen. Where jsa_income holds a different award from the one all the +reported amounts give, that award is used instead and taken to be the +claimant's or partner's. That covers an award entered directly (when the +simulation is built or later, or on a branch) and a reform that changes or +removes jsa_income. An award entered for one year says nothing about another. +YAML cases cover inputs given when the simulation is built +(income_support_work_and_jsa.yaml). +""" + +from policyengine_uk import Simulation + +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}}, + } + ) + + +PARTNER_WITH_JSA = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "jsa_income_reported": {YEAR: 3_000}, +} + + +def eligible(simulation, year=YEAR): + simulation.delete_arrays("income_support_eligible") + 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]) + 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=PARTNER_WITH_JSA) + assert not eligible(simulation) + simulation.set_input("jsa_income", YEAR, [0]) + 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) + + +def test_a_reform_that_removes_income_based_jsa_lifts_the_bar(): + # A reform that neutralises jsa_income pays no income-based JSA, so the + # partner's reported award no longer bars Income Support. + simulation = carer_family(other_adult=PARTNER_WITH_JSA) + assert not eligible(simulation) + simulation.tax_benefit_system.neutralize_variable("jsa_income") + simulation.delete_arrays("jsa_income") + assert simulation.calculate("jsa_income", YEAR)[0] == 0 + assert eligible(simulation) + + +def test_the_raw_reported_total_is_read_through_the_reports(): + """disable_simulated_benefits sets jsa_income to the raw total of every + member's report. Here the partner reports £200 and an adult outside the + couple £3,000, with capital of £10,000. Tariff income of + ceil(4,000 / 250) x £1 x 52 = £832 a year extinguishes the partner's + award, so the claimant and partner have no income-based JSA. The raw + total, £3,200, differs from the screened award, £3,200 - £832 = £2,368, + but is still what the reports give, so the reports decide whose it is.""" + members = ["carer", "partner", "other_adult"] + simulation = Simulation( + situation={ + "people": { + "carer": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + }, + "partner": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "jsa_income_reported": {YEAR: 200}, + }, + "other_adult": { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "jsa_income_reported": {YEAR: 3_000}, + }, + }, + "benunits": { + "family": { + "members": members, + "income_support_assessable_capital": {YEAR: 10_000}, + "jsa_income_assessable_capital": {YEAR: 10_000}, + } + }, + "households": {"home": {"members": members}}, + } + ) + assert simulation.calculate("jsa_income", YEAR)[0] == 2_368 + assert eligible(simulation) + simulation.set_input("jsa_income", YEAR, [3_200]) + assert eligible(simulation) + # 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)) + + +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)) + + +def test_a_stored_zero_never_bars_the_claim(): + # The claimant's own report of £0.004 is within half a penny of a stored + # £0, so the reports "explain" the zero; their sub-penny award must not + # then bar the claim, whether the zero is entered or the award removed. + claimant = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + "jsa_income_reported": {YEAR: 0.004}, + } + + def single(benunit_inputs=None): + return Simulation( + situation={ + "people": {"carer": claimant}, + "benunits": { + "family": { + "members": ["carer"], + "income_support_assessable_capital": {YEAR: 0}, + "jsa_income_assessable_capital": {YEAR: 0}, + **(benunit_inputs or {}), + } + }, + "households": {"home": {"members": ["carer"]}}, + } + ) + + assert eligible(single({"jsa_income": {YEAR: 0}})) + removed = single() + removed.tax_benefit_system.neutralize_variable("jsa_income") + removed.delete_arrays("jsa_income") + assert eligible(removed) + + +def reporting_family(carer_report, partner_report=None, other_adults=(), **benunit): + """A caring award holder who reports income-based JSA, an optional + partner who reports it, and adults outside the couple who report it, + with £6,250 of capital (£52 a year of tariff income) unless given.""" + people = { + "carer": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, + "jsa_income_reported": {YEAR: carer_report}, + } + } + if partner_report is not None: + people["partner"] = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "jsa_income_reported": {YEAR: partner_report}, + } + for i, report in enumerate(other_adults): + people[f"other_{i}"] = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "jsa_income_reported": {YEAR: report}, + } + members = list(people) + capital = benunit.pop("capital", 6_250) + return Simulation( + situation={ + "people": people, + "benunits": { + "family": { + "members": members, + "income_support_assessable_capital": {YEAR: capital}, + "jsa_income_assessable_capital": {YEAR: capital}, + **benunit, + } + }, + "households": {"home": {"members": members}}, + } + ) + + +def test_the_formulas_residual_is_no_award(): + # £10.01 + £41.99 sums a little above £52 in stored precision, so after + # £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) + + +def test_an_entered_amount_within_half_a_penny_of_zero_is_no_award(): + # £0.003 entered beside the carer's own £3,000 report is not what the + # reports give, so it is read as entered; within half a penny of zero it + # is no award. £0.006 is. + def entered(amount): + return reporting_family(3_000, capital=0, jsa_income={YEAR: amount}) + + assert eligible(entered(0.003)) + assert not eligible(entered(0.006)) + + +def test_the_couples_residual_is_no_award_beside_another_members_report(): + # An excluded adult's £3,000 makes jsa_income a real award, but on the + # couple's own reports (£10.01 + £41.99 against £52 of tariff income) the + # award is only a float residual, so it does not bar the claim. A real + # penny of award (£52.01 against £52) still does. + residual = reporting_family(10.01, 41.99, other_adults=[3_000]) + assert residual.calculate("jsa_income", YEAR)[0] > 2_999 + assert eligible(residual) + 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 271d20e827..2ad7e658bb 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -9,27 +9,49 @@ - adding an adult who is neither the claimant, the partner nor a young person in the family never changes income_support_eligible, whatever that adult's - age, ESA, Income Support or caring. The declared exception, outside the - values drawn here, is an esa_income entered directly that the new adult's - report makes equal to what the reports give: it is then read through the - reports (test_income_support_esa_entered_directly.py); + 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 the qualifying age for State Pension Credit, is in a prescribed category the model covers (a carer; a lone parent of a child aged 5 or under, the model's reading of Sch 1B para 1, counting only children in the household, reg 16(4); or a single claimant with a child placed by a local - authority, para 2) 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 the reported amounts do not explain (one that equals neither - the award on everyone's reported amounts nor their plain total); the - couple is not taken to be on State Pension Credit (s.124(1)(g)); and - capital is within the Income Support limit. + authority, para 2), 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 + the reported amounts do not explain (one that equals neither the award on + everyone's reported amounts nor their plain total); the couple is not + 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. (c) and (f) only ever bar a claim. The one other path is (g): + 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 +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. +test_income_support_direct_inputs.py (JSA) and +test_income_support_esa_entered_directly.py (ESA) test 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, ESA and Income Support are the model's reported -or proxy inputs; it reads the qualifying age +legal entitlement: caring, work hours, ESA, JSA and Income Support are the +model's reported or proxy inputs; it reads the qualifying age (has_attained_state_pension_credit_qualifying_age) and the model's proxy for being on Pension Credit (meets_pension_credit_age_conditions and would_claim_pc) from the model; and the means test is out of scope. No @@ -44,7 +66,7 @@ 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 @@ -60,20 +82,43 @@ @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 PRIMED_CLAIMANT = { "income_support_reported": 1_000, "esa_income_reported": 0, "esa_contrib_reported": 0, + "hours_worked": 0, + "jsa_contrib_reported": 0, + "jsa_income_reported": 0, } @@ -192,31 +237,44 @@ def excluded_members(draw): return adult +@st.composite +def direct_award(draw, n): + """An award entered directly for each of n families (half the time), or None. + + £4,000 is neither a total of the reported amounts drawn here nor such a + total less tariff income, so it is always read as entered directly. + """ + if draw(st.booleans()): + return draw( + st.lists(st.sampled_from([0, 4_000, 4_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()) - # £4,000 is neither a total of the reported amounts drawn here nor such a - # total less tariff income, so it is always read as entered directly. - esa_income = ( - draw(st.lists(st.sampled_from([0, 4_000, 4_000]), min_size=n, max_size=n)) - if draw(st.booleans()) - 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( d.get("is_looked_after_by_local_authority") @@ -228,10 +286,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] @@ -250,8 +310,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} @@ -276,11 +339,13 @@ def situation(units, capital_as_savings, esa_income): @INVARIANCE_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(): @@ -298,18 +363,37 @@ def test_excluded_member_never_changes_is_eligibility(drawn, data): assert eligible[i] == eligible[k + i], drawn[i] +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, attained_qualifying_age, on_pension_credit, 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 # A child placed by a local authority is not a member of the household # (reg 16(4)), so not a child for para 1, but brings a single claimant # within para 2. @@ -325,33 +409,47 @@ def reference_eligibility( # A child is under 16 (SSCBA s.137(1)). single_with_placed_child = len(adults) == 1 and any(d["age"] < 16 for d in placed) - 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 or single_with_placed_child) - and not over_qualifying_age + and ( + carer(adult) or lone_parent_with_young_child or single_with_placed_child + ) + and not attained_qualifying_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: # Within half a penny of zero is no award. income_related_esa = esa_income > 0.005 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: + # Within half a penny of zero is no award. + income_based_jsa = jsa_income > 0.005 + 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, attained_qualifying_age)) + any(is_claimant(i) for i in range(len(adults))) and not income_related_esa + and not income_based_jsa # s.124(1)(g), read through the model's proxy for being on Pension # Credit. and not on_pension_credit @@ -359,10 +457,11 @@ def is_claimant(adult, over_qualifying_age): ) -def explained_by_reports(esa_income, reported_total, capital, parameters): - """Whether an esa_income equals what the reported amounts give: the - award after the capital test, or their plain total.""" - ESA = parameters.gov.dwp.ESA.income.capital +def explained_by_reports(esa_income, reported_total, capital, parameters, rules=None): + """Whether an esa_income (or, with the JSA capital rules, a jsa_income) + equals what the reported amounts give: the award after the capital test, + or their plain total. Income-based JSA is active in YEAR.""" + ESA = rules if rules is not None else parameters.gov.dwp.ESA.income.capital tariff = ( math.ceil( max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step @@ -381,9 +480,11 @@ def explained_by_reports(esa_income, reported_total, capital, parameters): @DIFFERENTIAL_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") @@ -396,9 +497,9 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 for i, (adults, dependants, capital, extra) in enumerate(units): - # An esa_income the reported amounts explain is read through them, - # like a calculated one. - entered = None + # An esa_income or jsa_income the reported amounts explain is read + # through them, like a calculated one. + entered_esa = entered_jsa = None if esa_income is not None: reported_total = sum( member.get("esa_income_reported", 0) for member in adults + [extra] @@ -406,12 +507,25 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): if not explained_by_reports( esa_income[i], reported_total, capital, parameters ): - entered = esa_income[i] + entered_esa = esa_income[i] + if jsa_income is not None: + reported_total = sum( + member.get("jsa_income_reported", 0) for member in adults + [extra] + ) + if not explained_by_reports( + jsa_income[i], + reported_total, + capital, + parameters, + parameters.gov.dwp.JSA.income.capital, + ): + entered_jsa = jsa_income[i] expected = reference_eligibility( adults, dependants, capital, - entered, + entered_esa, + entered_jsa, attained_qualifying_age[start : start + len(adults)], on_pension_credit[i], parameters, @@ -424,3 +538,91 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): or attained_qualifying_age[start : start + len(adults)].all() ), 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. 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 = [ + (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]) + + +# 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], False, parameters + ) + assert reference == expected, (adult, esa, jsa) + assert eligible[i] == expected, (adult, esa, jsa) 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. 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 77d554a80f..be1f79c4e4 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): @@ -19,27 +20,34 @@ class income_support_eligible(Variable): "claimant must be under the " "qualifying age for State Pension Credit, fall within a prescribed " "category the model covers (a carer, a lone parent of a young child, " - "or a single claimant with a child placed by a local authority) and " - "not be entitled to Employment and Support Allowance. Neither the " - "claimant nor the partner may be entitled to income-related ESA, and " - "the other member of a couple may not be entitled to State Pension " - "Credit, which the model takes to be so where the couple meets the " - "Pension Credit age conditions and would claim it. An " - "adult in the benefit unit who is neither the claimant nor the " - "partner (such as a non-dependent adult) does not affect " - "eligibility, with a declared exception for a stored esa_income " - "that is not the formula's own result. Whether entered directly or " - "replaced by a reform (including one that scales it), such a value " - "is read through the reported awards when it equals what they give " - "(the award after esa_income_eligible, or their plain total, to " - "within half a penny after rounding to the precision it is stored " - "in), and is otherwise taken to be the claimant's or partner's. So " - "another member's report can change how such a value is read." + "or a single claimant with a child placed by a local authority), 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, and the other member of a couple may not be " + "entitled to State Pension Credit, which the model takes to be so " + "where the couple meets the Pension Credit age conditions and would " + "claim it. An adult in the benefit unit who is neither the " + "claimant nor the partner (such as a non-dependent adult) does not " + "affect eligibility, with a declared exception for a stored " + "esa_income or jsa_income that is not the formula's own result. " + "Whether entered directly or replaced by a reform (including one that " + "scales it), such a value is read through the reported awards when it " + "equals what they give (the award after the benefit's screen, or " + "their plain total, to within half a penny after rounding to the " + "precision it is stored in), and is otherwise taken to be the " + "claimant's or partner's. So another member's report can change how " + "such a value is read. A stored award within half a penny of zero " + "never bars the claim." ) definition_period = YEAR 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", @@ -99,11 +107,40 @@ def formula(benunit, period, parameters): under_qualifying_age = ~person( "has_attained_state_pension_credit_qualifying_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 @@ -142,6 +179,41 @@ def formula(benunit, period, parameters): income_related_esa = (esa_income > half_penny) & ( ~as_reported | (award_on_claimant_or_partner_reports > half_penny) ) + # 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 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( + benunit, period, jsa_reported_total + ) + jsa_award_on_claimant_or_partner_reports = income_related_jsa_award( + benunit, + 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.astype(jsa_income.dtype), + rtol=0, + atol=half_penny, + ) | np.isclose( + jsa_income, + jsa_reported_total.astype(jsa_income.dtype), + rtol=0, + atol=half_penny, + ) + # As for ESA, an amount within half a penny of zero is no award: a + # stored zero never bars the claim, and nor does the float residual + # left by reports that exactly match the tariff income. + income_based_jsa = (jsa_income > half_penny) & ( + ~jsa_as_reported | (jsa_award_on_claimant_or_partner_reports > half_penny) + ) # s.124(1)(g): the other member of a couple is not entitled to State # Pension Credit. Entitlement needs a claim (SSAA 1992 s.1). Reading the # Pension Credit amount here would be circular (Pension Credit income @@ -159,5 +231,6 @@ def formula(benunit, period, parameters): benunit.any(claimant) & ~on_pension_credit & ~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..3c20225649 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/income_support_remunerative_work_hours.py @@ -0,0 +1,38 @@ +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, a usual " + "week's hours in paid jobs (the Enhanced FRS records usual weekly " + "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 " + "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" + 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..58dad5288d 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 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() + 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 @@ -16,6 +28,4 @@ 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)