From aecd34ebf3681387233be3ddca77c1b85e99c572 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:04:24 -0400 Subject: [PATCH 01/33] Bar income-related ESA at pensionable age and on State Pension Credit Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached pensionable age, and Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member of the couple, is entitled to State Pension Credit. esa_income_eligible now applies both. Pension Credit reads the couple's ESA, so the screen takes the couple to be on Pension Credit where it meets the age conditions and would claim it, as income_support_eligible does for SSCBA 1992 s.124(1)(g). An outside member's own claim, and the payee of the couple's award, follow s.1(3)(c). Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 57 ++- .../esa_income/esa_income_pension_age.yaml | 406 ++++++++++++++++++ ...t_income_support_eligibility_properties.py | 26 +- ...t_gate_reads_claimant_or_partner_awards.py | 24 +- .../variables/gov/dwp/_legacy_award_payee.py | 17 +- .../variables/gov/dwp/esa_income.py | 16 +- .../variables/gov/dwp/esa_income_eligible.py | 90 +++- ...it_benefit_cap_exempt_specified_benefit.py | 7 +- .../gov/dwp/is_on_income_based_jsa.py | 2 +- .../gov/dwp/is_on_income_related_esa.py | 28 +- ...uc_benefit_cap_exempt_specified_benefit.py | 7 +- 11 files changed, 629 insertions(+), 51 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 318019bded..e6da1c1c96 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -25,6 +25,23 @@ against pay (reg 98(11)). - ESA partner (para 6(1)(f), reg 42(1)): 24 hours or more, unless a carer (reg 43(2)(c)). +- ESA age (WRA 2007 s.1(3)(c)): no claimant may have reached pensionable age, + their own State Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). A + caller that reads the model's pensionable age passes it as is_SP_age; + otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone + aged 64 or under was born after 5 October 1960 and reaches it at 66 and + some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born + before 6 April 1960 and reached it at 66 or earlier. +- ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant + nor the other member of the couple may be entitled to it. The model reads + this as the couple meeting the Pension Credit age conditions and claiming + it; a caller passes the model's reading (on_pension_credit). Otherwise no + member reports Pension Credit or Housing Benefit, so no mixed-age couple + keeps the SI 2019/37 art 4 saving, the exclusion of mixed-age couples is in + force (SPCA 2002 s.4(1A)) and every couple would claim, and a couple is on + it when every member has reached the qualifying age, which from 2019-20 is + pensionable age for everyone (SPCA 2002 s.1(6)). It bars only the couple's + claim, not that of a member claiming in their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -153,6 +170,26 @@ def esa_claimant_in_remunerative_work(adult, parameters): return not (within_lower_limit or permitted_work) +def reached_pensionable_age(adult): + """WRA 2007 s.1(3)(c): whether the adult has reached pensionable age.""" + if "is_SP_age" in adult: + return bool(adult["is_SP_age"]) + assert not 65 <= adult["age"] < 70, adult + return adult["age"] >= 70 + + +def couple_on_pension_credit(couple): + """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing + Benefit reports in 2025 or 2026: every member has reached the qualifying + age, so the couple meets the age conditions and would claim.""" + assert not any( + adult.get("pension_credit_reported", 0) + or adult.get("housing_benefit_reported", 0) + for adult in couple + ), couple + return len(couple) > 0 and all(reached_pensionable_age(a) for a in couple) + + def legacy_benefits_closed(couple, others, parameters): """Annual closure proxy for the claimant's and partner's own award. @@ -202,12 +239,25 @@ def candidates(couple, others, reported, parameters): ] -def esa_screen(couple, others, capital, parameters): +def esa_screen(couple, others, capital, parameters, on_pension_credit=None): """esa_income_eligible for a claimant and partner (couple) and other - members of the benefit unit outside the family (others).""" + members of the benefit unit outside the family (others). on_pension_credit + is whether the couple is taken to be on State Pension Credit, if the + caller reads it from the model.""" ESA = parameters.gov.dwp.ESA.income members = couple + others + def barred(i): + # WRA 2007 s.1(3)(c), for every claimant. + if reached_pensionable_age(members[i]): + return True + # Sch 1 para 6(1)(c) and (d), for the couple's claim only. + if i >= len(couple): + return False + if on_pension_credit is not None: + return bool(on_pension_credit) + return couple_on_pension_credit(couple) + def other_member_works(i): if i >= len(couple): return False @@ -219,7 +269,8 @@ def other_member_works(i): ) passes = any( - not esa_claimant_in_remunerative_work(members[i], parameters) + not barred(i) + and not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) for i in candidates(couple, others, "esa_income_reported", parameters) ) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml new file mode 100644 index 0000000000..5bfdbc17e7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -0,0 +1,406 @@ +# Income-related ESA and pensionable age or State Pension Credit. +# Welfare Reform Act 2007 s.1(3)(c): a claimant who has reached pensionable +# age is not entitled to ESA. Pensionable age is the person's own State +# Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). +# Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the +# claimant "is not entitled to state pension credit" and "is not a member of a +# couple the other member of which is entitled to ... state pension credit". +# A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 +# (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving +# applies. Northern Ireland's Welfare Reform Act (Northern Ireland) 2007 sets +# the same conditions in the same words. +# Everyone here is a legacy claimant (would_claim_uc false), since a Universal +# Credit claim ends the couple's income-related ESA anyway. No case has +# capital, so no tariff income: an award that passes is the 5_000 reported. +# Pension Credit with no income is the guarantee credit's standard minimum +# guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and +# £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social +# Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so +# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here reached +# pensionable age before 6 April 2016, so no savings credit is due. + +- name: Case 1, a single claimant aged 70 who reports income-related ESA gets Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 7). + is_SP_age: true + # s.1(3)(c) bars the claim: 0, not the 5_000 reported. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: false + # 52 x 238.00. + pension_credit: 12_376 + +- name: Case 2, a couple aged 70 and 68 where the 68-year-old reports income-related ESA get Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 68 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Both born after 5 October 1954 and before 6 April 1960: pensionable + # age 66 (rule 7). + is_SP_age: [true, true] + meets_pension_credit_age_conditions: true + # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it + # too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. + pension_credit: 18_889 + +- name: Case 3, a saved mixed-age couple aged 70 and 60 get Pension Credit and no ESA on the younger member's report. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + # SI 2019/37 art 4(1): the couple was entitled to Pension Credit on + # 14 May 2019 and has been since, so s.4(1A) does not exclude it. + has_mixed_age_couple_pension_credit_saving: true + households: + household: + members: [person1, person2] + output: + # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not + # bar them. + is_SP_age: [true, false] + meets_pension_credit_age_conditions: true + # Para 6(1)(d): the other member of the couple is entitled to State + # Pension Credit, so the 60-year-old gets no income-related ESA. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. + pension_credit: 18_889 + +- name: Case 4, the same mixed-age couple without the saving keep income-related ESA and get no Pension Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: false + households: + household: + members: [person1, person2] + output: + # SPCA 2002 s.4(1A): the 70-year-old is not entitled to Pension Credit + # while the partner is under the qualifying age. + meets_pension_credit_age_conditions: false + pension_credit: 0 + # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 5, a saved mixed-age couple that does not claim Pension Credit keep income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: true + would_claim_pc: false + households: + household: + members: [person1, person2] + output: + # Entitlement to Pension Credit needs a claim (Social Security + # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 6, a single claimant aged 60 keeps income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + is_SP_age: false + meets_pension_credit_age_conditions: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 7, a man aged 63 in 2016 over the Pension Credit qualifying age but under pensionable age gets no income-related ESA while he would claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born 1 March 1953, before 6 December 1953: pensionable age 65, on 1 + # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so s.1(3)(c) does + # not bar him in 2016-17. + is_SP_age: false + # The qualifying age for State Pension Credit is a woman's pensionable + # age for the same date of birth (SPCA 2002 s.1(6)): a woman born from 6 + # February to 5 March 1953 reached it on 6 January 2016 (rule 3, table 1). + has_attained_state_pension_credit_qualifying_age: true + meets_pension_credit_age_conditions: true + # Para 6(1)(c): he is entitled to State Pension Credit. + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + +- name: Case 8, the same man in 2016 keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + is_SP_age: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 9, a woman born on the same day reached pensionable age, so gets no income-related ESA in 2016 even without a Pension Credit claim. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: FEMALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + # Rule 3, table 1: pensionable age on 6 January 2016. + is_SP_age: true + # s.1(3)(c). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + +- name: Case 10, before the mixed-age couple exclusion, a couple aged 70 and 60 that would claim Pension Credit get no ESA on the younger member's report. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # SPCA 2002 s.4(1A) came into force on 15 May 2019 (SI 2019/37 art 3), so + # in 2018-19 the 70-year-old can claim Pension Credit for the couple. + is_mixed_age_couple: true + meets_pension_credit_age_conditions: true + # Para 6(1)(d). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 11, a member outside the couple aged 70 who reports income-related ESA gets none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_claimant_or_partner: true + person2: + age: 70 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # They claim in their own right, and s.1(3)(c) bars their claim too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 12, the couple's Pension Credit does not bar a member outside the couple who claims income-related ESA in their own right. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The 70-year-old is a single claimant of Pension Credit: 52 x 238.00. + meets_pension_credit_age_conditions: true + pension_credit: 12_376 + # The 40-year-old is not a member of the 70-year-old's couple, so para + # 6(1)(d) does not reach them: their own award of 5_000. + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, true] + +- name: Case 13, where both members of a mixed-age couple report income-related ESA, only the one under pensionable age can be its payee. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: false + households: + household: + members: [person1, person2] + output: + # The 60-year-old can claim; the model makes the couple's two reports one + # award of 5_000 + 5_000 = 10_000. + esa_income_eligible: true + esa_income: 10_000 + claimant_or_partner_esa_income: 10_000 + # s.1(3)(c): the 70-year-old cannot be the claimant, so the award is not + # payable to them. + is_on_income_related_esa: [false, true] diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index cb3319414a..f9ba9889ec 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -58,7 +58,9 @@ 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 +would_claim_pc) from the model, and so, for the income-related ESA screen, +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and the same Pension Credit +proxy (Sch 1 para 6(1)(c) and (d)); and the means test is out of scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit (only some added adults do), so in 2025 no family with a claimant or partner under the qualifying age meets the Pension Credit age conditions. The @@ -448,7 +450,9 @@ def is_claimant(i): sum(a["esa_income_reported"] for a in adults), capital, parameters.gov.dwp.ESA.income.capital, - ) and esa_screen(adults, [], capital, parameters) + ) and esa_screen( + adults, [], capital, parameters, on_pension_credit=on_pension_credit + ) if jsa_income is not None: # Within half a penny of zero is no award. income_based_jsa = jsa_income > 0.005 @@ -511,9 +515,19 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) + pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 for i, (adults, dependants, capital, extra) in enumerate(units): + # The ESA screen reads each member's pensionable age from the model. + adults = [ + dict(adult, is_SP_age=pensionable_age[start + j]) + for j, adult in enumerate(adults) + ] + if extra is not None: + extra = dict( + extra, is_SP_age=pensionable_age[start + len(adults) + len(dependants)] + ) # An esa_income or jsa_income the reported amounts explain is read # through them, like a calculated one. entered_esa = entered_jsa = None @@ -529,7 +543,13 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): reported_total, capital, parameters, - screen=esa_screen(adults, others, capital, parameters), + screen=esa_screen( + adults, + others, + capital, + parameters, + on_pension_credit=on_pension_credit[i], + ), ): entered_esa = esa_income[i] if jsa_income is not None: diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index ff2edaeab4..ca34dbf5c3 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -62,6 +62,9 @@ def reference_report_awards(benunit, period, award): "pension_credit_reported", "esa_income_reported", "jsa_income_reported", + # The ESA reference reads the model's pensionable age (WRA 2007 + # s.1(3)(c)). + "is_SP_age", ] arrays = {name: person(name, period) for name in names} roles = person("is_claimant_or_partner", period) @@ -76,6 +79,23 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen + # Sch 1 para 6(1)(c) and (d), through the model's proxy for the couple + # being on State Pension Credit. It never bars an outside member's claim. + on_pension_credit = benunit("meets_pension_credit_age_conditions", period) & ( + benunit("would_claim_pc", period) + ) + + def passes(members, unit, couple): + if not is_esa: + return screen(members, [], capitals[unit], parameters) + return screen( + members, + [], + capitals[unit], + parameters, + on_pension_credit=couple and on_pension_credit[unit], + ) + full, scoped = [], [] for unit in range(benunit.count): ids = np.flatnonzero(benunit.members_entity_id == unit) @@ -92,13 +112,13 @@ def reference_report_awards(benunit, period, award): active = is_esa or parameters.gov.dwp.JSA.income.active couple_amount = ( max(0, sum(member[f"{award}_reported"] for member in couple) - tariff) - if active and screen(couple, [], capital, parameters) + if active and passes(couple, unit, couple=True) else 0 ) outside_amount = sum( max(0, member[f"{award}_reported"] - tariff) for member in others - if active and screen([member], [], capital, parameters) + if active and passes([member], unit, couple=False) ) full.append(couple_amount + outside_amount) scoped.append(couple_amount) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index cee5d2c28a..35f5fdc5d6 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -15,7 +15,8 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No can_claim, if given, limits the payee to the members of the couple who can be the claimant (for Pension Credit, those who have attained the - qualifying age). Where neither can, both are considered, so a positive + qualifying age; for income-related ESA, those who have not reached + pensionable age). Where neither can, both are considered, so a positive award entered for the couple is still paid to one of them. couple, if given (a person-level mask), replaces the claimant and @@ -39,15 +40,17 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No return where(person.benunit.any(reports), reports, claimant) -def own_report_is_paid(person, period, award, works, capital_limit): +def own_report_is_paid(person, period, award, barred, capital_limit): """Whether income-related ESA or income-based JSA (award: "esa_income" or "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. - Their claim is screened on them alone. They must not be engaged in - remunerative work as its claimant (works), and the award on their report - alone must be positive: the report exceeds the tariff income from the - benefit unit's capital, within the capital limit (capital_limit). Their + Their claim is screened on them alone. No condition of their own may bar + it (barred): they must not be engaged in remunerative work as its + claimant, and, for income-related ESA, must not have reached pensionable + age (Welfare Reform Act 2007 s.1(3)(c)). The award on their report alone + must be positive: the report exceeds the tariff income from the benefit + unit's capital, within the capital limit (capital_limit). Their own capital is not observed, so the benefit unit's stands in for it. The benefit unit's screen (esa_income_eligible or jsa_income_eligible) cannot stand in for theirs, because when the claimant or partner reports @@ -63,7 +66,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): """ benunit = person.benunit own_award = ( - ~works + ~barred & (benunit(f"{award}_assessable_capital", period) <= capital_limit) & ( person(f"{award}_reported", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index f8bd1ff885..fe14c79598 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -3,8 +3,8 @@ def income_related_esa_award(benunit, period, reported_award): """Income-related ESA paid on a reported award: the award less tariff - income from capital, or nothing if the benefit unit fails the screen in - esa_income_eligible.""" + income from capital, or nothing if the benefit unit fails the age, Pension + Credit, work and capital screen in esa_income_eligible.""" tariff_income = benunit("esa_income_tariff_income", period) eligible = benunit("esa_income_eligible", period) return where(eligible, max_(0, reported_award - tariff_income), 0) @@ -14,7 +14,9 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) """Screen open person reports, retaining each original claim's ownership. The claimant and partner share one claim. Other reporters have separate - claims, each using the existing own-work and shared-capital/tariff proxy. + claims, each using the existing own-work and shared-capital/tariff proxy, + and barred once the reporter reaches pensionable age (Welfare Reform Act + 2007 s.1(3)(c)). Selecting members changes only reports, never the original couple or the work/capital screen. This helper must not read payable-person status, which itself depends on the aggregate award. @@ -28,9 +30,10 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) benunit, period, benunit.sum(reported * couple) ) works = person("esa_income_claimant_remunerative_work", period) + pensionable_age = person("is_SP_age", period) capital = benunit("esa_income_assessable_capital", period) limit = rules.income.capital.limit - own_claim = ~couple & ~works & benunit.project(capital <= limit) + own_claim = ~couple & ~works & ~pensionable_age & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) return couple_award + benunit.sum(own_awards) @@ -41,8 +44,9 @@ class esa_income(Variable): entity = BenUnit label = "ESA (income-based)" documentation = ( - "Reported income-related ESA screened through bounded capital and " - "remunerative work tests (esa_income_eligible). Reports whose award " + "Reported income-related ESA screened through bounded pensionable " + "age, Pension Credit, capital and remunerative work tests " + "(esa_income_eligible). Reports whose award " "has ended are left out (esa_income_report_closed): the owner's " "legacy benefits have closed, or the owner is the claimant or " "partner of a family that claims Universal Credit. " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 22f9c6b4b6..87650488be 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -5,34 +5,58 @@ class esa_income_eligible(Variable): value_type = bool entity = BenUnit label = ( - "Whether a reported income-related ESA award passes the capital and work tests" + "Whether a reported income-related ESA award passes the age, Pension " + "Credit, capital and work tests" ) documentation = ( - "Bounded work/capital screen for reported income-related ESA, not a full " - "entitlement model. When an original claimant or partner reports an open " - "award, only they are candidate claimants; otherwise other open reporters " - "are candidates. The claimant must not engage in remunerative work (Welfare " - "Reform Act 2007 Sch 1 para 6(1)(e); " - "esa_income_claimant_remunerative_work). The other member of the couple " - "must not engage in remunerative work (para 6(1)(f)): paid work of 24 hours " - "a week or more (ESA Regs 2008 reg 42(1)), subject to the existing carer " - "exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " - "reads as is_carer_for_benefits. Assessable capital must remain within the " - "existing limit (para 6(1)(b); reg 110). The aggregate esa_income uses this " - "screen for its original couple's part; each outside reporter's claim is " - "paid separately on their own existing work/capital screen and tariff. The " - "benefit unit's capital is an explicit proxy for unobserved outside-claim " - "capital. Reports whose own award is closed are excluded before choosing " - "candidates; ESA also excludes the couple's reports on a UC claim. This " - "variable's candidate rules are unchanged by the separate aggregation." + "Bounded age, Pension Credit, work and capital screen for reported " + "income-related ESA, not a full entitlement model. When an original claimant " + "or partner reports an open award, only they are candidate claimants; " + "otherwise other open reporters are candidates. The claimant must not have " + "reached pensionable age (Welfare Reform Act 2007 s.1(3)(c)): their own State " + "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " + "Neither the claimant nor the other member of the couple may be entitled to " + "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " + "couple's income-related ESA, so the model cannot read the Pension Credit " + "award here. As for Income Support (income_support_eligible), it takes the " + "couple to be on Pension Credit where the couple meets the Pension Credit age " + "conditions (meets_pension_credit_age_conditions) and would claim it " + "(would_claim_pc). That leaves out Pension Credit's means test, so the screen " + "also fails for a couple that test would refuse. The couple's Pension Credit " + "bars only the couple's claim, not that of a member who claims in their own " + "right. The claimant must not engage in remunerative work (para 6(1)(e); " + "esa_income_claimant_remunerative_work). The other member of the couple must " + "not engage in remunerative work (para 6(1)(f)): paid work of 24 hours a week " + "or more (ESA Regs 2008 reg 42(1)), subject to the existing carer exception " + "(reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model reads as " + "is_carer_for_benefits. Assessable capital must remain within the existing " + "limit (para 6(1)(b); reg 110). The model applies neither para 6(1)(g) " + "(receiving education) nor the other member's income-based JSA under para " + "6(1)(d). The Welfare Reform Act (Northern Ireland) 2007 sets the same " + "conditions in the same words, and Northern Ireland's pensionable age " + "(Pensions (Northern Ireland) Order 1995 Sch 2 para 1) follows the same " + "timetable, so one rule covers the UK. The aggregate esa_income uses this " + "screen for its original couple's part; each outside reporter's claim is paid " + "separately on their own existing work/capital screen, pensionable age and " + "tariff. The benefit unit's capital is an explicit proxy for unobserved " + "outside-claim capital. Reports whose own award is closed are excluded before " + "choosing candidates; ESA also excludes the couple's reports on a UC claim. " + "This variable's candidate rules are unchanged by the separate aggregation." ) definition_period = YEAR reference = ( + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/uksi/2008/794/regulation/41", "https://www.legislation.gov.uk/uksi/2008/794/regulation/42", "https://www.legislation.gov.uk/uksi/2008/794/regulation/43", "https://www.legislation.gov.uk/uksi/2008/794/regulation/110", + "https://www.legislation.gov.uk/nia/2007/2/section/1", + "https://www.legislation.gov.uk/nia/2007/2/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/nisi/1995/3213/schedule/2", ) def formula(benunit, period, parameters): @@ -49,6 +73,28 @@ def formula(benunit, period, parameters): candidate = reports_award & ( claimant_or_partner | ~benunit.project(couple_reports) ) + # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age", + # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man + # born before 6 December 1953 reaches it at 65. + pensionable_age = person("is_SP_age", period) + # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State + # Pension Credit, nor is the other member of the couple. Reading the + # Pension Credit amount here would be circular: its higher earnings + # disregard reads this award (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), + # and so does the working tax credit income test (TCA 2002 s.7(2)), + # and working tax credit counts as Pension Credit income. As + # income_support_eligible does for SSCBA 1992 s.124(1)(g), the couple + # is taken to be on Pension Credit where it meets the Pension Credit + # age conditions and would claim it. That leaves out the means test: a + # couple Pension Credit's means test would refuse is still taken to be + # on it. A family that left the Pension Credit route when DWP moved it + # to Universal Credit claims Universal Credit, which already ends the + # couple's award (esa_income_report_closed). The couple's Pension + # Credit does not bar a member who claims in their own right. + on_pension_credit = benunit( + "meets_pension_credit_age_conditions", period + ) & benunit("would_claim_pc", period) + pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) # Para 6(1)(f) and reg 42(1): the other member of the couple works 24 @@ -62,5 +108,11 @@ def formula(benunit, period, parameters): other_member_works = claimant_or_partner & ( (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) - claimant = candidate & ~claimant_works & ~other_member_works + claimant = ( + candidate + & ~pensionable_age + & ~pension_credit_bars + & ~claimant_works + & ~other_member_works + ) return benunit.any(claimant) & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 2481b490be..0cbd1f4173 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -120,7 +120,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=claimant_or_partner + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=claimant_or_partner, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 318ee8f10c..52b463ab20 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -55,7 +55,7 @@ def formula(person, period, parameters): person, period, "jsa_income", - works=hours >= JSA.remunerative_work.claimant_hours, + barred=hours >= JSA.remunerative_work.claimant_hours, capital_limit=JSA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index dde494cf3b..2a52e7d81c 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -16,12 +16,16 @@ class is_on_income_related_esa(Variable): "couple's award is paid to the claimant, not the partner: of the " "claimant and partner, only the payee is on it, meaning the one who " "reports the award, or the claimant where neither does, and only while " - "their award (claimant_or_partner_esa_income) is positive. Any other " + "their award (claimant_or_partner_esa_income) is positive. A member " + "who has reached pensionable age cannot be the claimant (Welfare Reform " + "Act 2007 s.1(3)(c)), so where the other member has not, only the " + "other member can be the payee. Any other " "member of the benefit unit, such as a non-dependent adult, claims in " - "their own right and is on it only if their own claim passes: they are " - "not engaged in remunerative work as its claimant " - "(esa_income_claimant_remunerative_work; Welfare Reform Act 2007 Sch 1 " - "para 6(1)(e)), and the award on their own report alone is positive, " + "their own right and is on it only if their own claim passes: they " + "have not reached pensionable age (is_SP_age; s.1(3)(c)), they are not " + "engaged in remunerative work as its claimant " + "(esa_income_claimant_remunerative_work; Sch 1 para 6(1)(e)), and the " + "award on their own report alone is positive, " "the report exceeding the tariff income from the benefit unit's " "capital, within the capital limit. On the formula path the claimant's " "or partner's claim and work never change this. The legacy " @@ -39,19 +43,27 @@ class is_on_income_related_esa(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", ) def formula(person, period, parameters): + # WRA 2007 s.1(3)(c): a member who has reached pensionable age cannot + # be the claimant, so the award is not payable to them. + pensionable_age = person("is_SP_age", period) couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 - ) & (is_payee_of_couple_award(person, period, "esa_income_reported")) - # Their own claim, on their own report and their own work alone. + ) & is_payee_of_couple_award( + person, period, "esa_income_reported", can_claim=~pensionable_age + ) + # Their own claim, on their own report, work and age alone (WRA 2007 + # s.1(3)(c): a claimant who has reached pensionable age is not entitled). own_award = own_report_is_paid( person, period, "esa_income", - works=person("esa_income_claimant_remunerative_work", period), + barred=person("esa_income_claimant_remunerative_work", period) + | pensionable_age, capital_limit=parameters(period).gov.dwp.ESA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index d752aad48f..b88e32758a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -152,7 +152,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=couple + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=couple, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa From dcf29693bed60ba212ec960a77575a5a860ea27b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:28:25 -0400 Subject: [PATCH 02/33] Test that pensionable age and Pension Credit bar income-related ESA Property tests: no family gets both awards, nobody over pensionable age is on income-related ESA, the screen matches the reference reading, and claiming Pension Credit never raises income-related ESA. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 policyengine_uk/tests/test_esa_income_pension_age_properties.py diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py new file mode 100644 index 0000000000..1a9a977fac --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -0,0 +1,217 @@ +"""Income-related ESA is never paid where pensionable age or Pension Credit bars it. + +Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached +pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and +(d) bar the income-related allowance where the claimant, or the other member +of the couple, is entitled to State Pension Credit. The model takes the couple +to be entitled where it meets the Pension Credit age conditions and would +claim it (meets_pension_credit_age_conditions and would_claim_pc), as Income +Support does for SSCBA 1992 s.124(1)(g). For every family drawn here: + +- no family gets both: where the model pays the couple Pension Credit, or + takes it to be on Pension Credit, the couple's income-related ESA + (claimant_or_partner_esa_income) is nil; +- nobody who has reached pensionable age is on income-related ESA + (is_on_income_related_esa), and a family whose every report comes from such + a member gets none (esa_income); +- esa_income_eligible equals the family-by-family reading of the law in + legacy_award_work_reference (esa_screen), given the model's pensionable age + (is_SP_age) and Pension Credit proxy, so the bars combine with the work and + capital tests as the law sets them; +- claiming Pension Credit never raises income-related ESA: the same families + with would_claim_pc false get at least as much, and exactly as much where + the couple does not meet the Pension Credit age conditions. + +The years are 2016, when a man born before 6 December 1953 could be over the +qualifying age for State Pension Credit but under his pensionable age of 65 +(Pensions Act 1995 Sch 4 para 1 rule 1), 2018, before the exclusion of +mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only +a mixed-age couple that keeps the SI 2019/37 art 4 saving +(has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. +Adults work some hours but have no pay, so only the other member's 24 hours +(para 6(1)(f)) and capital above £16,000 (para 6(1)(b)) bite among the older +tests; test_legacy_award_work_properties.py covers earnings. A member outside +the couple claims in their own right: the couple's Pension Credit does not bar +them, and pensionable age does. Everyone is a legacy claimant (would_claim_uc +false). Roles are given explicitly (is_claimant_or_partner). Each example +builds many families in one simulation, in separate benefit units. +""" + +import numpy as np +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st + +from legacy_award_work_reference import esa_screen +from policyengine_uk import Simulation + +YEARS = [2016, 2018, 2026] +# Either side of the State Pension Credit qualifying age and of pensionable +# age in each year drawn, including men aged 62 to 64 in 2016. +AGES = [30, 45, 58, 60, 61, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] +# 0 and 24 hours a week. +HOURS = [0, 0, 0, 1_248] + + +@st.composite +def adults(draw, outside=False): + adult = { + "age": draw(st.sampled_from(AGES)), + "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), + "gender": draw(st.sampled_from(["MALE", "FEMALE"])), + "esa_income_reported": draw(st.sampled_from([0, 3_000, 3_000])), + "hours_worked": draw(st.sampled_from(HOURS)), + } + if outside: + adult["current_education"] = "NOT_IN_EDUCATION" + return adult + + +@st.composite +def families(draw): + """A claimant, an optional partner, an optional member outside the couple, + capital and the couple's saving and Pension Credit take-up.""" + couple = [draw(adults())] + if draw(st.booleans()): + couple.append(draw(adults())) + others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] + benunit = { + "esa_income_assessable_capital": draw(st.sampled_from([0, 0, 0, 20_000])), + "has_mixed_age_couple_pension_credit_saving": draw(st.booleans()), + "would_claim_pc": draw(st.sampled_from([True, True, False])), + } + return couple, others, benunit + + +def situation(units, year): + people, benunits, households = {}, {}, {} + for i, (couple, others, benunit) in enumerate(units): + names = [] + for j, (inputs, claimant_or_partner) in enumerate( + [(m, True) for m in couple] + [(m, False) for m in others] + ): + name = f"p{i}_{j}" + people[name] = {k: {year: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + } + households[f"h{i}"] = {"members": names} + return {"people": people, "benunits": benunits, "households": households} + + +def label(units, year): + event(f"year {year}") + for couple, others, _ in units: + if len(couple) == 2: + event("couple") + if others: + event("member outside the couple") + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +FAMILIES = st.lists(families(), min_size=1, max_size=8) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_no_family_gets_pension_credit_and_income_related_esa(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + pension_credit = sim.calculate("pension_credit", year) + on_pension_credit = sim.calculate( + "meets_pension_credit_age_conditions", year + ) & sim.calculate("would_claim_pc", year) + couple_esa = sim.calculate("claimant_or_partner_esa_income", year) + if (on_pension_credit & (couple_esa == 0)).any(): + event("a couple on Pension Credit") + if (~on_pension_credit & (couple_esa > 0)).any(): + event("a couple paid income-related ESA") + # Pension Credit is paid only on the Pension Credit route, to a family + # that would claim it (is_pension_credit_eligible, pension_credit). + assert not ((pension_credit > 0) & ~on_pension_credit).any() + # Para 6(1)(c) and (d). + assert (couple_esa[on_pension_credit] == 0).all(), units + assert not ((pension_credit > 0) & (couple_esa > 0)).any(), units + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + pensionable_age = sim.calculate("is_SP_age", year) + on_esa = sim.calculate("is_on_income_related_esa", year) + esa = sim.calculate("esa_income", year) + if (pensionable_age & ~on_esa).any(): + event("someone over pensionable age") + # s.1(3)(c): the award is not payable to them. + assert not (pensionable_age & on_esa).any(), units + start = 0 + for i, (couple, others, _) in enumerate(units): + members = couple + others + flags = pensionable_age[start : start + len(members)] + reporters = [m["esa_income_reported"] > 0 for m in members] + if any(reporters) and all(f for f, r in zip(flags, reporters) if r): + event("every report from someone over pensionable age") + assert esa[i] == 0, units[i] + start += len(members) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_screen_matches_a_family_by_family_reading(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + eligible = sim.calculate("esa_income_eligible", year) + pensionable_age = sim.calculate("is_SP_age", year) + on_pension_credit = sim.calculate( + "meets_pension_credit_age_conditions", year + ) & sim.calculate("would_claim_pc", year) + parameters = sim.tax_benefit_system.parameters(year) + if eligible.any(): + event("some screen passes") + start = 0 + for i, (couple, others, benunit) in enumerate(units): + members = [ + dict(member, is_SP_age=pensionable_age[start + j]) + for j, member in enumerate(couple + others) + ] + expected = esa_screen( + members[: len(couple)], + members[len(couple) :], + benunit["esa_income_assessable_capital"], + parameters, + on_pension_credit=on_pension_credit[i], + ) + assert eligible[i] == expected, (year, units[i]) + start += len(members) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_claiming_pension_credit_never_raises_income_related_esa(units, year): + label(units, year) + n = len(units) + + def claiming(claims): + return [ + (couple, others, dict(benunit, would_claim_pc=claims)) + for couple, others, benunit in units + ] + + sim = Simulation(situation=situation(claiming(True) + claiming(False), year)) + esa = sim.calculate("esa_income", year) + route = sim.calculate("meets_pension_credit_age_conditions", year) + assert (route[:n] == route[n:]).all() + if (esa[n:] > esa[:n]).any(): + event("claiming Pension Credit ended an award") + assert (esa[:n] <= esa[n:] + 0.01).all(), units + assert np.allclose(esa[:n][~route[:n]], esa[n:][~route[:n]], atol=0.01), units From 1ca890c2cfe0bb5e7873b3095e85600f0d32d6db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:28:00 -0400 Subject: [PATCH 03/33] Pin outside reporters beside a Pension Credit couple in the property tests Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 1a9a977fac..c7833628a3 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -38,7 +38,7 @@ """ import numpy as np -from hypothesis import HealthCheck, event, given, settings +from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st from legacy_award_work_reference import esa_screen @@ -111,6 +111,32 @@ def label(units, year): event("member outside the couple") +NO_REPORT = {"months_since_last_birthday": 0, "esa_income_reported": 0} +# A member outside the couple who reports income-related ESA beside a +# single claimant aged 70 on Pension Credit, and one aged 70 themselves. +OUTSIDE_REPORTERS = [ + ( + [{"age": 70, "gender": "FEMALE", "hours_worked": 0, **NO_REPORT}], + [ + { + "age": age, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 3_000, + "hours_worked": 0, + "current_education": "NOT_IN_EDUCATION", + } + ], + { + "esa_income_assessable_capital": 0, + "has_mixed_age_couple_pension_credit_saving": False, + "would_claim_pc": True, + }, + ) + for age in [40, 70] +] + + SETTINGS = settings( max_examples=12, deadline=None, @@ -144,6 +170,7 @@ def test_no_family_gets_pension_credit_and_income_related_esa(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS, 2026) def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): label(units, year) sim = Simulation(situation=situation(units, year)) @@ -167,6 +194,8 @@ def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS[:1], 2026) +@example(OUTSIDE_REPORTERS[1:], 2026) def test_screen_matches_a_family_by_family_reading(units, year): label(units, year) sim = Simulation(situation=situation(units, year)) @@ -197,6 +226,7 @@ def test_screen_matches_a_family_by_family_reading(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS, 2026) def test_claiming_pension_credit_never_raises_income_related_esa(units, year): label(units, year) n = len(units) From d538f82ca6498aa1ea44d4006cfa94aa3b9ad4fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:38:43 -0400 Subject: [PATCH 04/33] Add changelog fragment for #2565 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2565.fixed.md diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md new file mode 100644 index 0000000000..153420156d --- /dev/null +++ b/changelog.d/2565.fixed.md @@ -0,0 +1 @@ +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple it takes to be on State Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). From 7e85a895b75af5bc100e050dcbb68b0240494870 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 13:33:48 -0400 Subject: [PATCH 05/33] Bar income-related ESA on Pension Credit worked out without it Sch 1 para 6(1)(c) and (d) bar the couple's income-related ESA on entitlement to State Pension Credit, which age and the claim alone do not establish. esa_income_eligible now reads pension_credit in a branch with no income-related ESA in the family (pension_credit_without_income_related_esa) and bars the couple's claim where that pays. The branch opens only when some couple has an open report, meets the Pension Credit age conditions and would claim. The YAML cases now cover each pairing of Pension Credit with and without the award, Sol's 2016 and 2018 witnesses, a saved couple built from facts (an older member born 1 March 1953) and the asymmetric cap payee. Co-Authored-By: Claude Opus 5.5 --- .../esa_income/esa_income_pension_age.yaml | 334 ++++++++++++++++-- .../variables/gov/dwp/esa_income_eligible.py | 141 +++++--- 2 files changed, 413 insertions(+), 62 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 5bfdbc17e7..da2a2e5b8b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -5,19 +5,22 @@ # Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the # claimant "is not entitled to state pension credit" and "is not a member of a # couple the other member of which is entitled to ... state pension credit". +# The model reads the couple's Pension Credit worked out with no +# income-related ESA in the family, B(0), and bars the couple's claim where it +# pays. B(e) is the Pension Credit the family would get with the award e. # A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 # (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving -# applies. Northern Ireland's Welfare Reform Act (Northern Ireland) 2007 sets -# the same conditions in the same words. +# applies. # Everyone here is a legacy claimant (would_claim_uc false), since a Universal # Credit claim ends the couple's income-related ESA anyway. No case has -# capital, so no tariff income: an award that passes is the 5_000 reported. +# capital, so no tariff income: an award that passes is the amount reported. # Pension Credit with no income is the guarantee credit's standard minimum # guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and # £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social # Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so -# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here reached -# pensionable age before 6 April 2016, so no savings credit is due. +# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here in 2026 reached +# pensionable age before 6 April 2016, so no savings credit is due (SPCA 2002 +# s.3(1)). - name: Case 1, a single claimant aged 70 who reports income-related ESA gets Pension Credit and no ESA. period: 2026 @@ -79,14 +82,18 @@ # 52 x 363.25. pension_credit: 18_889 -- name: Case 3, a saved mixed-age couple aged 70 and 60 get Pension Credit and no ESA on the younger member's report. +- name: Case 3, a saved mixed-age couple aged 73 and 60 get Pension Credit and no ESA on the younger member's report. period: 2026 absolute_error_margin: 0.01 input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true + # The couple has had Pension Credit since before 15 May 2019. + pension_credit_reported: 9_000 person2: age: 60 is_claimant_or_partner: true @@ -95,24 +102,29 @@ benunit: members: [person1, person2] would_claim_uc: false - # SI 2019/37 art 4(1): the couple was entitled to Pension Credit on - # 14 May 2019 and has been since, so s.4(1A) does not exclude it. - has_mixed_age_couple_pension_credit_saving: true households: household: members: [person1, person2] output: + # SI 2019/37 art 4(1): born 1 March 1953, the older member reached the + # qualifying age on 6 January 2016 (Pensions Act 1995 Sch 4 para 1 rule 3, + # table 1, via SPCA 2002 s.1(6)), so the couple could be entitled to + # Pension Credit on 14 May 2019, and it reports Pension Credit now, so + # s.4(1A) does not exclude it. + has_mixed_age_couple_pension_credit_saving: true # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not # bar them. is_SP_age: [true, false] meets_pension_credit_age_conditions: true - # Para 6(1)(d): the other member of the couple is entitled to State - # Pension Credit, so the 60-year-old gets no income-related ESA. + # Para 6(1)(d): B(0) = 52 x 363.25 = 18_889 is payable, so the other + # member of the couple is entitled to State Pension Credit and the + # 60-year-old gets no income-related ESA. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 is_on_income_related_esa: [false, false] - # 52 x 363.25. + # 52 x 363.25. He reached pensionable age at 65 on 1 March 2018 (rule + # 1), after 6 April 2016, so no savings credit is due (SPCA 2002 s.3(1)). pension_credit: 18_889 - name: Case 4, the same mixed-age couple without the saving keep income-related ESA and get no Pension Credit. @@ -121,7 +133,9 @@ input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true person2: age: 60 @@ -131,13 +145,14 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: false households: household: members: [person1, person2] output: - # SPCA 2002 s.4(1A): the 70-year-old is not entitled to Pension Credit - # while the partner is under the qualifying age. + # Neither member reports Pension Credit or pension-age Housing Benefit, + # so the couple keeps no saving, and SPCA 2002 s.4(1A) excludes the + # 73-year-old while the partner is under the qualifying age. + has_mixed_age_couple_pension_credit_saving: false meets_pension_credit_age_conditions: false pension_credit: 0 # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. @@ -152,8 +167,11 @@ input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true + pension_credit_reported: 9_000 person2: age: 60 is_claimant_or_partner: true @@ -162,12 +180,13 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: true would_claim_pc: false households: household: members: [person1, person2] output: + has_mixed_age_couple_pension_credit_saving: true + meets_pension_credit_age_conditions: true # Entitlement to Pension Credit needs a claim (Social Security # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. pension_credit: 0 @@ -229,10 +248,13 @@ # February to 5 March 1953 reached it on 6 January 2016 (rule 3, table 1). has_attained_state_pension_credit_qualifying_age: true meets_pension_credit_age_conditions: true - # Para 6(1)(c): he is entitled to State Pension Credit. + # Para 6(1)(c): with no income, B(0) is the minimum guarantee, 52 x + # 155.60 = 8_091.20 in 2016-17 (SPC Regs 2002 reg 6(1)(b)), so he is + # entitled to State Pension Credit. esa_income_eligible: false esa_income: 0 is_on_income_related_esa: false + pension_credit: 8_091.20 - name: Case 8, the same man in 2016 keeps income-related ESA if he would not claim Pension Credit. period: 2016 @@ -391,11 +413,14 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: false households: household: members: [person1, person2] output: + # Born in 1956, the 70-year-old reached the qualifying age after 14 May + # 2019, so the couple keeps no saving (SI 2019/37 art 4(1)). + has_mixed_age_couple_pension_credit_saving: false + meets_pension_credit_age_conditions: false # The 60-year-old can claim; the model makes the couple's two reports one # award of 5_000 + 5_000 = 10_000. esa_income_eligible: true @@ -404,3 +429,270 @@ # s.1(3)(c): the 70-year-old cannot be the claimant, so the award is not # payable to them. is_on_income_related_esa: [false, true] + +- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple's award brings neither benefit cap exception. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + esa_includes_support_component: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + esa_income: 10_000 + # s.1(3)(c): only the 60-year-old can be the claimant, so the award is + # paid to them, without the support component. + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the exception needs the award's support component, which + # belongs to the 70-year-old, who is not its claimant. + is_uc_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: false + +# Cases 15 to 19 take one case for each pairing of B(0) and B(e). Each is a +# man aged 63 in 2016, born 1 March 1953: over the qualifying age for State +# Pension Credit (from 6 January 2016) and under his pensionable age (65, +# on 1 March 2018), with support-group income-related ESA. In 2016-17 the +# minimum guarantee is £155.60 a week, 52 x 155.60 = 8_091.20 a year (SPC +# Regs 2002 reg 6(1)(b)), and Pension Credit disregards £5 a week of a single +# claimant's earnings (Sch VI para 5), or £20 a week while the claimant is on +# employment and support allowance (para 4(1)(a)(vii)). Below 65 no savings +# credit is due (SPCA 2002 s.3(1)). His income is below the personal +# allowance (£11,000) and his earnings below the National Insurance primary +# threshold (£155 a week), so neither takes anything off. + +- name: Case 15, where Pension Credit pays with or without the award, the man gets Pension Credit worked out without it and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 6_011.20 + esa_includes_support_component: true + # £50 a week for 10 hours. + employment_income: 2_600 + hours_worked: 520 + # £40 a week. + private_pension_income: 2_080 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): income 50 + 40 - 5 = 85 a week, so 155.60 - 85 = 70.60 a week. + # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) pays, so para 6(1)(c) + # bars the award and Pension Credit is B(0): 52 x 70.60. + esa_income_eligible: false + esa_income: 0 + pension_credit: 3_671.20 + pension_credit_earnings_disregard: 260 + +- name: Case 16, where Pension Credit pays nothing with or without the award, the man keeps income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + # £115.50 a week for 15 hours. + employment_income: 6_006 + hours_worked: 780 + # £61 a week. + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): 115.50 + 61 - 5 = 171.50 a week, over 155.60. B(e): 115.50 + 61 + # - 20 = 156.50, still over it. Pension Credit refuses him either way, so + # para 6(1)(c) does not bar the award: 52 x 94.60 = 4_919.20. + pension_credit: 0 + esa_income_eligible: true + esa_income: 4_919.20 + is_on_income_related_esa: true + +- name: Case 17, the same man keeps the same income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + pension_credit: 0 + esa_income: 4_919.20 + +- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets that Pension Credit too. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + # £50 a week. + private_pension_income: 2_600 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): + # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state + # is consistent: Pension Credit would end the award, and without the + # award Pension Credit pays nothing. B(0) pays nothing, so the award + # stands, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 = 525.20. + esa_income_eligible: true + esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit: 525.20 + +- name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + is_claimant_or_partner: true + # 20 hours a week. + employment_income: 10_000 + hours_worked: 1_040 + working_tax_credit_reported: 1 + person2: + age: 58 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Before 15 May 2019 the 66-year-old can claim Pension Credit for the + # couple. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard + # is £10 a week (Sch VI para 5), £20 with the award. The 66-year-old has + # reached pensionable age, so pays no primary Class 1 contributions + # (SSCBA 1992 s.6(3)), and 10_000 is under the £11,000 personal + # allowance, so his earnings count in full. + # Working tax credit: the basic and couple elements, 1_960 + 2_010 = + # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts + # as Pension Credit income (SPCA 2002 s.15(1)(b)). + # B(e): the award lifts the tax credit income test (TCA 2002 s.7(2)), so + # working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over + # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs + # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the + # £212.97 couple threshold: nil. + # B(0): Pension Credit on 3_970 would be nil too, so it gives no + # passport. The income test applies: 41% of income over £6,420 comes off, + # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - + # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) pays, so para + # 6(1)(d) bars the award, and the couple gets B(0). + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + working_tax_credit: 2_502.20 + pension_credit: 370.40 + +- name: Case 20, in 2018 a couple aged 66 and 58 whose income Pension Credit refuses keep support-group ESA and both benefit cap exceptions. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + date_of_birth: 19520701 + gender: MALE + is_claimant_or_partner: true + # £200 a week. + private_pension_income: 10_400 + person2: + age: 58 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 2_537.60 + esa_includes_support_component: true + # £124 a week for 15 hours. + employment_income: 6_448 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2018-19 the couple minimum guarantee is £248.80 a week. B(0): 200 + + # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so + # nil either way. (Each member's income is under the £11,850 personal + # allowance, and £124 a week is under the £162 National Insurance primary + # threshold, so neither takes anything off.) Para 6(1)(d) does not bar the + # award. + pension_credit: 0 + esa_income: 2_537.60 + claimant_or_partner_esa_income: 2_537.60 + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the award with its support component. + is_uc_benefit_cap_exempt_specified_benefit: true + is_housing_benefit_benefit_cap_exempt_specified_benefit: true + # So no cap applies: uc_benefit_cap is unlimited. + is_uc_benefit_cap_exempt: true diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 87650488be..8c805bb502 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,5 +1,58 @@ from policyengine_uk.model_api import * +# The branch in which the family has no income-related ESA. +NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" + + +def pension_credit_without_income_related_esa(benunit, period): + """Pension Credit worked out with no income-related ESA in the family. + + Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's + income-related ESA while the claimant, or the other member of the couple, + is entitled to State Pension Credit, and Pension Credit reads that award. + Its higher earnings disregard counts the couple's award + (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para + 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and + the tax credit income test reads the award (TCA 2002 s.7(2)), as does + Income Support's gate, which that test reads. Pension Credit's route test + (left_pension_route_at_legacy_closure) reads Universal Credit + entitlement, whose work capability test reads esa_income + (has_own_esa_award). Reading Pension Credit in the simulation itself + would be circular. + + This calculates Pension Credit in a branch in which esa_income, the + couple's award and esa_income_eligible are nil, so the screen never runs + there. The branch reads pension_credit, so the claim (would_claim_pc) + and an entered award count. Where it pays, the bar holds: the couple's + award is nil, and the simulation's own Pension Credit then equals the + branch's. Where it pays nothing, the award stands. If the award then + makes Pension Credit pay, through the higher earnings disregard, no + consistent state exists: Pension Credit would end the award, which would + end Pension Credit. The award then stands beside the Pension Credit + worked out on it. + + In a year with tax credits, this branch reaches the passport branch + (pension_credit_with_passported_tax_credits), and the passport branch + reaches this one. A branch reads the inputs its parents set, so a branch + of either keeps both. + """ + simulation = benunit.simulation + # get_branch returns an existing branch of that name, or this simulation + # if it has that name, so take a name in use by neither. + name = NO_INCOME_RELATED_ESA_BRANCH + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + branch.set_input(variable, period, benunit.empty_array()) + branch.set_input("esa_income_eligible", period, benunit.filled_array(False)) + # Through the population, so a microsimulation returns an + # unweighted array. + return branch.populations[benunit.entity.key]("pension_credit", period) + finally: + simulation.branches.pop(name, None) + class esa_income_eligible(Variable): value_type = bool @@ -17,31 +70,37 @@ class esa_income_eligible(Variable): "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " "Neither the claimant nor the other member of the couple may be entitled to " "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's income-related ESA, so the model cannot read the Pension Credit " - "award here. As for Income Support (income_support_eligible), it takes the " - "couple to be on Pension Credit where the couple meets the Pension Credit age " - "conditions (meets_pension_credit_age_conditions) and would claim it " - "(would_claim_pc). That leaves out Pension Credit's means test, so the screen " - "also fails for a couple that test would refuse. The couple's Pension Credit " - "bars only the couple's claim, not that of a member who claims in their own " - "right. The claimant must not engage in remunerative work (para 6(1)(e); " - "esa_income_claimant_remunerative_work). The other member of the couple must " - "not engage in remunerative work (para 6(1)(f)): paid work of 24 hours a week " - "or more (ESA Regs 2008 reg 42(1)), subject to the existing carer exception " - "(reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model reads as " - "is_carer_for_benefits. Assessable capital must remain within the existing " - "limit (para 6(1)(b); reg 110). The model applies neither para 6(1)(g) " - "(receiving education) nor the other member's income-based JSA under para " - "6(1)(d). The Welfare Reform Act (Northern Ireland) 2007 sets the same " - "conditions in the same words, and Northern Ireland's pensionable age " - "(Pensions (Northern Ireland) Order 1995 Sch 2 para 1) follows the same " - "timetable, so one rule covers the UK. The aggregate esa_income uses this " - "screen for its original couple's part; each outside reporter's claim is paid " - "separately on their own existing work/capital screen, pensionable age and " - "tariff. The benefit unit's capital is an explicit proxy for unobserved " - "outside-claim capital. Reports whose own award is closed are excluded before " - "choosing candidates; ESA also excludes the couple's reports on a UC claim. " - "This variable's candidate rules are unchanged by the separate aggregation." + "couple's award, so the screen reads Pension Credit worked out with no " + "income-related ESA in the family " + "(pension_credit_without_income_related_esa), which counts the claim " + "(would_claim_pc) and an entered award, and bars the couple's claim where it " + "pays more than nil. It works that out only for a population in which some " + "couple has an open report, meets the Pension Credit age conditions " + "(meets_pension_credit_age_conditions) and would claim, and bars only such a " + "couple. Where Pension Credit pays nothing without the award but pays with " + "it, through the higher earnings disregard the award brings (SPC Regs 2002 " + "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " + "both. The couple's Pension Credit bars only the couple's claim, not that of " + "a member who claims in their own right. The claimant must not engage in " + "remunerative work (para 6(1)(e); esa_income_claimant_remunerative_work). The " + "other member of the couple must not engage in remunerative work (para " + "6(1)(f)): paid work of 24 hours a week or more (ESA Regs 2008 reg 42(1)), " + "subject to the existing carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B " + "para 4), which the model reads as is_carer_for_benefits. Assessable capital " + "must remain within the existing limit (para 6(1)(b); reg 110). The model " + "applies neither para 6(1)(g) (receiving education) nor the other member's " + "income-based JSA under para 6(1)(d). The Welfare Reform Act (Northern " + "Ireland) 2007 sets the same conditions in s.1(3)(c) and Sch 1 para 6(1)(c) " + "and (d), but its s.1(6) takes pensionable age from the Pensions (Northern " + "Ireland) Order 1995 Sch 2 para 1, not the Pensions Act 1995 Sch 4 para 1. " + "The two give the same pensionable age for every date of birth, so one rule " + "covers the UK. The aggregate esa_income uses this screen for its original " + "couple's part; each outside reporter's claim is paid separately on their own " + "existing work/capital screen, pensionable age and tariff. The benefit unit's " + "capital is an explicit proxy for unobserved outside-claim capital. Reports " + "whose own award is closed are excluded before choosing candidates; ESA also " + "excludes the couple's reports on a UC claim. This variable's candidate rules " + "are unchanged by the separate aggregation." ) definition_period = YEAR reference = ( @@ -78,22 +137,22 @@ def formula(benunit, period, parameters): # born before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Reading the - # Pension Credit amount here would be circular: its higher earnings - # disregard reads this award (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), - # and so does the working tax credit income test (TCA 2002 s.7(2)), - # and working tax credit counts as Pension Credit income. As - # income_support_eligible does for SSCBA 1992 s.124(1)(g), the couple - # is taken to be on Pension Credit where it meets the Pension Credit - # age conditions and would claim it. That leaves out the means test: a - # couple Pension Credit's means test would refuse is still taken to be - # on it. A family that left the Pension Credit route when DWP moved it - # to Universal Credit claims Universal Credit, which already ends the - # couple's award (esa_income_report_closed). The couple's Pension - # Credit does not bar a member who claims in their own right. - on_pension_credit = benunit( - "meets_pension_credit_age_conditions", period - ) & benunit("would_claim_pc", period) + # Pension Credit, nor is the other member of the couple. Pension + # Credit reads the couple's award, so this reads Pension Credit worked + # out without it, for a couple with an open report that meets the + # Pension Credit age conditions and would claim, and only where some + # couple does. The couple's Pension Credit does not bar a member who + # claims in their own right. + could_be_on_pension_credit = ( + couple_reports + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + ) + on_pension_credit = could_be_on_pension_credit + if could_be_on_pension_credit.any(): + on_pension_credit = could_be_on_pension_credit & ( + pension_credit_without_income_related_esa(benunit, period) > 0 + ) pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) From e7f5b0a58182e47acd817d1b19cde9f46ed7fa3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:13:44 -0400 Subject: [PATCH 06/33] Test the Pension Credit bar against a simulation without the award The property test now builds, for each example, a second simulation of the same people with no income-related ESA reported, whose Pension Credit is the bar's B(0) with no branch. Where that pays nothing, take-up leaves the couple's ESA alone; where it pays, the couple's ESA is nil and Pension Credit equals it. The generator now draws earnings, pensions, savings and, in tax-credit years, working tax credit. Two tests count the branches: none opens without a couple that could be on Pension Credit, none outlives the calculation, and the passport branch inside it reads the award as nil. The four property tests built a fresh tax-benefit system per example (about a minute each under load); one combined test on the cloned system builds two simulations per example and five examples. The shared reference now accepts tax credit reports in years with tax credits, and the Income Support tests pass the ESA screen entitlement (the couple would claim and Pension Credit pays) in place of the proxy. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 36 +- .../test_esa_income_pension_age_properties.py | 530 +++++++++++++----- ...t_income_support_eligibility_properties.py | 29 +- ...t_gate_reads_claimant_or_partner_awards.py | 12 +- 4 files changed, 433 insertions(+), 174 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index e6da1c1c96..febea436dc 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -33,15 +33,17 @@ some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born before 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant - nor the other member of the couple may be entitled to it. The model reads - this as the couple meeting the Pension Credit age conditions and claiming - it; a caller passes the model's reading (on_pension_credit). Otherwise no - member reports Pension Credit or Housing Benefit, so no mixed-age couple - keeps the SI 2019/37 art 4 saving, the exclusion of mixed-age couples is in - force (SPCA 2002 s.4(1A)) and every couple would claim, and a couple is on - it when every member has reached the qualifying age, which from 2019-20 is - pensionable age for everyone (SPCA 2002 s.1(6)). It bars only the couple's - claim, not that of a member claiming in their own right. + nor the other member of the couple may be entitled to it. A caller that + knows passes whether the couple is entitled (on_pension_credit): it would + claim, and Pension Credit worked out with no income-related ESA in the + family pays. Otherwise no member reports Pension Credit or Housing + Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the + exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a + couple every member of which has reached the qualifying age can then be + entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 + s.1(6)), so s.1(3)(c) bars each of them whether or not Pension Credit + pays. It bars only the couple's claim, not that of a member claiming in + their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -180,8 +182,9 @@ def reached_pensionable_age(adult): def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing - Benefit reports in 2025 or 2026: every member has reached the qualifying - age, so the couple meets the age conditions and would claim.""" + Benefit reports in 2025 or 2026: a couple every member of which has + reached the qualifying age, each of whom has also reached pensionable + age, so s.1(3)(c) bars them whether or not Pension Credit pays.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) @@ -195,12 +198,13 @@ def legacy_benefits_closed(couple, others, parameters): Reports by other members neither close nor shield it (SI 2014/1230 regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a - notice/deadline model. No tax-credit reports are generated here. Ages - avoid the changing Pension Credit qualifying-age boundary. + notice/deadline model. Tax-credit reports are generated only in years + with tax credits, in which they close nothing. Ages avoid the changing + Pension Credit qualifying-age boundary. """ dwp = parameters.gov.dwp members = couple + others - assert not any( + assert dwp.tax_credits.active or not any( member.get("child_tax_credit_reported", 0) or member.get("working_tax_credit_reported", 0) for member in members @@ -242,8 +246,8 @@ def candidates(couple, others, reported, parameters): def esa_screen(couple, others, capital, parameters, on_pension_credit=None): """esa_income_eligible for a claimant and partner (couple) and other members of the benefit unit outside the family (others). on_pension_credit - is whether the couple is taken to be on State Pension Credit, if the - caller reads it from the model.""" + is whether the couple is entitled to State Pension Credit, if the caller + knows it.""" ESA = parameters.gov.dwp.ESA.income members = couple + others diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index c7833628a3..5c06afc49f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -1,26 +1,33 @@ -"""Income-related ESA is never paid where pensionable age or Pension Credit bars it. +"""Income-related ESA gives way to State Pension Credit only where Pension Credit pays. Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member -of the couple, is entitled to State Pension Credit. The model takes the couple -to be entitled where it meets the Pension Credit age conditions and would -claim it (meets_pension_credit_age_conditions and would_claim_pc), as Income -Support does for SSCBA 1992 s.124(1)(g). For every family drawn here: - -- no family gets both: where the model pays the couple Pension Credit, or - takes it to be on Pension Credit, the couple's income-related ESA - (claimant_or_partner_esa_income) is nil; -- nobody who has reached pensionable age is on income-related ESA - (is_on_income_related_esa), and a family whose every report comes from such - a member gets none (esa_income); +of the couple, is entitled to State Pension Credit. Pension Credit reads the +couple's award, so the model reads the couple's Pension Credit worked out with +no income-related ESA in the family, B(0), and bars the couple's claim where it +pays. + +Each example builds a second, separate simulation of the same people with no +income-related ESA reported. Its pension_credit is B(0), worked out with no +branch. Against it, for every family drawn here: + +- where B(0) pays nothing, whether the couple would claim Pension Credit + changes neither the couple's income-related ESA + (claimant_or_partner_esa_income) nor the family's (esa_income); +- where B(0) pays, the couple would claim it and has an open report, the + couple's income-related ESA is nil and the family's Pension Credit is B(0); +- claiming Pension Credit never raises income-related ESA; - esa_income_eligible equals the family-by-family reading of the law in legacy_award_work_reference (esa_screen), given the model's pensionable age - (is_SP_age) and Pension Credit proxy, so the bars combine with the work and - capital tests as the law sets them; -- claiming Pension Credit never raises income-related ESA: the same families - with would_claim_pc false get at least as much, and exactly as much where - the couple does not meet the Pension Credit age conditions. + (is_SP_age) and B(0); +- nobody who has reached pensionable age is on income-related ESA + (is_on_income_related_esa), and a family whose every report comes from such + a member gets none. + +A family whose Pension Credit pays only with the award, through the higher +earnings disregard the award brings, gets both: no state is consistent there. +The test counts such families and asserts nothing more of them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -28,220 +35,447 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults work some hours but have no pay, so only the other member's 24 hours -(para 6(1)(f)) and capital above £16,000 (para 6(1)(b)) bite among the older -tests; test_legacy_award_work_properties.py covers earnings. A member outside -the couple claims in their own right: the couple's Pension Credit does not bar -them, and pensionable age does. Everyone is a legacy claimant (would_claim_uc -false). Roles are given explicitly (is_claimant_or_partner). Each example -builds many families in one simulation, in separate benefit units. +Adults have earnings, pensions and household savings, so Pension Credit's +means test pays some families and refuses others, and in 2016 and 2018 some +report working tax credit, whose income test the award lifts (TCA 2002 +s.7(2)). A member outside the couple claims in their own right: the couple's +Pension Credit does not bar them, and pensionable age does. + +Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving +would otherwise read the ESA report, so the generator enters them, and no one +reports Housing Benefit, whose saving route also reads it. The test asserts +that the two simulations agree on the Pension Credit age conditions. Roles are +given explicitly (is_claimant_or_partner). Each example builds many families +in one simulation, in separate benefit units, with the take-up copies in the +same simulation. Simulations take most of the time here, so each example +builds two and the file builds few. + +Separate tests count the branches the model opens: a population with no couple +that could be on Pension Credit opens none, a branch never outlives the +calculation, and the tax credit passport branch inside it reads the couple's +award as nil. """ import numpy as np +import pytest from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st +from policyengine_core.simulations import Simulation as CoreSimulation -from legacy_award_work_reference import esa_screen from policyengine_uk import Simulation +from policyengine_uk.tests.legacy_award_work_reference import esa_screen +from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( + NO_INCOME_RELATED_ESA_BRANCH, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEARS = [2016, 2018, 2026] # Either side of the State Pension Credit qualifying age and of pensionable # age in each year drawn, including men aged 62 to 64 in 2016. -AGES = [30, 45, 58, 60, 61, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] -# 0 and 24 hours a week. -HOURS = [0, 0, 0, 1_248] +AGES = [30, 45, 58, 60, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] +# 0, 10, 15 and 24 hours a week. +HOURS = [0, 0, 520, 780, 1_248] +# £50, £100 and about £192 a week. +EARNINGS = [0, 0, 2_600, 5_200, 10_000] +# £40, £61 and £200 a week. +PENSIONS = [0, 0, 2_080, 3_172, 10_400] +SAVINGS = [0, 0, 8_000, 14_000, 40_000] @st.composite -def adults(draw, outside=False): +def adults(draw, outside=False, tax_credits=False): adult = { "age": draw(st.sampled_from(AGES)), "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), "gender": draw(st.sampled_from(["MALE", "FEMALE"])), - "esa_income_reported": draw(st.sampled_from([0, 3_000, 3_000])), + "esa_income_reported": draw(st.sampled_from([0, 2_500, 5_000])), + "esa_includes_support_component": draw(st.booleans()), "hours_worked": draw(st.sampled_from(HOURS)), + "employment_income": draw(st.sampled_from(EARNINGS)), + "private_pension_income": draw(st.sampled_from(PENSIONS)), } if outside: adult["current_education"] = "NOT_IN_EDUCATION" + elif tax_credits: + adult["working_tax_credit_reported"] = draw(st.sampled_from([0, 0, 0, 1])) return adult @st.composite -def families(draw): +def families(draw, tax_credits): """A claimant, an optional partner, an optional member outside the couple, - capital and the couple's saving and Pension Credit take-up.""" - couple = [draw(adults())] + the couple's saving and the household's savings. Members of the couple + report working tax credit only in a year with tax credits.""" + couple = [draw(adults(tax_credits=tax_credits))] if draw(st.booleans()): - couple.append(draw(adults())) + couple.append(draw(adults(tax_credits=tax_credits))) others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] - benunit = { - "esa_income_assessable_capital": draw(st.sampled_from([0, 0, 0, 20_000])), - "has_mixed_age_couple_pension_credit_saving": draw(st.booleans()), - "would_claim_pc": draw(st.sampled_from([True, True, False])), - } - return couple, others, benunit + saving = draw(st.booleans()) + return couple, others, saving, draw(st.sampled_from(SAVINGS)) + + +@st.composite +def examples(draw): + year = draw(st.sampled_from(YEARS)) + # Tax credits ended on 5 April 2025. + tax_credits = year < 2025 + units = draw(st.lists(families(tax_credits), min_size=4, max_size=12)) + return units, year -def situation(units, year): +def situation(units, year, would_claim_pc, reports=True): + """Each family once for each take-up in would_claim_pc. Without reports, + nobody reports income-related ESA.""" people, benunits, households = {}, {}, {} - for i, (couple, others, benunit) in enumerate(units): + copies = [(unit, claims) for claims in would_claim_pc for unit in units] + for i, ((couple, others, saving, savings), claims) in enumerate(copies): names = [] - for j, (inputs, claimant_or_partner) in enumerate( - [(m, True) for m in couple] + [(m, False) for m in others] - ): + members = [(m, True) for m in couple] + [(m, False) for m in others] + for j, (inputs, claimant_or_partner) in enumerate(members): name = f"p{i}_{j}" - people[name] = {k: {year: v} for k, v in inputs.items()} - people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} + values = dict(inputs, is_claimant_or_partner=claimant_or_partner) + if not reports: + values.pop("esa_income_reported") + people[name] = {k: {year: v} for k, v in values.items()} names.append(name) benunits[f"b{i}"] = { "members": names, "would_claim_uc": {year: False}, - **{k: {year: v} for k, v in benunit.items()}, + "claims_all_entitled_benefits": {year: False}, + "has_mixed_age_couple_pension_credit_saving": {year: saving}, + "would_claim_pc": {year: claims}, } - households[f"h{i}"] = {"members": names} + households[f"h{i}"] = {"members": names, "savings": {year: savings}} return {"people": people, "benunits": benunits, "households": households} -def label(units, year): - event(f"year {year}") - for couple, others, _ in units: - if len(couple) == 2: - event("couple") - if others: - event("member outside the couple") +def couple_reports(sim, year): + """Whether the couple has an open report of income-related ESA.""" + open_report = (sim.calculate("esa_income_reported", year) > 0) & ~sim.calculate( + "esa_income_report_closed", year + ) + couple = sim.calculate("is_claimant_or_partner", year) + return sim.populations["benunit"].sum(open_report & couple) > 0 -NO_REPORT = {"months_since_last_birthday": 0, "esa_income_reported": 0} -# A member outside the couple who reports income-related ESA beside a -# single claimant aged 70 on Pension Credit, and one aged 70 themselves. -OUTSIDE_REPORTERS = [ +# Each pairing of B(0) and B(e) in 2016, for a man aged 63 born 1 March 1953 +# (esa_income_pension_age.yaml cases 15, 16 and 18), the couple of case 19 +# whose working tax credit decides it, and a member outside the couple +# reporting beside a pensioner on Pension Credit. +MAN = { + "age": 63, + "months_since_last_birthday": 6, + "gender": "MALE", + "esa_includes_support_component": True, + "working_tax_credit_reported": 0, +} +ROWS = [ + ( + [ + dict( + MAN, + esa_income_reported=reported, + employment_income=earnings, + hours_worked=hours, + private_pension_income=pension, + ) + ], + [], + False, + 0, + ) + for reported, earnings, hours, pension in [ + (6_011.20, 2_600, 520, 2_080), + (4_919.20, 6_006, 780, 3_172), + (5_491.20, 6_006, 780, 2_600), + ] +] + [ + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 1_040, + "employment_income": 10_000, + "private_pension_income": 0, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 3_000, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), ( - [{"age": 70, "gender": "FEMALE", "hours_worked": 0, **NO_REPORT}], [ { - "age": age, + "age": 70, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + } + ], + [ + { + "age": 40, "months_since_last_birthday": 0, "gender": "MALE", "esa_income_reported": 3_000, + "esa_includes_support_component": False, "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, "current_education": "NOT_IN_EDUCATION", } ], - { - "esa_income_assessable_capital": 0, - "has_mixed_age_couple_pension_credit_saving": False, - "would_claim_pc": True, - }, - ) - for age in [40, 70] + False, + 0, + ), ] -SETTINGS = settings( - max_examples=12, +@settings( + max_examples=4, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -FAMILIES = st.lists(families(), min_size=1, max_size=8) - - -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -def test_no_family_gets_pension_credit_and_income_related_esa(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) +@given(examples()) +@example((ROWS, 2016)) +def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): + units, year = drawn + n = len(units) + event(f"year {year}") + sim = Simulation(situation=situation(units, year, [True, False])) + oracle = Simulation(situation=situation(units, year, [True], reports=False)) + route = sim.calculate("meets_pension_credit_age_conditions", year) + # The oracle's Pension Credit reads the same age conditions. + assert ( + route[:n] == oracle.calculate("meets_pension_credit_age_conditions", year) + ).all() + assert (route[:n] == route[n:]).all() + without_award = oracle.calculate("pension_credit", year) pension_credit = sim.calculate("pension_credit", year) - on_pension_credit = sim.calculate( - "meets_pension_credit_age_conditions", year - ) & sim.calculate("would_claim_pc", year) couple_esa = sim.calculate("claimant_or_partner_esa_income", year) - if (on_pension_credit & (couple_esa == 0)).any(): - event("a couple on Pension Credit") - if (~on_pension_credit & (couple_esa > 0)).any(): - event("a couple paid income-related ESA") - # Pension Credit is paid only on the Pension Credit route, to a family - # that would claim it (is_pension_credit_eligible, pension_credit). - assert not ((pension_credit > 0) & ~on_pension_credit).any() - # Para 6(1)(c) and (d). - assert (couple_esa[on_pension_credit] == 0).all(), units - assert not ((pension_credit > 0) & (couple_esa > 0)).any(), units - - -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS, 2026) -def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) - pensionable_age = sim.calculate("is_SP_age", year) - on_esa = sim.calculate("is_on_income_related_esa", year) esa = sim.calculate("esa_income", year) - if (pensionable_age & ~on_esa).any(): - event("someone over pensionable age") - # s.1(3)(c): the award is not payable to them. - assert not (pensionable_age & on_esa).any(), units - start = 0 - for i, (couple, others, _) in enumerate(units): - members = couple + others - flags = pensionable_age[start : start + len(members)] - reporters = [m["esa_income_reported"] > 0 for m in members] - if any(reporters) and all(f for f, r in zip(flags, reporters) if r): - event("every report from someone over pensionable age") - assert esa[i] == 0, units[i] - start += len(members) + reports = couple_reports(sim, year) + claims, declines = slice(0, n), slice(n, 2 * n) + + pays = without_award > 0 + barred = pays & reports[claims] + refused = ~pays + for label, mask in [ + ("Pension Credit pays without the award", barred), + ("Pension Credit refuses a couple with a report", refused & reports[claims]), + ( + "both awards paid", + (couple_esa[claims] > 0) & (pension_credit[claims] > 0), + ), + ]: + if mask.any(): + event(label) + # Sch 1 para 6(1)(c) and (d): where Pension Credit pays without the award, + # the couple's award is nil and the family gets that Pension Credit. + assert (couple_esa[claims][barred] == 0).all(), units + assert np.allclose( + pension_credit[claims][barred], without_award[barred], atol=0.01 + ), units + # Where it pays nothing, take-up changes no award. + assert np.allclose( + couple_esa[claims][refused], couple_esa[declines][refused], atol=0.01 + ), units + assert np.allclose(esa[claims][refused], esa[declines][refused], atol=0.01), units + # Claiming Pension Credit never raises income-related ESA. + assert (esa[claims] <= esa[declines] + 0.01).all(), units -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS[:1], 2026) -@example(OUTSIDE_REPORTERS[1:], 2026) -def test_screen_matches_a_family_by_family_reading(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) + # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) pensionable_age = sim.calculate("is_SP_age", year) - on_pension_credit = sim.calculate( - "meets_pension_credit_age_conditions", year - ) & sim.calculate("would_claim_pc", year) + on_esa = sim.calculate("is_on_income_related_esa", year) + capital = sim.calculate("esa_income_assessable_capital", year) parameters = sim.tax_benefit_system.parameters(year) - if eligible.any(): - event("some screen passes") + # s.1(3)(c): the award is not payable to anyone over pensionable age. + assert not (pensionable_age & on_esa).any(), units start = 0 - for i, (couple, others, benunit) in enumerate(units): + for i, (unit, claimed) in enumerate( + [(unit, True) for unit in units] + [(unit, False) for unit in units] + ): + couple, others, _, _ = unit members = [ dict(member, is_SP_age=pensionable_age[start + j]) for j, member in enumerate(couple + others) ] + on_pension_credit = claimed and route[i % n] and pays[i % n] expected = esa_screen( members[: len(couple)], members[len(couple) :], - benunit["esa_income_assessable_capital"], + capital[i], parameters, - on_pension_credit=on_pension_credit[i], + on_pension_credit=on_pension_credit, ) - assert eligible[i] == expected, (year, units[i]) + assert eligible[i] == expected, (year, claimed, unit) + reporters = [m["esa_income_reported"] > 0 for m in members] + if any(reporters) and all( + m["is_SP_age"] for m, r in zip(members, reporters) if r + ): + assert esa[i] == 0, (year, unit) start += len(members) -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS, 2026) -def test_claiming_pension_credit_never_raises_income_related_esa(units, year): - label(units, year) - n = len(units) +class BranchLog: + """Records each branch Core creates, with the simulation it branches.""" + + def __init__(self, monkeypatch): + self.created = [] + get_branch = CoreSimulation.get_branch + + def logged(simulation, name="branch", clone_system=False): + new = name not in simulation.branches and name != simulation.branch_name + branch = get_branch(simulation, name, clone_system) + if new: + self.created.append((simulation, branch)) + return branch - def claiming(claims): + monkeypatch.setattr(CoreSimulation, "get_branch", logged) + + def bar_branches(self): return [ - (couple, others, dict(benunit, would_claim_pc=claims)) - for couple, others, benunit in units + (parent, branch) + for parent, branch in self.created + if branch.branch_name.startswith(NO_INCOME_RELATED_ESA_BRANCH) ] - sim = Simulation(situation=situation(claiming(True) + claiming(False), year)) - esa = sim.calculate("esa_income", year) - route = sim.calculate("meets_pension_credit_age_conditions", year) - assert (route[:n] == route[n:]).all() - if (esa[n:] > esa[:n]).any(): - event("claiming Pension Credit ended an award") - assert (esa[:n] <= esa[n:] + 0.01).all(), units - assert np.allclose(esa[:n][~route[:n]], esa[n:][~route[:n]], atol=0.01), units + +def household(members, year, **benunit): + names = [f"p{j}" for j in range(len(members))] + return { + "people": { + name: {k: {year: v} for k, v in inputs.items()} + for name, inputs in zip(names, members) + }, + "benunits": { + "b": { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + } + }, + "households": {"h": {"members": names}}, + } + + +def combine(*situations): + combined = {"people": {}, "benunits": {}, "households": {}} + for i, part in enumerate(situations): + for entity, units in part.items(): + for name, unit in units.items(): + unit = dict(unit) + if "members" in unit: + unit["members"] = [f"{m}_{i}" for m in unit["members"]] + combined[entity][f"{name}_{i}"] = unit + return combined + + +def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( + monkeypatch, +): + """In 2026 no couple here has an open report and meets the Pension Credit + age conditions: a working-age claimant reporting the award, a couple aged + 70 and 60 without the saving (case 4's kind), a pensioner without a report, + and a member outside the couple reporting beside a pensioner.""" + log = BranchLog(monkeypatch) + year = 2026 + adult = {"is_claimant_or_partner": True} + sim = Simulation( + situation=combine( + household([dict(adult, age=45, esa_income_reported=5_000)], year), + household( + [dict(adult, age=70), dict(adult, age=60, esa_income_reported=5_000)], + year, + ), + household([dict(adult, age=75)], year), + household( + [ + dict(adult, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 5_000, + }, + ], + year, + ), + ) + ) + assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000] + sim.calculate("household_net_income", year) + assert log.bar_branches() == [] + + +def test_the_branch_closes_and_its_passport_branch_reads_no_award(monkeypatch): + """In 2016 the couple of esa_income_pension_age.yaml case 19 meets the + Pension Credit age conditions with an open report, so the model opens the + branch, and its working tax credit opens the passport branch inside it + (pension_credit_with_passported_tax_credits). That branch reads the + award as nil, set on its parent, and no branch outlives the calculation.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=household( + [ + { + "age": 66, + "is_claimant_or_partner": True, + "employment_income": 10_000, + "hours_worked": 1_040, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "is_claimant_or_partner": True, + "esa_income_reported": 3_000, + }, + ], + year, + ) + ) + assert sim.calculate("esa_income", year).tolist() == [0] + assert sim.calculate("pension_credit", year) == pytest.approx([370.40], abs=0.01) + bar_branches = log.bar_branches() + assert bar_branches + passports = [ + branch + for parent, branch in log.created + if any(parent is bar for _, bar in bar_branches) + and branch.branch_name.startswith("tax_credits_pension_credit_passport") + ] + assert passports + for passport in passports: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + assert passport.calculate(variable, year).tolist() == [0], variable + for parent, branch in bar_branches: + assert all(other is not branch for other in parent.branches.values()) + assert not [ + name for name in sim.branches if name.startswith(NO_INCOME_RELATED_ESA_BRANCH) + ] diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index f9ba9889ec..c494b0446e 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -58,9 +58,12 @@ 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 so, for the income-related ESA screen, -pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and the same Pension Credit -proxy (Sch 1 para 6(1)(c) and (d)); and the means test is out of scope. No +would_claim_pc) from the model, and, for the income-related ESA screen, +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether Pension Credit +pays the couple (Sch 1 para 6(1)(c) and (d)): no one drawn here has earnings, +and in 2025 no one has tax credits, so the model's pension_credit is the same +with or without the couple's income-related ESA; and the means test is out of +scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit (only some added adults do), so in 2025 no family with a claimant or partner under the qualifying age meets the Pension Credit age conditions. The @@ -398,8 +401,14 @@ def reference_eligibility( attained_qualifying_age, on_pension_credit, parameters, + entitled_to_pension_credit=False, ): - """The model's Income Support gate, read family by family.""" + """The model's Income Support gate, read family by family. + + on_pension_credit is the model's proxy for s.124(1)(g); + entitled_to_pension_credit is whether Pension Credit pays the couple, + which the income-related ESA screen reads (WRA 2007 Sch 1 para 6(1)(c) + and (d)).""" IS = parameters.gov.dwp.income_support WORK = IS.eligibility.remunerative_work JSA = parameters.gov.dwp.JSA.income @@ -451,7 +460,11 @@ def is_claimant(i): capital, parameters.gov.dwp.ESA.income.capital, ) and esa_screen( - adults, [], capital, parameters, on_pension_credit=on_pension_credit + adults, + [], + capital, + parameters, + on_pension_credit=entitled_to_pension_credit, ) if jsa_income is not None: # Within half a penny of zero is no award. @@ -515,6 +528,9 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) + # Entitlement for the ESA screen: Pension Credit pays. Without earnings + # or tax credits, it pays the same with the couple's award as without. + entitled = on_pension_credit & (sim.calculate("pension_credit", YEAR) > 0) pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 @@ -548,7 +564,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): others, capital, parameters, - on_pension_credit=on_pension_credit[i], + on_pension_credit=entitled[i], ), ): entered_esa = esa_income[i] @@ -574,6 +590,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): attained_qualifying_age[start : start + len(adults)], on_pension_credit[i], parameters, + entitled_to_pension_credit=entitled[i], ) assert eligible[i] == expected, units[i] # The docstring's claim: no family drawn here with a claimant or diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index ca34dbf5c3..86734bb1fb 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -79,10 +79,14 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen - # Sch 1 para 6(1)(c) and (d), through the model's proxy for the couple - # being on State Pension Credit. It never bars an outside member's claim. - on_pension_credit = benunit("meets_pension_credit_age_conditions", period) & ( - benunit("would_claim_pc", period) + # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension Credit + # and it pays. No one here has earnings, and in 2025 no one has tax + # credits, so Pension Credit pays the same with the couple's award as + # without. It never bars an outside member's claim. + on_pension_credit = ( + benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + & (benunit("pension_credit", period) > 0) ) def passes(members, unit, couple): From 1971645a3acd51ba3ea11d3d49e813ddc0a385d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:17:42 -0400 Subject: [PATCH 07/33] Cite pensionable-age rules by their numbers and restate the changelog Pensions Act 1995 Sch 4 para 1 gives 66 to a person born from 6 October 1954 to 5 April 1960 under rule 6, 66 and some months under rule 7 and 67 under rule 8. The changelog now states the bar on Pension Credit worked out without the award. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- policyengine_uk/tests/legacy_award_work_reference.py | 2 +- .../benefit/family/esa_income/esa_income_pension_age.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index 153420156d..3ddcaa3308 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple it takes to be on State Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit, worked out without that award, pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index febea436dc..b92009199a 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -30,7 +30,7 @@ caller that reads the model's pensionable age passes it as is_SP_age; otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone aged 64 or under was born after 5 October 1960 and reaches it at 66 and - some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born + some months or at 67 (rules 7 and 8), and anyone aged 70 or over was born before 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index da2a2e5b8b..3c113c0deb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -39,7 +39,7 @@ household: members: [person1] output: - # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 7). + # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 6). is_SP_age: true # s.1(3)(c) bars the claim: 0, not the 5_000 reported. esa_income_eligible: false @@ -70,7 +70,7 @@ members: [person1, person2] output: # Both born after 5 October 1954 and before 6 April 1960: pensionable - # age 66 (rule 7). + # age 66 (rule 6). is_SP_age: [true, true] meets_pension_credit_age_conditions: true # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it From d37e42de921f2593e86343c869c62bdb458abe54 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:55:40 -0400 Subject: [PATCH 08/33] Open the Pension Credit branch only for a claim the bar could end The bar can change the screen only for a couple whose claim passes the age, work and capital tests, so the branch now opens only where such a couple meets the Pension Credit age conditions and would claim. Each family's result is unchanged. The Universal Credit modifier reads ESA for later years when the simulation is built, and in those years many reporters have reached pensionable age; the branch no longer opens for them. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 14 +++-- .../variables/gov/dwp/esa_income_eligible.py | 53 +++++++++---------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 5c06afc49f..3b37946e52 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -399,10 +399,12 @@ def combine(*situations): def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( monkeypatch, ): - """In 2026 no couple here has an open report and meets the Pension Credit - age conditions: a working-age claimant reporting the award, a couple aged - 70 and 60 without the saving (case 4's kind), a pensioner without a report, - and a member outside the couple reporting beside a pensioner.""" + """In 2026 no couple here has a claim that passes the other tests and + meets the Pension Credit age conditions: a working-age claimant reporting + the award, a couple aged 70 and 60 without the saving (case 4's kind), a + pensioner without a report, a member outside the couple reporting beside + a pensioner, and a pensioner reporting the award, whose claim pensionable + age bars whatever Pension Credit pays.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -426,9 +428,11 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( ], year, ), + household([dict(adult, age=75, esa_income_reported=5_000)], year), ) ) - assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000] + assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000, 0] + assert sim.calculate("meets_pension_credit_age_conditions", year)[-1] sim.calculate("household_net_income", year) assert log.bar_branches() == [] diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 8c805bb502..ec3c9a1767 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -75,9 +75,10 @@ class esa_income_eligible(Variable): "(pension_credit_without_income_related_esa), which counts the claim " "(would_claim_pc) and an entered award, and bars the couple's claim where it " "pays more than nil. It works that out only for a population in which some " - "couple has an open report, meets the Pension Credit age conditions " - "(meets_pension_credit_age_conditions) and would claim, and bars only such a " - "couple. Where Pension Credit pays nothing without the award but pays with " + "couple's claim passes the other tests and the couple meets the Pension " + "Credit age conditions (meets_pension_credit_age_conditions) and would claim, " + "and bars only such a couple. Where Pension Credit pays nothing without the " + "award but pays with " "it, through the higher earnings disregard the award brings (SPC Regs 2002 " "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " "both. The couple's Pension Credit bars only the couple's claim, not that of " @@ -136,24 +137,6 @@ def formula(benunit, period, parameters): # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man # born before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) - # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads Pension Credit worked - # out without it, for a couple with an open report that meets the - # Pension Credit age conditions and would claim, and only where some - # couple does. The couple's Pension Credit does not bar a member who - # claims in their own right. - could_be_on_pension_credit = ( - couple_reports - & benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - ) - on_pension_credit = could_be_on_pension_credit - if could_be_on_pension_credit.any(): - on_pension_credit = could_be_on_pension_credit & ( - pension_credit_without_income_related_esa(benunit, period) > 0 - ) - pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) # Para 6(1)(f) and reg 42(1): the other member of the couple works 24 @@ -167,11 +150,25 @@ def formula(benunit, period, parameters): other_member_works = claimant_or_partner & ( (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) - claimant = ( - candidate - & ~pensionable_age - & ~pension_credit_bars - & ~claimant_works - & ~other_member_works + passes = candidate & ~pensionable_age & ~claimant_works & ~other_member_works + within_capital = capital <= ESA.capital.limit + # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State + # Pension Credit, nor is the other member of the couple. Pension + # Credit reads the couple's award, so this reads Pension Credit worked + # out without it, for a couple whose claim passes the other tests and + # that meets the Pension Credit age conditions and would claim, and + # only where some couple does. The couple's Pension Credit does not + # bar a member who claims in their own right. + could_be_on_pension_credit = ( + benunit.any(passes & claimant_or_partner) + & within_capital + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) ) - return benunit.any(claimant) & (capital <= ESA.capital.limit) + on_pension_credit = could_be_on_pension_credit + if could_be_on_pension_credit.any(): + on_pension_credit = could_be_on_pension_credit & ( + pension_credit_without_income_related_esa(benunit, period) > 0 + ) + pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) + return benunit.any(passes & ~pension_credit_bars) & within_capital From 0d157f0781c02ed33cb2cf65aa9fb5a0f2fc4913 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 16:25:03 -0400 Subject: [PATCH 09/33] Name the actor in the Pension Credit bar's comments and changelog Comments, docstrings, the YAML header and the changelog now say what the model does in active voice. No formula changes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- .../tests/legacy_award_work_reference.py | 4 +-- .../esa_income/esa_income_pension_age.yaml | 4 +-- .../test_esa_income_pension_age_properties.py | 6 ++-- .../variables/gov/dwp/esa_income_eligible.py | 33 ++++++++++--------- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index 3ddcaa3308..c99d5ac52c 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit, worked out without that award, pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit without that award pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index b92009199a..efbe9685ec 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -35,8 +35,8 @@ - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would - claim, and Pension Credit worked out with no income-related ESA in the - family pays. Otherwise no member reports Pension Credit or Housing + claim, and the Pension Credit it would get with no income-related ESA in + the family pays. Otherwise no member reports Pension Credit or Housing Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 3c113c0deb..bde90721ca 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -5,7 +5,7 @@ # Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the # claimant "is not entitled to state pension credit" and "is not a member of a # couple the other member of which is entitled to ... state pension credit". -# The model reads the couple's Pension Credit worked out with no +# The model reads the Pension Credit the couple would get with no # income-related ESA in the family, B(0), and bars the couple's claim where it # pays. B(e) is the Pension Credit the family would get with the award e. # A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 @@ -474,7 +474,7 @@ # allowance (£11,000) and his earnings below the National Insurance primary # threshold (£155 a week), so neither takes anything off. -- name: Case 15, where Pension Credit pays with or without the award, the man gets Pension Credit worked out without it and no income-related ESA. +- name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 3b37946e52..e8d9ddb554 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -4,13 +4,13 @@ pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member of the couple, is entitled to State Pension Credit. Pension Credit reads the -couple's award, so the model reads the couple's Pension Credit worked out with +couple's award, so the model reads the Pension Credit the couple would get with no income-related ESA in the family, B(0), and bars the couple's claim where it pays. Each example builds a second, separate simulation of the same people with no -income-related ESA reported. Its pension_credit is B(0), worked out with no -branch. Against it, for every family drawn here: +income-related ESA reported. Its pension_credit gives B(0) without a branch. +Against it, for every family drawn here: - where B(0) pays nothing, whether the couple would claim Pension Credit changes neither the couple's income-related ESA diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index ec3c9a1767..4a3e156fcd 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -5,11 +5,12 @@ def pension_credit_without_income_related_esa(benunit, period): - """Pension Credit worked out with no income-related ESA in the family. + """The Pension Credit the family would get with no income-related ESA. Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's income-related ESA while the claimant, or the other member of the couple, - is entitled to State Pension Credit, and Pension Credit reads that award. + has an entitlement to State Pension Credit, and Pension Credit reads that + award. Its higher earnings disregard counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and @@ -18,18 +19,18 @@ def pension_credit_without_income_related_esa(benunit, period): (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income (has_own_esa_award). Reading Pension Credit in the simulation itself - would be circular. + would close a cycle. - This calculates Pension Credit in a branch in which esa_income, the - couple's award and esa_income_eligible are nil, so the screen never runs + This calculates Pension Credit in a branch that sets esa_income, the + couple's award and esa_income_eligible to nil, so the screen never runs there. The branch reads pension_credit, so the claim (would_claim_pc) - and an entered award count. Where it pays, the bar holds: the couple's - award is nil, and the simulation's own Pension Credit then equals the - branch's. Where it pays nothing, the award stands. If the award then - makes Pension Credit pay, through the higher earnings disregard, no - consistent state exists: Pension Credit would end the award, which would - end Pension Credit. The award then stands beside the Pension Credit - worked out on it. + and an entered award count. Where it pays, the bar holds: the screen + refuses the couple's award, and the simulation's own Pension Credit then + equals the branch's. Where it pays nothing, the award stands. If the + award then makes Pension Credit pay, through the higher earnings + disregard, no consistent state exists: Pension Credit would end the + award, which would end Pension Credit. The award then stands beside the + Pension Credit the model works out on it. In a year with tax credits, this branch reaches the passport branch (pension_credit_with_passported_tax_credits), and the passport branch @@ -70,8 +71,8 @@ class esa_income_eligible(Variable): "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " "Neither the claimant nor the other member of the couple may be entitled to " "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's award, so the screen reads Pension Credit worked out with no " - "income-related ESA in the family " + "couple's award, so the screen reads the Pension Credit the couple would get " + "with no income-related ESA in the family " "(pension_credit_without_income_related_esa), which counts the claim " "(would_claim_pc) and an entered award, and bars the couple's claim where it " "pays more than nil. It works that out only for a population in which some " @@ -154,8 +155,8 @@ def formula(benunit, period, parameters): within_capital = capital <= ESA.capital.limit # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads Pension Credit worked - # out without it, for a couple whose claim passes the other tests and + # Credit reads the couple's award, so this reads the couple's Pension + # Credit without it, for a couple whose claim passes the other tests and # that meets the Pension Credit age conditions and would claim, and # only where some couple does. The couple's Pension Credit does not # bar a member who claims in their own right. From b6ddfcf0dc36d640a36f544032ffaffbe912d8f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:20:30 -0400 Subject: [PATCH 10/33] Read entered or reformed Pension Credit for every couple the ESA bar reaches The income-related ESA screen inferred that Pension Credit could not pay a couple that failed the Pension Credit age conditions or would not claim. That holds only for the model's own formulas. Where a caller enters pension_credit, its entitlement, its eligibility or either credit, a reform replaces one of their formulas or changes the entitlement's sum, or the reform freezes Pension Credit at the baseline's award, the screen now reads Pension Credit without the award for every couple whose claim passes the other tests (pension_credit_follows_model_formulas). A parameter reform keeps the age and take-up conditions. The screen also returns before it reads any Pension Credit condition when no couple's claim passes the age, work and capital tests, and the Income Support gate test reads Pension Credit only for its ESA reference. replaces_formula, shared with Housing Benefit's hb_uses_annual_override, tells an input or a replaced formula from the model's calculation; the five Pension Credit variables join SUPPLIED_INPUT_VARIABLES so that it can ask about them. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 14 +- .../esa_income/esa_income_pension_age.yaml | 117 +++++ .../test_esa_income_pension_age_properties.py | 406 ++++++++++++++++-- ...t_gate_reads_claimant_or_partner_awards.py | 19 +- policyengine_uk/utils/supplied_inputs.py | 26 ++ .../variables/gov/dwp/esa_income_eligible.py | 228 ++++++---- ...ed_or_temporary_accommodation_disregard.py | 19 +- 7 files changed, 695 insertions(+), 134 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index efbe9685ec..2c5a45f939 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -29,9 +29,9 @@ their own State Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). A caller that reads the model's pensionable age passes it as is_SP_age; otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone - aged 64 or under was born after 5 October 1960 and reaches it at 66 and - some months or at 67 (rules 7 and 8), and anyone aged 70 or over was born - before 6 April 1960 and reached it at 66 or earlier. + aged 64 or under was born after 5 October 1960, reaches pensionable age at + 66 or later, and so falls below it; anyone aged 70 or over was born before + 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would @@ -41,8 +41,9 @@ exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 - s.1(6)), so s.1(3)(c) bars each of them whether or not Pension Credit - pays. It bars only the couple's claim, not that of a member claiming in + s.1(6)), so each of them has reached pensionable age and has no claim + under s.1(3)(c), whether or not Pension Credit pays. Pension Credit + entitlement ends only the couple's claim, not that of a member claiming in their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the @@ -184,7 +185,8 @@ def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing Benefit reports in 2025 or 2026: a couple every member of which has reached the qualifying age, each of whom has also reached pensionable - age, so s.1(3)(c) bars them whether or not Pension Credit pays.""" + age and so has no claim under s.1(3)(c), whether or not Pension Credit + pays.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index bde90721ca..c612100677 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -696,3 +696,120 @@ is_housing_benefit_benefit_cap_exempt_specified_benefit: true # So no cap applies: uc_benefit_cap is unlimited. is_uc_benefit_cap_exempt: true + +# Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model +# then reads the award it pays without the couple's income-related ESA, +# whatever the Pension Credit age conditions and the claim +# (would_claim_pc), since the input, not the formulas, sets the award. The +# man of cases 7 and 8 meets the age conditions; a man aged 40, born 1 March +# 1976, does not. + +- name: Case 21, a man who reports income-related ESA and has £2,000 of Pension Credit as an input gets no ESA, though he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + pension_credit: 2_000 + households: + household: + members: [person1] + output: + # Para 6(1)(c): he has an entitlement to the £2,000 of Pension Credit. + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + +- name: Case 22, a man aged 40 whose Pension Credit entitlement is an input of £2,000 gets that Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + pension_credit_entitlement: 2_000 + households: + household: + members: [person1] + output: + # He would claim, so pension_credit pays the entitlement, though he is + # under the qualifying age for State Pension Credit. + meets_pension_credit_age_conditions: false + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + +- name: Case 23, a man aged 40 whom an input makes eligible for Pension Credit, with no income, gets the minimum guarantee and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + is_pension_credit_eligible: true + households: + household: + members: [person1] + output: + meets_pension_credit_age_conditions: false + # Without the award his income is nil, so the guarantee credit pays the + # minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg + # 6(1)(b)). Under 65 he gets no savings credit. + guarantee_credit: 8_091.20 + pension_credit: 8_091.20 + esa_income_eligible: false + esa_income: 0 + +- name: Case 24, the man of case 21 with £2,000 of guarantee credit as an input keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + guarantee_credit: 2_000 + households: + household: + members: [person1] + output: + # pension_credit pays the entitlement only on a claim, so he gets no + # Pension Credit, and the model pays the award. + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index e8d9ddb554..3822c0a271 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -1,22 +1,24 @@ -"""Income-related ESA gives way to State Pension Credit only where Pension Credit pays. +"""The model refuses a couple income-related ESA only where the couple would +get State Pension Credit without it. -Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached -pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and -(d) bar the income-related allowance where the claimant, or the other member -of the couple, is entitled to State Pension Credit. Pension Credit reads the -couple's award, so the model reads the Pension Credit the couple would get with -no income-related ESA in the family, B(0), and bars the couple's claim where it -pays. +Parliament bars ESA for a claimant who has reached pensionable age, their own +State Pension age (Welfare Reform Act 2007 s.1(3)(c) and s.1(6)), and bars the +income-related allowance where the claimant, or the other member of the +couple, has an entitlement to State Pension Credit (Sch 1 para 6(1)(c) and +(d)). The model's Pension Credit reads the couple's award, so the model reads +the Pension Credit the couple would get with no income-related ESA in the +family, B(0), and refuses the couple's claim where B(0) exceeds nil. Each example builds a second, separate simulation of the same people with no income-related ESA reported. Its pension_credit gives B(0) without a branch. Against it, for every family drawn here: -- where B(0) pays nothing, whether the couple would claim Pension Credit - changes neither the couple's income-related ESA - (claimant_or_partner_esa_income) nor the family's (esa_income); -- where B(0) pays, the couple would claim it and has an open report, the - couple's income-related ESA is nil and the family's Pension Credit is B(0); +- where B(0) is nil, whether the couple would claim Pension Credit changes + neither the couple's income-related ESA (claimant_or_partner_esa_income) + nor the family's (esa_income); +- where B(0) exceeds nil, the couple would claim it and has an open report, + the couple's income-related ESA is nil and the family's Pension Credit is + B(0); - claiming Pension Credit never raises income-related ESA; - esa_income_eligible equals the family-by-family reading of the law in legacy_award_work_reference (esa_screen), given the model's pensionable age @@ -25,7 +27,7 @@ (is_on_income_related_esa), and a family whose every report comes from such a member gets none. -A family whose Pension Credit pays only with the award, through the higher +A family that gets Pension Credit only with the award, through the higher earnings disregard the award brings, gets both: no state is consistent there. The test counts such families and asserts nothing more of them. @@ -35,11 +37,11 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults have earnings, pensions and household savings, so Pension Credit's -means test pays some families and refuses others, and in 2016 and 2018 some -report working tax credit, whose income test the award lifts (TCA 2002 -s.7(2)). A member outside the couple claims in their own right: the couple's -Pension Credit does not bar them, and pensionable age does. +Adults have earnings, pensions and household savings, so some families pass +Pension Credit's means test and others fail it, and in 2016 and 2018 some +report working tax credit, which HMRC pays without its income test to a +family on the award (TCA 2002 s.7(2)). A member outside the couple claims in their own right: the couple's +Pension Credit leaves their claim alone, and their pensionable age does not. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one @@ -50,22 +52,40 @@ same simulation. Simulations take most of the time here, so each example builds two and the file builds few. -Separate tests count the branches the model opens: a population with no couple -that could be on Pension Credit opens none, a branch never outlives the -calculation, and the tax credit passport branch inside it reads the couple's -award as nil. +A differential test runs the drawn families through a reform that calculates +the model's Pension Credit by a formula of its own, so the screen reads +Pension Credit for every couple whose claim passes the other tests, and +requires the same results as the model, which reads it only for a couple +that meets the age conditions and would claim. + +Separate tests count the branches the model opens and the variables the +screen reads: a population with no couple that could be on Pension Credit +opens none, a branch never outlives the calculation, the tax credit passport +branch inside it reads the couple's award as nil, and the screen reads no +Pension Credit condition without a couple's claim that passes the other +tests. Others enter Pension Credit, or a part of it, as an input, or reform +it: an entered award, a reform that pays every family and a frozen award each +make the model read the award for every couple, and a parameter reform keeps +the age and take-up conditions. """ import numpy as np import pytest from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as period_ +from policyengine_core.reforms import Reform from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_uk import Simulation +from policyengine_uk.model_api import GBP, YEAR, BenUnit, Variable from policyengine_uk.tests.legacy_award_work_reference import esa_screen from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( NO_INCOME_RELATED_ESA_BRANCH, + pension_credit_follows_model_formulas, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit as model_pension_credit, ) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -159,10 +179,11 @@ def couple_reports(sim, year): return sim.populations["benunit"].sum(open_report & couple) > 0 -# Each pairing of B(0) and B(e) in 2016, for a man aged 63 born 1 March 1953 -# (esa_income_pension_age.yaml cases 15, 16 and 18), the couple of case 19 -# whose working tax credit decides it, and a member outside the couple -# reporting beside a pensioner on Pension Credit. +# Each pairing of B(0) and B(e) in 2016, for a man aged 63 and six months, whom +# the model takes to be born on 6 April 1953, with the earnings, pensions and +# awards of esa_income_pension_age.yaml cases 15, 16 and 18; the couple of case +# 19, whose working tax credit decides the pairing; and a member outside the +# couple reporting beside a pensioner on Pension Credit. MAN = { "age": 63, "months_since_last_birthday": 6, @@ -483,3 +504,334 @@ def test_the_branch_closes_and_its_passport_branch_reads_no_award(monkeypatch): assert not [ name for name in sim.branches if name.startswith(NO_INCOME_RELATED_ESA_BRANCH) ] + + +# The man of esa_income_pension_age.yaml cases 7 and 8, born 1 March 1953: in +# 2016 he meets the Pension Credit age conditions, under his pensionable age. +# A man aged 40 does not meet them. +MAN_63 = { + "age": 63, + "date_of_birth": 19530301, + "gender": "MALE", + "is_claimant_or_partner": True, + "esa_income_reported": 5_000, +} +MAN_40 = dict(MAN_63, age=40, date_of_birth=19760301) +ADULT = {"is_claimant_or_partner": True} + + +def no_branch_opens_beside(log): + return log.bar_branches() == [] + + +def test_an_entered_pension_credit_award_bars_the_award_whatever_the_age_and_take_up(): + """Three men in 2016 each have £2,000 of Pension Credit as an input: the + man aged 63 who would claim Pension Credit, the same man who would not, + and a man aged 40. Each has an entitlement to that Pension Credit (Sch 1 + para 6(1)(c)), so none gets income-related ESA (esa_income_pension_age.yaml + case 21).""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=True, pension_credit=2_000), + household([MAN_63], year, would_claim_pc=False, pension_credit=2_000), + household([MAN_40], year, pension_credit=2_000), + ) + ) + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + True, + False, + ] + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert sim.calculate("claimant_or_partner_esa_income", year).tolist() == [0] * 3 + + +class flat_pension_credit(Reform): + """Pension Credit of £2,000 for every benefit unit.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return benunit.filled_array(2_000) + + self.update_variable(pension_credit) + + +def test_a_reform_that_pays_pension_credit_to_all_bars_the_award(monkeypatch): + """Under a reform that pays every benefit unit £2,000 of Pension Credit, + the man aged 63 who would claim it, the same man who would not and a man + aged 40 each have an entitlement to it, so none gets income-related ESA, + and the model reads that Pension Credit for all three.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + ), + reform=flat_pension_credit, + ) + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert log.bar_branches() + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class pension_credit_by_another_formula(Reform): + """The model's Pension Credit, calculated by a formula of the reform's.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return model_pension_credit.formula(benunit, period, parameters) + + self.update_variable(pension_credit) + + +OUTPUTS = [ + "esa_income", + "claimant_or_partner_esa_income", + "esa_income_eligible", + "pension_credit", + "working_tax_credit", + "household_net_income", +] + + +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(examples()) +@example((ROWS, 2016)) +def test_reading_pension_credit_for_every_couple_matches_the_model(drawn): + """Differential: a reform that calculates the model's Pension Credit by a + formula of its own makes the screen read Pension Credit without the award + for every couple whose claim passes the other tests. It must give every + family the same ESA, Pension Credit and income as the model, which reads it + only for a couple that meets the age conditions and would claim.""" + units, year = drawn + event(f"year {year}") + people = situation(units, year, [True, False]) + model = Simulation(situation=people) + reformed = Simulation(situation=people, reform=pension_credit_by_another_formula) + parameters = model.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + model.populations["benunit"], period_(year), parameters + ) + assert not pension_credit_follows_model_formulas( + reformed.populations["benunit"], period_(year), parameters + ) + for variable in OUTPUTS: + assert np.allclose( + model.calculate(variable, year), + reformed.calculate(variable, year), + atol=0.01, + ), (variable, units) + + +def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): + """A reform that lifts the exclusion of mixed-age couples in 2026 keeps + the model's formulas, and meets_pension_credit_age_conditions reads its + parameters. A couple aged 70 and 60 without the saving then meets the + age conditions, so the model reads its Pension Credit and refuses the + younger member's award. With only a working-age claimant, no couple meets + them, and the model opens no branch, under the reform or without it.""" + log = BranchLog(monkeypatch) + year = 2026 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": False + } + } + working_age = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + mixed = household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ) + + alone = Simulation(situation=combine(working_age), reform=reform) + assert pension_credit_follows_model_formulas( + alone.populations["benunit"], period_(year), alone.tax_benefit_system.parameters + ) + assert alone.calculate("esa_income", year).tolist() == [5_000] + assert no_branch_opens_beside(log) + + baseline = Simulation(situation=combine(working_age, mixed)) + assert baseline.calculate("esa_income", year).tolist() == [5_000, 5_000] + assert baseline.calculate("pension_credit", year).tolist() == [0, 0] + assert no_branch_opens_beside(log) + + lifted = Simulation(situation=combine(working_age, mixed), reform=reform) + assert lifted.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + False, + True, + ] + # The couple's minimum guarantee for 52 weeks in 2026-27: 52 x 363.25. + assert lifted.calculate("pension_credit", year) == pytest.approx( + [0, 18_889], abs=0.01 + ) + assert lifted.calculate("esa_income", year).tolist() == [5_000, 0] + assert log.bar_branches() + + +def test_a_frozen_pension_credit_bars_the_award_whatever_the_reform_age_conditions(): + """In 2018 a couple aged 70 and 60 meets the Pension Credit age + conditions, so gets Pension Credit and no income-related ESA. A reform + that brings the exclusion of mixed-age couples forward to 2018 and freezes + Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) + still pays that award, though the couple no longer meets the reform's age + conditions, so the couple still gets no income-related ESA.""" + year = 2018 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": True + }, + "gov.contrib.freeze_pension_credit": {f"{year}-01-01.{year}-12-31": True}, + } + sim = Simulation( + situation=household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ), + reform=reform, + ) + assert not sim.calculate("meets_pension_credit_age_conditions", year)[0] + assert sim.baseline.calculate("esa_income", year).tolist() == [0] + # The couple's minimum guarantee for 52 weeks in 2018-19: 52 x 248.80. + frozen = sim.baseline.calculate("pension_credit", year) + assert frozen == pytest.approx([12_937.60], abs=0.01) + assert sim.calculate("pension_credit", year) == pytest.approx(frozen, abs=0.01) + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + assert sim.calculate("esa_income", year).tolist() == [0] + + +def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_conditions(): + """An input for pension_credit, its entitlement, its eligibility or either + credit can pay Pension Credit outside the age and take-up conditions, so + each makes the model read Pension Credit for every couple.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + parameters = sim.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + for variable, value in [ + ("pension_credit", 2_000), + ("pension_credit_entitlement", 2_000), + ("is_pension_credit_eligible", True), + ("guarantee_credit", 2_000), + ("savings_credit", 2_000), + ]: + branch = sim.get_branch(f"entered_{variable}") + try: + branch.set_input(variable, year, np.array([value])) + assert not pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), parameters + ), variable + # An input for another year leaves this year to the formulas. + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year + 1), parameters + ), variable + finally: + sim.branches.pop(f"entered_{variable}", None) + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + + +class ScreenReads: + """Records each variable the income-related ESA screen reads.""" + + def __init__(self, monkeypatch): + self.variables = [] + calculate = CoreSimulation.calculate + + def logged(simulation, variable_name, *args, **kwargs): + stack = simulation.tracer.stack + if stack and stack[-1]["name"] == "esa_income_eligible": + self.variables.append(variable_name) + return calculate(simulation, variable_name, *args, **kwargs) + + monkeypatch.setattr(CoreSimulation, "calculate", logged) + + +PENSION_CREDIT_CONDITIONS = {"meets_pension_credit_age_conditions", "would_claim_pc"} + + +def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( + monkeypatch, +): + """In 2026 no couple here has a claim that passes the age, work and + capital tests: a pensioner reporting the award, a claimant aged 45 in + remunerative work, and a member outside the couple aged 40 reporting + beside a pensioner, whose own claim passes. The screen then reads neither + the Pension Credit age conditions nor the claim. A claimant aged 45 out of + work passes, and the screen reads both.""" + reads = ScreenReads(monkeypatch) + year = 2026 + no_couple_claim = [ + household([dict(ADULT, age=75, esa_income_reported=5_000)], year), + household( + [ + dict( + ADULT, + age=45, + esa_income_reported=5_000, + employment_income=15_000, + hours_worked=1_560, + ) + ], + year, + ), + household( + [ + dict(ADULT, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 5_000, + }, + ], + year, + ), + ] + sim = Simulation(situation=combine(*no_couple_claim)) + assert sim.calculate("esa_income_eligible", year).tolist() == [False, False, True] + assert "esa_income_assessable_capital" in reads.variables + assert not PENSION_CREDIT_CONDITIONS & set(reads.variables) + + reads.variables.clear() + out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) + assert sim.calculate("esa_income_eligible", year).tolist() == [ + False, + False, + True, + True, + ] + assert PENSION_CREDIT_CONDITIONS <= set(reads.variables) diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index 86734bb1fb..f5cd9310c2 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -79,15 +79,16 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen - # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension Credit - # and it pays. No one here has earnings, and in 2025 no one has tax - # credits, so Pension Credit pays the same with the couple's award as - # without. It never bars an outside member's claim. - on_pension_credit = ( - benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - & (benunit("pension_credit", period) > 0) - ) + if is_esa: + # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension + # Credit and it pays. No one here has earnings, and in 2025 no one has + # tax credits, so Pension Credit pays the same with the couple's award + # as without. An outside member keeps their own claim whatever it pays. + on_pension_credit = ( + benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + & (benunit("pension_credit", period) > 0) + ) def passes(members, unit, couple): if not is_esa: diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 38897f81bf..6c292bbacd 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -63,13 +63,17 @@ "extended_childcare_entitlement_term_share", "extended_childcare_entitlement_total_per_child", "gas_consumption_kwh", + "guarantee_credit", "housing_benefit_applicable_income", "housing_benefit_applicable_income_childcare_element", "housing_benefit_applicable_income_disregard", "housing_benefit_childcare_earnings_limit", "housing_benefit_specified_or_temporary_accommodation_disregard", "in_HE", + "is_pension_credit_eligible", "other_local_bus_trips", + "pension_credit", + "pension_credit_entitlement", "pip_dl_category", "pip_dl_points", "pip_m_category", @@ -80,6 +84,7 @@ "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "ni_epg_full_rate_applies", + "savings_credit", "tenure_type", "targeted_childcare_entitlement", "trading_loss", @@ -200,6 +205,27 @@ def stored_as_input(population, variable_name: str, period: Period) -> bool: ) +def replaces_formula( + population, variable_name: str, period: Period, default_formula +) -> bool: + """Whether the simulation gives ``variable_name`` for ``period`` a value + other than what ``default_formula`` calculates: a reform neutralizes the + variable, removes its formula for the period or gives it another, or a + caller set an input for the period (``stored_as_input``), as a scenario + does with its own value (``set_model_value``). A cached formula result is + not an input, even in populations taken from another simulation or in + their clones and branches.""" + variable = population.simulation.tax_benefit_system.get_variable(variable_name) + formula = variable.get_formula(period) + if ( + variable.is_neutralized + or formula is None + or formula.__code__ != default_formula.__code__ + ): + return True + return stored_as_input(population, variable_name, period) + + def supplied_input( population, variable_name: str, period: Period ) -> Optional[np.ndarray]: diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 4a3e156fcd..d4b600e741 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,41 +1,108 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import replaces_formula, stored_as_input +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_entitlement import ( + pension_credit_entitlement, +) +from policyengine_uk.variables.gov.dwp.pension_credit.savings_credit.savings_credit import ( + savings_credit, +) # The branch in which the family has no income-related ESA. NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" +# The Pension Credit variables whose formulas the model's age and take-up +# conditions govern, with pension_credit_entitlement, which sums two of them. +PENSION_CREDIT_FORMULAS = [ + pension_credit, + is_pension_credit_eligible, + guarantee_credit, + savings_credit, +] + + +def pension_credit_follows_model_formulas(benunit, period, parameters): + """Whether the simulation calculates Pension Credit with the model's own + formulas, which pay it only to a family that meets the Pension Credit age + conditions (meets_pension_credit_age_conditions) and would claim + (would_claim_pc). + + pension_credit equals the entitlement where the family would claim and + nil otherwise. The entitlement sums the guarantee and savings credits for + a family eligible for Pension Credit (is_pension_credit_eligible), and + nil for any other, and only a family that meets the age conditions is + eligible. A parameter reform keeps these formulas, and the age conditions + read its parameters. + + A caller can instead set pension_credit, its entitlement, its eligibility + or either credit as an input for the period. A reform can replace or + remove one of their formulas, change what the entitlement sums or which + families it covers, or freeze Pension Credit at the baseline's award + (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. + Pension Credit can then pay a family that does not meet the age + conditions or would not claim. + """ + simulation = benunit.simulation + if ( + parameters(period).gov.contrib.freeze_pension_credit + and simulation.baseline is not None + ): + return False + for default in PENSION_CREDIT_FORMULAS: + if replaces_formula(benunit, default.__name__, period, default.formula): + return False + name = pension_credit_entitlement.__name__ + entitlement = simulation.tax_benefit_system.get_variable(name) + return ( + not entitlement.is_neutralized + and entitlement.get_formula(period) is None + and entitlement.adds == pension_credit_entitlement.adds + and entitlement.defined_for == pension_credit_entitlement.defined_for + and not stored_as_input(benunit, name, period) + ) + def pension_credit_without_income_related_esa(benunit, period): """The Pension Credit the family would get with no income-related ESA. - Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's - income-related ESA while the claimant, or the other member of the couple, - has an entitlement to State Pension Credit, and Pension Credit reads that - award. - Its higher earnings disregard counts the couple's award - (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para - 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and + Parliament bars the couple's income-related ESA while the claimant, or + the other member of the couple, has an entitlement to State Pension + Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), and the + model's Pension Credit reads that award. Its higher earnings disregard + counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 + Sch VI para 4(1)(a)(vii)). It counts working tax credit as income, and the tax credit income test reads the award (TCA 2002 s.7(2)), as does - Income Support's gate, which that test reads. Pension Credit's route test + Income Support's gate, which that test reads. Its route test (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income - (has_own_esa_award). Reading Pension Credit in the simulation itself - would close a cycle. + (has_own_esa_award). A formula that read Pension Credit in the + simulation itself would close a cycle. - This calculates Pension Credit in a branch that sets esa_income, the - couple's award and esa_income_eligible to nil, so the screen never runs - there. The branch reads pension_credit, so the claim (would_claim_pc) - and an entered award count. Where it pays, the bar holds: the screen - refuses the couple's award, and the simulation's own Pension Credit then - equals the branch's. Where it pays nothing, the award stands. If the - award then makes Pension Credit pay, through the higher earnings - disregard, no consistent state exists: Pension Credit would end the - award, which would end Pension Credit. The award then stands beside the - Pension Credit the model works out on it. + This function calculates Pension Credit in a branch that sets + esa_income, the couple's award and esa_income_eligible to nil, so the + screen never runs there. The branch reads pension_credit, so it counts + the claim (would_claim_pc) and an entered award. Where that Pension + Credit pays, the screen refuses the couple's award, and the + simulation's own Pension Credit then equals the branch's. Where it pays + nothing, the model pays the award. If the award then makes Pension + Credit pay, through the higher earnings disregard, no consistent state + exists: with Pension Credit in payment the family could not have the + award, and without the award it would get no Pension Credit. The model + then pays the award beside the Pension Credit it works out on it. - In a year with tax credits, this branch reaches the passport branch - (pension_credit_with_passported_tax_credits), and the passport branch - reaches this one. A branch reads the inputs its parents set, so a branch - of either keeps both. + In a year with tax credits, a calculation in this branch opens the + passport branch (pension_credit_with_passported_tax_credits), and one in + the passport branch opens this one. Core reads a branch's inputs from + the branches it was made from as well, so a branch of either keeps both + sets of inputs. """ simulation = benunit.simulation # get_branch returns an existing branch of that name, or this simulation @@ -69,40 +136,43 @@ class esa_income_eligible(Variable): "otherwise other open reporters are candidates. The claimant must not have " "reached pensionable age (Welfare Reform Act 2007 s.1(3)(c)): their own State " "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " - "Neither the claimant nor the other member of the couple may be entitled to " - "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's award, so the screen reads the Pension Credit the couple would get " - "with no income-related ESA in the family " - "(pension_credit_without_income_related_esa), which counts the claim " - "(would_claim_pc) and an entered award, and bars the couple's claim where it " - "pays more than nil. It works that out only for a population in which some " - "couple's claim passes the other tests and the couple meets the Pension " - "Credit age conditions (meets_pension_credit_age_conditions) and would claim, " - "and bars only such a couple. Where Pension Credit pays nothing without the " - "award but pays with " - "it, through the higher earnings disregard the award brings (SPC Regs 2002 " + "Neither the claimant nor the other member of the couple may have an " + "entitlement to State Pension Credit (Sch 1 para 6(1)(c) and (d)). The " + "model's Pension Credit reads the couple's award, so the screen reads the " + "Pension Credit the couple would get with no income-related ESA in the " + "family (pension_credit_without_income_related_esa), which counts the claim " + "(would_claim_pc) and an entered award, and refuses the couple's claim where " + "it exceeds nil. The screen works that out for each couple whose claim " + "passes the other tests, and only where some couple's claim does. Where the " + "model's own formulas calculate Pension Credit " + "(pension_credit_follows_model_formulas), they pay it only to a couple that " + "meets the Pension Credit age conditions (meets_pension_credit_age_conditions) " + "and would claim, so the screen works it out only for such a couple. Where " + "the family gets no Pension Credit without the award but gets it with the " + "award, through the higher earnings disregard the award brings (SPC Regs 2002 " "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " - "both. The couple's Pension Credit bars only the couple's claim, not that of " - "a member who claims in their own right. The claimant must not engage in " - "remunerative work (para 6(1)(e); esa_income_claimant_remunerative_work). The " - "other member of the couple must not engage in remunerative work (para " - "6(1)(f)): paid work of 24 hours a week or more (ESA Regs 2008 reg 42(1)), " - "subject to the existing carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B " - "para 4), which the model reads as is_carer_for_benefits. Assessable capital " - "must remain within the existing limit (para 6(1)(b); reg 110). The model " - "applies neither para 6(1)(g) (receiving education) nor the other member's " - "income-based JSA under para 6(1)(d). The Welfare Reform Act (Northern " - "Ireland) 2007 sets the same conditions in s.1(3)(c) and Sch 1 para 6(1)(c) " - "and (d), but its s.1(6) takes pensionable age from the Pensions (Northern " - "Ireland) Order 1995 Sch 2 para 1, not the Pensions Act 1995 Sch 4 para 1. " - "The two give the same pensionable age for every date of birth, so one rule " - "covers the UK. The aggregate esa_income uses this screen for its original " - "couple's part; each outside reporter's claim is paid separately on their own " - "existing work/capital screen, pensionable age and tariff. The benefit unit's " - "capital is an explicit proxy for unobserved outside-claim capital. Reports " - "whose own award is closed are excluded before choosing candidates; ESA also " - "excludes the couple's reports on a UC claim. This variable's candidate rules " - "are unchanged by the separate aggregation." + "both. A member who claims in their own right keeps their claim whatever the " + "couple's Pension Credit. The claimant must not engage in remunerative work " + "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " + "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " + "24 hours a week or more (ESA Regs 2008 reg 42(1)), subject to the existing " + "carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " + "reads as is_carer_for_benefits. Assessable capital must remain within the " + "existing limit (para 6(1)(b); reg 110). The model applies neither para " + "6(1)(g) (receiving education) nor the other member's income-based JSA under " + "para 6(1)(d). The Northern Ireland Assembly set the same conditions in the " + "Welfare Reform Act (Northern Ireland) 2007 s.1(3)(c) and Sch 1 para 6(1)(c) " + "and (d), but took pensionable age in s.1(6) from the Pensions (Northern " + "Ireland) Order 1995 Sch 2 para 1, where Parliament took it from the Pensions " + "Act 1995 Sch 4 para 1. Under either schedule a person born on a given date " + "reaches pensionable age on the same day, so the model applies one rule " + "across the UK. The aggregate esa_income uses this screen for its original " + "couple's part and pays each outside reporter's claim separately, on their " + "own existing work/capital screen, pensionable age and tariff. The screen " + "uses the benefit unit's capital as an explicit proxy for unobserved " + "outside-claim capital. It drops reports whose own award has closed before it " + "chooses candidates, and ESA also drops the couple's reports on a UC claim. " + "The separate aggregation leaves this variable's candidate rules unchanged." ) definition_period = YEAR reference = ( @@ -134,9 +204,9 @@ def formula(benunit, period, parameters): candidate = reports_award & ( claimant_or_partner | ~benunit.project(couple_reports) ) - # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age", - # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man - # born before 6 December 1953 reaches it at 65. + # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age" + # (s.1(6): the age in Pensions Act 1995 Sch 4 para 1). A man born + # before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) @@ -153,22 +223,28 @@ def formula(benunit, period, parameters): ) passes = candidate & ~pensionable_age & ~claimant_works & ~other_member_works within_capital = capital <= ESA.capital.limit - # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads the couple's Pension - # Credit without it, for a couple whose claim passes the other tests and - # that meets the Pension Credit age conditions and would claim, and - # only where some couple does. The couple's Pension Credit does not - # bar a member who claims in their own right. - could_be_on_pension_credit = ( - benunit.any(passes & claimant_or_partner) - & within_capital - & benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - ) - on_pension_credit = could_be_on_pension_credit - if could_be_on_pension_credit.any(): - on_pension_credit = could_be_on_pension_credit & ( + couple_passes = benunit.any(passes & claimant_or_partner) & within_capital + if not couple_passes.any(): + return benunit.any(passes) & within_capital + # Sch 1 para 6(1)(c) and (d): Parliament allows the income-related + # allowance only where neither the claimant nor the other member of + # the couple has an entitlement to State Pension Credit. The model's + # Pension Credit reads the couple's award, so this reads the couple's + # Pension Credit without it, for each couple whose claim passes the + # other tests. The model's own formulas pay Pension Credit only to a + # couple that meets the age conditions and would claim, so where they + # calculate it, this reads it only for a couple that does, and only + # where some couple does. A member who claims in their own right keeps + # their claim whatever the couple's Pension Credit. + on_pension_credit = couple_passes + if pension_credit_follows_model_formulas(benunit, period, parameters): + on_pension_credit = ( + couple_passes + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + ) + if on_pension_credit.any(): + on_pension_credit = on_pension_credit & ( pension_credit_without_income_related_esa(benunit, period) > 0 ) pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py index 7f29a3b4cc..b474522ada 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py @@ -1,27 +1,14 @@ from policyengine_uk.model_api import * from policyengine_core.parameters import ParameterNode from policyengine_uk.utils.excise import fiscal_year_segments -from policyengine_uk.utils.supplied_inputs import stored_as_input +from policyengine_uk.utils.supplied_inputs import replaces_formula def hb_uses_annual_override(benunit, period, variable_name, default_formula): """Keep annual inputs and replacement formulas out of recalculation.""" - simulation = benunit.simulation - variable = simulation.tax_benefit_system.get_variable(variable_name) - formula = variable.get_formula(period) - if ( - variable.is_neutralized - or formula is None - or formula.__code__ != default_formula.__code__ - ): - return True - # An input the caller set for this period overrides recalculation, and so - # does a scenario's own value (set_model_value), which the variable then - # reads; a cached formula result does not, even in populations taken from - # another simulation or in their clones and branches. - # SUPPLIED_INPUT_VARIABLES lists each name passed here, as stored_as_input + # SUPPLIED_INPUT_VARIABLES lists each name passed here, as replaces_formula # and set_model_value require. - return stored_as_input(benunit, variable_name, period) + return replaces_formula(benunit, variable_name, period, default_formula) def hb_accommodation_segments(benunit, period, parameters): From d4441835a7d0fa80cd285c631d3448229ff27b76 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:06:57 -0400 Subject: [PATCH 11/33] Leave Housing Benefit's override helper as it is on main The open fix-2730-hb-wtc-disregard branch rewrites hb_uses_annual_override, so delegating it to replaces_formula here would conflict with that branch. replaces_formula makes the same test for the ESA screen. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/supplied_inputs.py | 3 ++- ...ed_or_temporary_accommodation_disregard.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 6c292bbacd..74ca59f443 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -214,7 +214,8 @@ def replaces_formula( caller set an input for the period (``stored_as_input``), as a scenario does with its own value (``set_model_value``). A cached formula result is not an input, even in populations taken from another simulation or in - their clones and branches.""" + their clones and branches. Housing Benefit's hb_uses_annual_override + makes the same test for its annual amounts.""" variable = population.simulation.tax_benefit_system.get_variable(variable_name) formula = variable.get_formula(period) if ( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py index b474522ada..7f29a3b4cc 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py @@ -1,14 +1,27 @@ from policyengine_uk.model_api import * from policyengine_core.parameters import ParameterNode from policyengine_uk.utils.excise import fiscal_year_segments -from policyengine_uk.utils.supplied_inputs import replaces_formula +from policyengine_uk.utils.supplied_inputs import stored_as_input def hb_uses_annual_override(benunit, period, variable_name, default_formula): """Keep annual inputs and replacement formulas out of recalculation.""" - # SUPPLIED_INPUT_VARIABLES lists each name passed here, as replaces_formula + simulation = benunit.simulation + variable = simulation.tax_benefit_system.get_variable(variable_name) + formula = variable.get_formula(period) + if ( + variable.is_neutralized + or formula is None + or formula.__code__ != default_formula.__code__ + ): + return True + # An input the caller set for this period overrides recalculation, and so + # does a scenario's own value (set_model_value), which the variable then + # reads; a cached formula result does not, even in populations taken from + # another simulation or in their clones and branches. + # SUPPLIED_INPUT_VARIABLES lists each name passed here, as stored_as_input # and set_model_value require. - return replaces_formula(benunit, variable_name, period, default_formula) + return stored_as_input(benunit, variable_name, period) def hb_accommodation_segments(benunit, period, parameters): From da6d9add345c60febcadaab9b07fb45980f32cd1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:12:40 -0400 Subject: [PATCH 12/33] Name the claimant or body that acts in the ESA bar's comments Comments and docstrings no longer make a statute bar, exclude or lift: the claimant fails or meets a condition, DWP disregards earnings and HMRC pays working tax credit. The changelog fragment covers entered and reformed Pension Credit. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- .../esa_income/esa_income_pension_age.yaml | 75 +++++++++---------- .../test_esa_income_pension_age_properties.py | 4 +- .../variables/gov/dwp/_legacy_award_payee.py | 9 ++- .../variables/gov/dwp/esa_income.py | 4 +- .../gov/dwp/is_on_income_related_esa.py | 5 +- 6 files changed, 50 insertions(+), 49 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index c99d5ac52c..c686c53a85 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit without that award pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index c612100677..a5a1bbc8ae 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -2,17 +2,17 @@ # Welfare Reform Act 2007 s.1(3)(c): a claimant who has reached pensionable # age is not entitled to ESA. Pensionable age is the person's own State # Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). -# Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the -# claimant "is not entitled to state pension credit" and "is not a member of a -# couple the other member of which is entitled to ... state pension credit". -# The model reads the Pension Credit the couple would get with no -# income-related ESA in the family, B(0), and bars the couple's claim where it -# pays. B(e) is the Pension Credit the family would get with the award e. -# A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 -# (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving -# applies. -# Everyone here is a legacy claimant (would_claim_uc false), since a Universal -# Credit claim ends the couple's income-related ESA anyway. No case has +# Sch 1 para 6(1)(c) and (d): a claimant gets the income-related allowance +# only if the claimant "is not entitled to state pension credit" and "is not a +# member of a couple the other member of which is entitled to ... state +# pension credit". The model reads the Pension Credit the couple would get +# with no income-related ESA in the family, B(0), and refuses the couple's +# claim where B(0) exceeds nil. B(e) is the Pension Credit the family would +# get with the award e. A mixed-age couple has no entitlement to State Pension +# Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it +# keeps the SI 2019/37 art 4 saving. +# Everyone here is a legacy claimant (would_claim_uc false), since a couple +# that claims Universal Credit loses its income-related ESA anyway. No case has # capital, so no tariff income: an award that passes is the amount reported. # Pension Credit with no income is the guarantee credit's standard minimum # guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and @@ -41,7 +41,7 @@ output: # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 6). is_SP_age: true - # s.1(3)(c) bars the claim: 0, not the 5_000 reported. + # The claimant fails s.1(3)(c): 0, not the 5_000 reported. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -73,8 +73,7 @@ # age 66 (rule 6). is_SP_age: [true, true] meets_pension_credit_age_conditions: true - # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it - # too. + # The 68-year-old fails s.1(3)(c), and para 6(1)(c) and (d) too. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -110,10 +109,9 @@ # qualifying age on 6 January 2016 (Pensions Act 1995 Sch 4 para 1 rule 3, # table 1, via SPCA 2002 s.1(6)), so the couple could be entitled to # Pension Credit on 14 May 2019, and it reports Pension Credit now, so - # s.4(1A) does not exclude it. + # it keeps Pension Credit despite s.4(1A). has_mixed_age_couple_pension_credit_saving: true - # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not - # bar them. + # The 60-year-old has not reached pensionable age, so meets s.1(3)(c). is_SP_age: [true, false] meets_pension_credit_age_conditions: true # Para 6(1)(d): B(0) = 52 x 363.25 = 18_889 is payable, so the other @@ -150,12 +148,13 @@ members: [person1, person2] output: # Neither member reports Pension Credit or pension-age Housing Benefit, - # so the couple keeps no saving, and SPCA 2002 s.4(1A) excludes the - # 73-year-old while the partner is under the qualifying age. + # so the couple keeps no saving, and the 73-year-old has no entitlement + # to Pension Credit while the partner is under the qualifying age (SPCA + # 2002 s.4(1A)). has_mixed_age_couple_pension_credit_saving: false meets_pension_credit_age_conditions: false pension_credit: 0 - # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. + # The 60-year-old meets both para 6(1)(d) and s.1(3)(c): the 5_000. esa_income_eligible: true esa_income: 5_000 claimant_or_partner_esa_income: 5_000 @@ -188,7 +187,7 @@ has_mixed_age_couple_pension_credit_saving: true meets_pension_credit_age_conditions: true # Entitlement to Pension Credit needs a claim (Social Security - # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. + # Administration Act 1992 s.1), so the 60-year-old meets para 6(1)(d). pension_credit: 0 esa_income_eligible: true esa_income: 5_000 @@ -240,8 +239,8 @@ members: [person1] output: # Born 1 March 1953, before 6 December 1953: pensionable age 65, on 1 - # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so s.1(3)(c) does - # not bar him in 2016-17. + # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so he meets + # s.1(3)(c) in 2016-17. is_SP_age: false # The qualifying age for State Pension Credit is a woman's pensionable # age for the same date of birth (SPCA 2002 s.1(6)): a woman born from 6 @@ -359,7 +358,7 @@ household: members: [person1, person2] output: - # They claim in their own right, and s.1(3)(c) bars their claim too. + # They claim in their own right, and fail s.1(3)(c) too. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -467,9 +466,9 @@ # Pension Credit (from 6 January 2016) and under his pensionable age (65, # on 1 March 2018), with support-group income-related ESA. In 2016-17 the # minimum guarantee is £155.60 a week, 52 x 155.60 = 8_091.20 a year (SPC -# Regs 2002 reg 6(1)(b)), and Pension Credit disregards £5 a week of a single -# claimant's earnings (Sch VI para 5), or £20 a week while the claimant is on -# employment and support allowance (para 4(1)(a)(vii)). Below 65 no savings +# Regs 2002 reg 6(1)(b)), and DWP disregards £5 a week of a single claimant's +# earnings (Sch VI para 5), or £20 a week while the claimant is on employment +# and support allowance (para 4(1)(a)(vii)). Below 65 no savings # credit is due (SPCA 2002 s.3(1)). His income is below the personal # allowance (£11,000) and his earnings below the National Insurance primary # threshold (£155 a week), so neither takes anything off. @@ -500,8 +499,8 @@ members: [person1] output: # B(0): income 50 + 40 - 5 = 85 a week, so 155.60 - 85 = 70.60 a week. - # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) pays, so para 6(1)(c) - # bars the award and Pension Credit is B(0): 52 x 70.60. + # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) exceeds nil, so he fails + # para 6(1)(c) and gets B(0): 52 x 70.60. esa_income_eligible: false esa_income: 0 pension_credit: 3_671.20 @@ -533,8 +532,8 @@ members: [person1] output: # B(0): 115.50 + 61 - 5 = 171.50 a week, over 155.60. B(e): 115.50 + 61 - # - 20 = 156.50, still over it. Pension Credit refuses him either way, so - # para 6(1)(c) does not bar the award: 52 x 94.60 = 4_919.20. + # - 20 = 156.50, still over it. He gets no Pension Credit either way, so + # meets para 6(1)(c) and keeps the award: 52 x 94.60 = 4_919.20. pension_credit: 0 esa_income_eligible: true esa_income: 4_919.20 @@ -635,16 +634,16 @@ # Working tax credit: the basic and couple elements, 1_960 + 2_010 = # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts # as Pension Credit income (SPCA 2002 s.15(1)(b)). - # B(e): the award lifts the tax credit income test (TCA 2002 s.7(2)), so - # working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over + # B(e): with the award HMRC pays working tax credit without its income + # test (TCA 2002 s.7(2)), so working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the # £212.97 couple threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - - # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) pays, so para - # 6(1)(d) bars the award, and the couple gets B(0). + # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) exceeds nil, so + # the younger member fails para 6(1)(d), and the couple gets B(0). esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -684,8 +683,8 @@ # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so # nil either way. (Each member's income is under the £11,850 personal # allowance, and £124 a week is under the £162 National Insurance primary - # threshold, so neither takes anything off.) Para 6(1)(d) does not bar the - # award. + # threshold, so neither takes anything off.) The younger member meets + # para 6(1)(d). pension_credit: 0 esa_income: 2_537.60 claimant_or_partner_esa_income: 2_537.60 @@ -700,7 +699,7 @@ # Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model # then reads the award it pays without the couple's income-related ESA, # whatever the Pension Credit age conditions and the claim -# (would_claim_pc), since the input, not the formulas, sets the award. The +# (would_claim_pc), since the caller, not the formulas, sets the award. The # man of cases 7 and 8 meets the age conditions; a man aged 40, born 1 March # 1976, does not. diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 3822c0a271..9a5fbfd4da 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -424,8 +424,8 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( meets the Pension Credit age conditions: a working-age claimant reporting the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside - a pensioner, and a pensioner reporting the award, whose claim pensionable - age bars whatever Pension Credit pays.""" + a pensioner, and a pensioner reporting the award, who has reached + pensionable age and so has no claim whatever Pension Credit pays.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 35f5fdc5d6..e476acb154 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -45,10 +45,11 @@ def own_report_is_paid(person, period, award, barred, capital_limit): "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. - Their claim is screened on them alone. No condition of their own may bar - it (barred): they must not be engaged in remunerative work as its - claimant, and, for income-related ESA, must not have reached pensionable - age (Welfare Reform Act 2007 s.1(3)(c)). The award on their report alone + The model screens their claim on them alone. They must meet each + condition of their own (barred is true where they fail one): they must + not engage in remunerative work as its claimant, and, for income-related + ESA, must not have reached pensionable age (Welfare Reform Act 2007 + s.1(3)(c)). The award on their report alone must be positive: the report exceeds the tariff income from the benefit unit's capital, within the capital limit (capital_limit). Their own capital is not observed, so the benefit unit's stands in for it. diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index fe14c79598..d29c1f3a5e 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -15,8 +15,8 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) The claimant and partner share one claim. Other reporters have separate claims, each using the existing own-work and shared-capital/tariff proxy, - and barred once the reporter reaches pensionable age (Welfare Reform Act - 2007 s.1(3)(c)). + and the model pays none once the reporter reaches pensionable age + (Welfare Reform Act 2007 s.1(3)(c)). Selecting members changes only reports, never the original couple or the work/capital screen. This helper must not read payable-person status, which itself depends on the aggregate award. diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index 2a52e7d81c..5a52150937 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -49,7 +49,7 @@ class is_on_income_related_esa(Variable): def formula(person, period, parameters): # WRA 2007 s.1(3)(c): a member who has reached pensionable age cannot - # be the claimant, so the award is not payable to them. + # be the claimant, so the model pays them none of the award. pensionable_age = person("is_SP_age", period) couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 @@ -57,7 +57,8 @@ def formula(person, period, parameters): person, period, "esa_income_reported", can_claim=~pensionable_age ) # Their own claim, on their own report, work and age alone (WRA 2007 - # s.1(3)(c): a claimant who has reached pensionable age is not entitled). + # s.1(3)(c): a claimant who has reached pensionable age has no + # entitlement). own_award = own_report_is_paid( person, period, From 2ee68e799d9250a4b1e67df904e9134d796b64f2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:22:15 -0400 Subject: [PATCH 13/33] Count only the screen's reads in the simulation itself The branch that works out Pension Credit without employment income gives a claimant in paid work no earnings, so the screen can read the Pension Credit conditions there for a family whose claim fails the work test in the simulation itself. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 9a5fbfd4da..25e49e679d 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -764,7 +764,10 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition class ScreenReads: - """Records each variable the income-related ESA screen reads.""" + """Records each variable the income-related ESA screen reads in a + simulation itself. A branch can change the family: the one that works out + Pension Credit without employment income gives a claimant in paid work no + earnings, so their claim can pass the work test there.""" def __init__(self, monkeypatch): self.variables = [] @@ -772,7 +775,11 @@ def __init__(self, monkeypatch): def logged(simulation, variable_name, *args, **kwargs): stack = simulation.tracer.stack - if stack and stack[-1]["name"] == "esa_income_eligible": + if ( + simulation.branch_name == "default" + and stack + and stack[-1]["name"] == "esa_income_eligible" + ): self.variables.append(variable_name) return calculate(simulation, variable_name, *args, **kwargs) From cef744bc2cc7d3da868f11ddae22ffaaebfb52aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:34:34 -0400 Subject: [PATCH 14/33] Count the screen's reads for the test year in the simulation itself The model also runs the screen for 2027 to 2032 in that population, where the situation enters no earnings or hours, so the claimant in paid work can pass the work test there. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 25e49e679d..67e808d202 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -764,27 +764,36 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition class ScreenReads: - """Records each variable the income-related ESA screen reads in a - simulation itself. A branch can change the family: the one that works out - Pension Credit without employment income gives a claimant in paid work no - earnings, so their claim can pass the work test there.""" + """Records each variable the income-related ESA screen reads, with the + branch and year it reads in. A branch can change the family: the one that + works out Pension Credit without employment income gives a claimant in + paid work no earnings, so their claim can pass the work test there. So + can a later year: the model also runs the screen for 2027 to 2032 here, + and the situation enters earnings and hours for 2026 only.""" def __init__(self, monkeypatch): - self.variables = [] + self.reads = [] calculate = CoreSimulation.calculate def logged(simulation, variable_name, *args, **kwargs): stack = simulation.tracer.stack - if ( - simulation.branch_name == "default" - and stack - and stack[-1]["name"] == "esa_income_eligible" - ): - self.variables.append(variable_name) + if stack and stack[-1]["name"] == "esa_income_eligible": + frame = stack[-1] + self.reads.append( + (simulation.branch_name, str(frame["period"]), variable_name) + ) return calculate(simulation, variable_name, *args, **kwargs) monkeypatch.setattr(CoreSimulation, "calculate", logged) + def variables(self, year): + """What the screen read in the simulation itself, for year.""" + return { + variable + for branch, period, variable in self.reads + if branch == "default" and period == str(year) + } + PENSION_CREDIT_CONDITIONS = {"meets_pension_credit_age_conditions", "would_claim_pc"} @@ -829,10 +838,10 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( ] sim = Simulation(situation=combine(*no_couple_claim)) assert sim.calculate("esa_income_eligible", year).tolist() == [False, False, True] - assert "esa_income_assessable_capital" in reads.variables - assert not PENSION_CREDIT_CONDITIONS & set(reads.variables) + assert "esa_income_assessable_capital" in reads.variables(year) + assert not PENSION_CREDIT_CONDITIONS & reads.variables(year), reads.reads - reads.variables.clear() + reads.reads.clear() out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) assert sim.calculate("esa_income_eligible", year).tolist() == [ @@ -841,4 +850,4 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( True, True, ] - assert PENSION_CREDIT_CONDITIONS <= set(reads.variables) + assert PENSION_CREDIT_CONDITIONS <= reads.variables(year) From 09e0c0872b8e07737714fc1ea11ec95ee2b64afa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:59:44 -0500 Subject: [PATCH 15/33] Compare whole Pension Credit definitions before inferring nil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pension_credit_follows_model_formulas now asks replaces_definition about all five Pension Credit variables. It compares every attribute Core calculates a variable from (formula for the period, default, defined_for, adds and subtracts, period, neutralization, end) with the model's own class, and counts an input for the period. A reform that keeps the formula but gives a family outside defined_for a default of £2,000 now drops the age and take-up conditions, so the man aged 40 who would get that £2,000 gets no ESA. Tests: the four reforms (entitlement default, entitlement default with another payment formula, payment default, eligibility default), a reform that changes only the guarantee credit's coverage, the model's own definitions registered again (shortcut kept), and an exhaustive change of each attribute of each variable. The parameter reform test now also asserts the shortcut on the lifted couple. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 215 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 72 ++++-- .../variables/gov/dwp/esa_income_eligible.py | 41 ++-- 3 files changed, 285 insertions(+), 43 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 67e808d202..7cc517d9be 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -64,9 +64,13 @@ branch inside it reads the couple's award as nil, and the screen reads no Pension Credit condition without a couple's claim that passes the other tests. Others enter Pension Credit, or a part of it, as an input, or reform -it: an entered award, a reform that pays every family and a frozen award each +it: an entered award, a reform that pays every family, a frozen award and a +reform that changes the default or coverage of a Pension Credit variable each make the model read the award for every couple, and a parameter reform keeps -the age and take-up conditions. +the age and take-up conditions. One test changes each attribute of each +Pension Credit variable in turn, and requires the model to drop the age and +take-up conditions for every change to what Core calculates the variable +from. """ import numpy as np @@ -82,8 +86,15 @@ from policyengine_uk.tests.legacy_award_work_reference import esa_screen from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( NO_INCOME_RELATED_ESA_BRANCH, + PENSION_CREDIT_VARIABLES, pension_credit_follows_model_formulas, ) +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit as model_guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible as model_eligibility, +) from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( pension_credit as model_pension_credit, ) @@ -683,6 +694,7 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): assert no_branch_opens_beside(log) lifted = Simulation(situation=combine(working_age, mixed), reform=reform) + assert follows_model_formulas(lifted, year) assert lifted.calculate("meets_pension_credit_age_conditions", year).tolist() == [ False, True, @@ -763,6 +775,205 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition ) +def follows_model_formulas(sim, year): + return pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class entitlement_with_a_default(Reform): + """The model's Pension Credit entitlement, which gives £2,000 to a family + outside its defined_for.""" + + def apply(self): + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(pension_credit_entitlement) + + +class payment_with_a_default(Reform): + """The model's Pension Credit formula for a family eligible for Pension + Credit, and £2,000 for any other.""" + + def apply(self): + class pension_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + formula = model_pension_credit.formula + + self.update_variable(pension_credit) + + +class eligibility_with_a_default(Reform): + """The model's eligibility formula for a family that meets the age + conditions, and eligibility for any other.""" + + def apply(self): + class is_pension_credit_eligible(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + default_value = True + defined_for = "meets_pension_credit_age_conditions" + formula = model_eligibility.formula + + self.update_variable(is_pension_credit_eligible) + + +class guarantee_credit_for_every_family_it_tests(Reform): + """The model's guarantee credit and entitlement for every family with + income within the minimum guarantee (is_guarantee_credit_eligible), not + only one eligible for Pension Credit. Only their coverage changes.""" + + def apply(self): + class guarantee_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + formula = model_guarantee_credit.formula + + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(guarantee_credit) + self.update_variable(pension_credit_entitlement) + + +class model_pension_credit_registered_again(Reform): + """The model's own five Pension Credit variables, registered again.""" + + def apply(self): + for variable in PENSION_CREDIT_VARIABLES: + self.update_variable(variable) + + +@pytest.mark.parametrize( + "reform, follows, pension_credit_at_40, esa", + [ + (entitlement_with_a_default, False, 2_000, [5_000, 0, 0]), + ( + (entitlement_with_a_default, pension_credit_by_another_formula), + False, + 2_000, + [5_000, 0, 0], + ), + (payment_with_a_default, False, 2_000, [5_000, 0, 0]), + (eligibility_with_a_default, False, 8_091.20, [5_000, 0, 0]), + (guarantee_credit_for_every_family_it_tests, False, 8_091.20, [5_000, 0, 0]), + (model_pension_credit_registered_again, True, 0, [5_000, 5_000, 0]), + ], + ids=[ + "entitlement_default", + "entitlement_default_another_formula", + "payment_default", + "eligibility_default", + "guarantee_credit_coverage", + "model_definitions_again", + ], +) +def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( + reform, follows, pension_credit_at_40, esa +): + """In 2016 the man aged 63 who would not claim Pension Credit gets none + and keeps his £5,000 award, and the man aged 63 who would claim gets the + £8,091.20 minimum guarantee (52 x £155.60) and no award. The man aged 40 + does not meet the age conditions. Under each reform but the last, a + family outside the definition's defined_for gets something other than + the model's: Pension Credit pays him £2,000 or the minimum guarantee, so + he has an entitlement to it (Sch 1 para 6(1)(c)) and gets no award. The + model's own definitions, registered again, keep the shortcut, and he + keeps his £5,000.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert follows_model_formulas(sim, year) is follows + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + False, + True, + ] + assert sim.calculate("pension_credit", year) == pytest.approx( + [0, pension_credit_at_40, 8_091.20], abs=0.01 + ) + assert sim.calculate("esa_income", year).tolist() == esa + + +def wrapped(formula): + def wrapper(entity, period, parameters): + return formula(entity, period, parameters) + + return wrapper + + +def changes(variable): + """For each attribute a reform can change, the attribute, a new value and + whether the change can alter what Core calculates.""" + formula = wrapped(variable.get_formula() or model_pension_credit.formula) + default = True if variable.value_type is bool else 2_000.0 + return [ + ("default_value", default, True), + ("defined_for", "is_guarantee_credit_eligible", True), + ("is_neutralized", True, True), + ("formulas", {"0001-01-01": formula}, True), + ("adds", ["guarantee_credit"], True), + ("definition_period", "month", True), + ("end", period_(2010).start.date, True), + ("label", "Changed", False), + ("documentation", "Changed.", False), + ("reference", ("https://www.legislation.gov.uk/ukpga/2002/16",), False), + ("unit", "currency-USD", False), + ("metadata", {"changed": True}, False), + ] + + +def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): + """Exhaustive over the five Pension Credit variables and the attributes a + reform can change: any change to what Core calculates a variable from + drops the age and take-up conditions, and a change to its description + alone keeps them.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + system = sim.tax_benefit_system + assert follows_model_formulas(sim, year) + for model_variable in PENSION_CREDIT_VARIABLES: + name = model_variable.__name__ + variable = system.variables[name] + for attribute, value, calculates in changes(variable): + changed = variable.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables[name] = changed + try: + assert follows_model_formulas(sim, year) is not calculates, ( + name, + attribute, + ) + finally: + system.variables[name] = variable + assert follows_model_formulas(sim, year) + + class ScreenReads: """Records each variable the income-related ESA screen reads, with the branch and year it reads in. A branch can change the family: the one that diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 74ca59f443..2496f50dad 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -33,6 +33,7 @@ the record. """ +from functools import cache from typing import List, Optional import numpy as np @@ -205,26 +206,63 @@ def stored_as_input(population, variable_name: str, period: Period) -> bool: ) -def replaces_formula( - population, variable_name: str, period: Period, default_formula -) -> bool: - """Whether the simulation gives ``variable_name`` for ``period`` a value - other than what ``default_formula`` calculates: a reform neutralizes the - variable, removes its formula for the period or gives it another, or a - caller set an input for the period (``stored_as_input``), as a scenario - does with its own value (``set_model_value``). A cached formula result is - not an input, even in populations taken from another simulation or in - their clones and branches. Housing Benefit's hb_uses_annual_override - makes the same test for its annual amounts.""" - variable = population.simulation.tax_benefit_system.get_variable(variable_name) +# Variable attributes that describe a variable without changing what Core +# calculates for it. +DESCRIPTIVE_ATTRIBUTES = frozenset( + { + "_explicit_attribute_names", + "baseline_variable", + "category", + "cerfa_field", + "documentation", + "hidden_input", + "index_in_module", + "label", + "metadata", + "module_name", + "reference", + "unit", + } +) + + +def calculation_definition(variable, period: Period) -> dict: + """Every attribute Core calculates ``variable`` from for ``period``: all + but the descriptive ones, with the formula for the period, by its code, + in place of the formulas, and the entity by its key.""" + definition = { + name: value + for name, value in vars(variable).items() + if name not in DESCRIPTIVE_ATTRIBUTES | {"entity", "formulas"} + } formula = variable.get_formula(period) - if ( - variable.is_neutralized - or formula is None - or formula.__code__ != default_formula.__code__ + definition["formula"] = None if formula is None else formula.__code__ + definition["entity"] = variable.entity.key + return definition + + +@cache +def _model_variable(variable_class): + return variable_class() + + +def replaces_definition(population, variable_class, period: Period) -> bool: + """Whether the simulation gives the variable that ``variable_class`` + defines a value for ``period`` other than that definition calculates. A + reform can change anything Core calculates it from: neutralize it, give + it another formula for the period or none, or change its default, the + variable it is defined for, what it sums or its period. Or a caller can + set an input for the period (``stored_as_input``), as a scenario does + with its own value (``set_model_value``). A cached formula result is not + an input, even in populations taken from another simulation or in their + clones and branches.""" + name = variable_class.__name__ + variable = population.simulation.tax_benefit_system.get_variable(name) + if calculation_definition(variable, period) != calculation_definition( + _model_variable(variable_class), period ): return True - return stored_as_input(population, variable_name, period) + return stored_as_input(population, name, period) def supplied_input( diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index d4b600e741..0ed7374b02 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.supplied_inputs import replaces_formula, stored_as_input +from policyengine_uk.utils.supplied_inputs import replaces_definition from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( guarantee_credit, ) @@ -19,10 +19,11 @@ # The branch in which the family has no income-related ESA. NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" -# The Pension Credit variables whose formulas the model's age and take-up -# conditions govern, with pension_credit_entitlement, which sums two of them. -PENSION_CREDIT_FORMULAS = [ +# The Pension Credit variables whose definitions pay Pension Credit only to a +# family that meets the age conditions and would claim. +PENSION_CREDIT_VARIABLES = [ pension_credit, + pension_credit_entitlement, is_pension_credit_eligible, guarantee_credit, savings_credit, @@ -38,17 +39,17 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): pension_credit equals the entitlement where the family would claim and nil otherwise. The entitlement sums the guarantee and savings credits for a family eligible for Pension Credit (is_pension_credit_eligible), and - nil for any other, and only a family that meets the age conditions is - eligible. A parameter reform keeps these formulas, and the age conditions - read its parameters. + gives nil to any other, and only a family that meets the age conditions + is eligible. A parameter reform keeps these definitions, and the age + conditions read its parameters. A caller can instead set pension_credit, its entitlement, its eligibility - or either credit as an input for the period. A reform can replace or - remove one of their formulas, change what the entitlement sums or which - families it covers, or freeze Pension Credit at the baseline's award - (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. - Pension Credit can then pay a family that does not meet the age - conditions or would not claim. + or either credit as an input for the period. A reform can change anything + Core calculates one of them from (replaces_definition), such as the + default a family outside its defined_for gets, or freeze Pension Credit + at the baseline's award (gov.contrib.freeze_pension_credit) whatever the + reform's age conditions. Pension Credit can then pay a family that does + not meet the age conditions or would not claim. """ simulation = benunit.simulation if ( @@ -56,17 +57,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): and simulation.baseline is not None ): return False - for default in PENSION_CREDIT_FORMULAS: - if replaces_formula(benunit, default.__name__, period, default.formula): - return False - name = pension_credit_entitlement.__name__ - entitlement = simulation.tax_benefit_system.get_variable(name) - return ( - not entitlement.is_neutralized - and entitlement.get_formula(period) is None - and entitlement.adds == pension_credit_entitlement.adds - and entitlement.defined_for == pension_credit_entitlement.defined_for - and not stored_as_input(benunit, name, period) + return not any( + replaces_definition(benunit, variable, period) + for variable in PENSION_CREDIT_VARIABLES ) From 3fc1e1d7891c01cd43b1ba280f045879de53a6fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:00:22 -0500 Subject: [PATCH 16/33] Read Pension Credit for every couple when take-up has a formula The screen read would_claim_pc in the simulation itself to skip couples that would not claim. A reform that gives take-up a formula reading the family's ESA then closed esa_income -> esa_income_eligible -> would_claim_pc -> esa_income. pension_credit_follows_model_formulas now returns false when would_claim_pc has a formula for the period, so the screen reads Pension Credit without the award for every couple whose claim passes the other tests. Supplied take-up and parameter reforms keep the shortcut. Tests: the take-up formula and its control with Pension Credit by another formula (ESA [0, 5000, 0], Pension Credit [8091.20, 0, 8091.20]), and the exhaustive guard test's take-up rows (formula drops the shortcut; default and input keep it). Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 88 +++++++++++++++++-- .../variables/gov/dwp/esa_income_eligible.py | 11 ++- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 7cc517d9be..fcb6be45be 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -64,13 +64,13 @@ branch inside it reads the couple's award as nil, and the screen reads no Pension Credit condition without a couple's claim that passes the other tests. Others enter Pension Credit, or a part of it, as an input, or reform -it: an entered award, a reform that pays every family, a frozen award and a -reform that changes the default or coverage of a Pension Credit variable each -make the model read the award for every couple, and a parameter reform keeps -the age and take-up conditions. One test changes each attribute of each -Pension Credit variable in turn, and requires the model to drop the age and -take-up conditions for every change to what Core calculates the variable -from. +it: an entered award, a reform that pays every family, a frozen award, a +reform that changes the default or coverage of a Pension Credit variable and +a reform that gives take-up a formula each make the model read the award for +every couple, and a parameter reform keeps the age and take-up conditions. +One test changes each attribute of each Pension Credit variable in turn, and +requires the model to drop the age and take-up conditions for every change to +what Core calculates the variable from. """ import numpy as np @@ -918,6 +918,55 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( assert sim.calculate("esa_income", year).tolist() == esa +class take_up_without_income_related_esa(Reform): + """A family claims Pension Credit while it gets no income-related ESA.""" + + def apply(self): + class would_claim_pc(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + + def formula(benunit, period, parameters): + return benunit("esa_income", period) == 0 + + self.update_variable(would_claim_pc) + + +@pytest.mark.parametrize( + "reform", + [ + take_up_without_income_related_esa, + (take_up_without_income_related_esa, pension_credit_by_another_formula), + ], + ids=["take_up_formula", "take_up_formula_another_formula"], +) +def test_a_take_up_formula_makes_the_model_read_pension_credit_for_every_couple( + reform, +): + """Take-up that reads the family's ESA would close a cycle if the screen + read take-up in the simulation itself, so the model reads Pension Credit + without the award for every couple. Without the award both men aged 63 + in 2016 would claim and get the £8,091.20 minimum guarantee, so neither + gets the award, and both then claim. The man aged 40 gets no Pension + Credit, so he keeps his £5,000 and does not claim.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert not follows_model_formulas(sim, year) + assert sim.calculate("esa_income", year).tolist() == [0, 5_000, 0] + assert sim.calculate("pension_credit", year) == pytest.approx( + [8_091.20, 0, 8_091.20], abs=0.01 + ) + assert sim.calculate("would_claim_pc", year).tolist() == [True, False, True] + + def wrapped(formula): def wrapper(entity, period, parameters): return formula(entity, period, parameters) @@ -950,7 +999,8 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): """Exhaustive over the five Pension Credit variables and the attributes a reform can change: any change to what Core calculates a variable from drops the age and take-up conditions, and a change to its description - alone keeps them.""" + alone keeps them. A take-up formula drops them too, and a take-up input or + default keeps them, since the screen reads take-up itself.""" year = 2016 sim = Simulation(situation=household([MAN_40], year)) system = sim.tax_benefit_system @@ -971,6 +1021,28 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): ) finally: system.variables[name] = variable + take_up = system.variables["would_claim_pc"] + for attribute, value, follows in [ + ("formulas", {"0001-01-01": lambda benunit, period, parameters: True}, False), + ("default_value", False, True), + ]: + changed = take_up.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables["would_claim_pc"] = changed + try: + assert follows_model_formulas(sim, year) is follows, attribute + finally: + system.variables["would_claim_pc"] = take_up + branch = sim.get_branch("entered_take_up") + try: + branch.set_input("would_claim_pc", year, np.array([False])) + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), system.parameters + ) + finally: + sim.branches.pop("entered_take_up", None) assert follows_model_formulas(sim, year) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 0ed7374b02..fa776a77b5 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -49,7 +49,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): default a family outside its defined_for gets, or freeze Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. Pension Credit can then pay a family that does - not meet the age conditions or would not claim. + not meet the age conditions or would not claim. A reform can also give + would_claim_pc a formula, which could read the couple's ESA; the screen + would then close a cycle if it read take-up in the simulation itself. """ simulation = benunit.simulation if ( @@ -57,10 +59,13 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): and simulation.baseline is not None ): return False - return not any( + if any( replaces_definition(benunit, variable, period) for variable in PENSION_CREDIT_VARIABLES - ) + ): + return False + take_up = simulation.tax_benefit_system.get_variable("would_claim_pc") + return take_up.get_formula(period) is None def pension_credit_without_income_related_esa(benunit, period): From 31d90e93b37be71f794219661d1e076bea2a158c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:00:33 -0500 Subject: [PATCH 17/33] Name who acts in the ESA bar's comments and test notes DWP applies the higher earnings disregard while the claimant or partner receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)); a member outside the couple keeps their own claim; the 40-year-old claims in their own right; nobody owes income tax or primary Class 1 contributions in cases 13 and 20. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 6 ++-- .../esa_income/esa_income_pension_age.yaml | 28 ++++++++++--------- .../test_esa_income_pension_age_properties.py | 13 +++++---- .../variables/gov/dwp/esa_income_eligible.py | 10 +++---- 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 2c5a45f939..d3e7bc8bf5 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -42,9 +42,9 @@ couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 s.1(6)), so each of them has reached pensionable age and has no claim - under s.1(3)(c), whether or not Pension Credit pays. Pension Credit - entitlement ends only the couple's claim, not that of a member claiming in - their own right. + under s.1(3)(c), whether or not Pension Credit pays. A member outside the + couple keeps their own claim even when the couple has an entitlement to + Pension Credit. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index a5a1bbc8ae..e2518410b1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -388,8 +388,8 @@ # The 70-year-old is a single claimant of Pension Credit: 52 x 238.00. meets_pension_credit_age_conditions: true pension_credit: 12_376 - # The 40-year-old is not a member of the 70-year-old's couple, so para - # 6(1)(d) does not reach them: their own award of 5_000. + # The 40-year-old is not a member of the 70-year-old's couple, claims in + # their own right and keeps their award of 5_000 (para 6(1)(d)). esa_income_eligible: true esa_income: 5_000 claimant_or_partner_esa_income: 0 @@ -429,7 +429,7 @@ # payable to them. is_on_income_related_esa: [false, true] -- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple's award brings neither benefit cap exception. +- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple gets neither benefit cap exception. period: 2026 absolute_error_margin: 0.01 input: @@ -469,9 +469,10 @@ # Regs 2002 reg 6(1)(b)), and DWP disregards £5 a week of a single claimant's # earnings (Sch VI para 5), or £20 a week while the claimant is on employment # and support allowance (para 4(1)(a)(vii)). Below 65 no savings -# credit is due (SPCA 2002 s.3(1)). His income is below the personal -# allowance (£11,000) and his earnings below the National Insurance primary -# threshold (£155 a week), so neither takes anything off. +# credit is due (SPCA 2002 s.3(1)). His income stays under the personal +# allowance (£11,000) and his earnings under the National Insurance primary +# threshold (£155 a week), so he owes no income tax or primary Class 1 +# contributions. - name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 @@ -592,9 +593,10 @@ output: # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state - # is consistent: Pension Credit would end the award, and without the - # award Pension Credit pays nothing. B(0) pays nothing, so the award - # stands, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 = 525.20. + # is consistent: with Pension Credit in payment, the claimant could not + # have ESA; without ESA, he gets no Pension Credit. With B(0) nil, the + # model pays the award, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 + # = 525.20. esa_income_eligible: true esa_income: 5_491.20 pension_credit_earnings_disregard: 1_040 @@ -681,10 +683,10 @@ output: # In 2018-19 the couple minimum guarantee is £248.80 a week. B(0): 200 + # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so - # nil either way. (Each member's income is under the £11,850 personal - # allowance, and £124 a week is under the £162 National Insurance primary - # threshold, so neither takes anything off.) The younger member meets - # para 6(1)(d). + # nil either way. (Each member's income stays under the £11,850 personal + # allowance, and £124 a week under the £162 National Insurance primary + # threshold, so neither member owes income tax or primary Class 1 + # contributions.) The younger member meets para 6(1)(d). pension_credit: 0 esa_income: 2_537.60 claimant_or_partner_esa_income: 2_537.60 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index fcb6be45be..4ac452cf73 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -27,9 +27,11 @@ (is_on_income_related_esa), and a family whose every report comes from such a member gets none. -A family that gets Pension Credit only with the award, through the higher -earnings disregard the award brings, gets both: no state is consistent there. -The test counts such families and asserts nothing more of them. +DWP disregards £20 a week of earnings while the claimant or partner +receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), so some families get +Pension Credit only with the award. Such a family gets both: no state is +consistent there. The test counts such families and asserts nothing more of +them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -40,8 +42,9 @@ Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report working tax credit, which HMRC pays without its income test to a -family on the award (TCA 2002 s.7(2)). A member outside the couple claims in their own right: the couple's -Pension Credit leaves their claim alone, and their pensionable age does not. +family on the award (TCA 2002 s.7(2)). A member outside the couple claims in +their own right and keeps that claim while below pensionable age, whatever +the couple's Pension Credit. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index fa776a77b5..bb061d6789 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -145,11 +145,11 @@ class esa_income_eligible(Variable): "model's own formulas calculate Pension Credit " "(pension_credit_follows_model_formulas), they pay it only to a couple that " "meets the Pension Credit age conditions (meets_pension_credit_age_conditions) " - "and would claim, so the screen works it out only for such a couple. Where " - "the family gets no Pension Credit without the award but gets it with the " - "award, through the higher earnings disregard the award brings (SPC Regs 2002 " - "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " - "both. A member who claims in their own right keeps their claim whatever the " + "and would claim, so the screen works it out only for such a couple. DWP " + "disregards £20 a week of earnings while the claimant or partner receives " + "ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Where the family gets Pension " + "Credit only with that disregard, no consistent state exists, and the family " + "gets both. A member who claims in their own right keeps their claim whatever the " "couple's Pension Credit. The claimant must not engage in remunerative work " "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " From cf7153e53b519feb25693201252e3c3dc7e59b0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:40:07 -0500 Subject: [PATCH 18/33] Check the awards before the shortcut flag in the new reform tests A mutant that keeps the shortcut then fails on the award it pays, or on the cycle it closes, rather than on the flag. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 4ac452cf73..dea6ea27fb 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -909,16 +909,16 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( ), reform=reform, ) - assert follows_model_formulas(sim, year) is follows + assert sim.calculate("esa_income", year).tolist() == esa + assert sim.calculate("pension_credit", year) == pytest.approx( + [0, pension_credit_at_40, 8_091.20], abs=0.01 + ) assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ True, False, True, ] - assert sim.calculate("pension_credit", year) == pytest.approx( - [0, pension_credit_at_40, 8_091.20], abs=0.01 - ) - assert sim.calculate("esa_income", year).tolist() == esa + assert follows_model_formulas(sim, year) is follows class take_up_without_income_related_esa(Reform): @@ -962,12 +962,12 @@ def test_a_take_up_formula_makes_the_model_read_pension_credit_for_every_couple( ), reform=reform, ) - assert not follows_model_formulas(sim, year) assert sim.calculate("esa_income", year).tolist() == [0, 5_000, 0] assert sim.calculate("pension_credit", year) == pytest.approx( [8_091.20, 0, 8_091.20], abs=0.01 ) assert sim.calculate("would_claim_pc", year).tolist() == [True, False, True] + assert not follows_model_formulas(sim, year) def wrapped(formula): From eec9f6480ceaa5990d8f4420477cf9995da8ac45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:17:57 -0500 Subject: [PATCH 19/33] Name the five Pension Credit variables in the exhaustive guard test The test looped over the model's own list, so a variable dropped from that list dropped out of the test too. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index dea6ea27fb..a48648faf2 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -998,6 +998,16 @@ def changes(variable): ] +# The five Pension Credit variables, named apart from the model's own list. +PENSION_CREDIT_NAMES = [ + "pension_credit", + "pension_credit_entitlement", + "is_pension_credit_eligible", + "guarantee_credit", + "savings_credit", +] + + def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): """Exhaustive over the five Pension Credit variables and the attributes a reform can change: any change to what Core calculates a variable from @@ -1008,8 +1018,7 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): sim = Simulation(situation=household([MAN_40], year)) system = sim.tax_benefit_system assert follows_model_formulas(sim, year) - for model_variable in PENSION_CREDIT_VARIABLES: - name = model_variable.__name__ + for name in PENSION_CREDIT_NAMES: variable = system.variables[name] for attribute, value, calculates in changes(variable): changed = variable.clone() From abdcede2d5e084134c32978680ed6049e2baec3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:47:00 -0500 Subject: [PATCH 20/33] Name who acts in the remaining new comments and docstrings The generators draw tax-credit reports; Core reads a branch's inputs from its parent branches; the model pays the reported award and applies no Universal Credit cap in case 20. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 6 +++--- .../esa_income/esa_income_pension_age.yaml | 12 ++++++------ policyengine_uk/utils/supplied_inputs.py | 18 +++++++++--------- .../variables/gov/dwp/esa_income_eligible.py | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index d3e7bc8bf5..0630d8adea 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -200,9 +200,9 @@ def legacy_benefits_closed(couple, others, parameters): Reports by other members neither close nor shield it (SI 2014/1230 regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a - notice/deadline model. Tax-credit reports are generated only in years - with tax credits, in which they close nothing. Ages avoid the changing - Pension Credit qualifying-age boundary. + notice/deadline model. The generators draw tax-credit reports only in + years with tax credits, in which they close nothing. Ages avoid the + changing Pension Credit qualifying-age boundary. """ dwp = parameters.gov.dwp members = couple + others diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index e2518410b1..87e715ef82 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -11,11 +11,11 @@ # get with the award e. A mixed-age couple has no entitlement to State Pension # Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it # keeps the SI 2019/37 art 4 saving. -# Everyone here is a legacy claimant (would_claim_uc false), since a couple -# that claims Universal Credit loses its income-related ESA anyway. No case has -# capital, so no tariff income: an award that passes is the amount reported. -# Pension Credit with no income is the guarantee credit's standard minimum -# guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and +# Nobody here claims Universal Credit (would_claim_uc false), since a couple +# that claims it loses its income-related ESA anyway. No case has capital, so +# no tariff income: the model pays an award that passes at the amount +# reported. With no income, a family gets the guarantee credit's standard +# minimum guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and # £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social # Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so # 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here in 2026 reached @@ -695,7 +695,7 @@ # reg 83(1): the award with its support component. is_uc_benefit_cap_exempt_specified_benefit: true is_housing_benefit_benefit_cap_exempt_specified_benefit: true - # So no cap applies: uc_benefit_cap is unlimited. + # So the model applies no Universal Credit benefit cap. is_uc_benefit_cap_exempt: true # Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 2496f50dad..3328cbaafb 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -247,15 +247,15 @@ def _model_variable(variable_class): def replaces_definition(population, variable_class, period: Period) -> bool: - """Whether the simulation gives the variable that ``variable_class`` - defines a value for ``period`` other than that definition calculates. A - reform can change anything Core calculates it from: neutralize it, give - it another formula for the period or none, or change its default, the - variable it is defined for, what it sums or its period. Or a caller can - set an input for the period (``stored_as_input``), as a scenario does - with its own value (``set_model_value``). A cached formula result is not - an input, even in populations taken from another simulation or in their - clones and branches.""" + """Whether the simulation can give the variable that ``variable_class`` + defines a value for ``period`` other than the one that definition + calculates. A reform can change anything Core calculates it from: + neutralize it, give it another formula for the period or none, or change + its default, its defined_for, what it sums or its period. A caller can + also set an input for the period (``stored_as_input``), as a scenario + does with its own value (``set_model_value``). A cached formula result is + not an input, even in populations taken from another simulation or in + their clones and branches.""" name = variable_class.__name__ variable = population.simulation.tax_benefit_system.get_variable(name) if calculation_definition(variable, period) != calculation_definition( diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index bb061d6789..a25963a7f4 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -99,8 +99,8 @@ def pension_credit_without_income_related_esa(benunit, period): In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in the passport branch opens this one. Core reads a branch's inputs from - the branches it was made from as well, so a branch of either keeps both - sets of inputs. + its parent branches as well, so a branch of either keeps both sets of + inputs. """ simulation = benunit.simulation # get_branch returns an existing branch of that name, or this simulation From 1d6df1a43cfbf7c445d16def69181a02797b088b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:58:23 -0500 Subject: [PATCH 21/33] Name the model as the payer in the ESA Pension Credit docstrings Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 11 +++++------ .../test_income_support_eligibility_properties.py | 12 ++++++------ .../variables/gov/dwp/esa_income_eligible.py | 6 +++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index a48648faf2..37ae283b49 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -894,12 +894,11 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( """In 2016 the man aged 63 who would not claim Pension Credit gets none and keeps his £5,000 award, and the man aged 63 who would claim gets the £8,091.20 minimum guarantee (52 x £155.60) and no award. The man aged 40 - does not meet the age conditions. Under each reform but the last, a - family outside the definition's defined_for gets something other than - the model's: Pension Credit pays him £2,000 or the minimum guarantee, so - he has an entitlement to it (Sch 1 para 6(1)(c)) and gets no award. The - model's own definitions, registered again, keep the shortcut, and he - keeps his £5,000.""" + does not meet the age conditions. Under each reform but the last, the + model pays him £2,000 of Pension Credit or the minimum guarantee outside + the definition's defined_for, so he has an entitlement to it (Sch 1 para + 6(1)(c)) and gets no award. The model's own definitions, registered + again, keep the shortcut, and he keeps his £5,000.""" year = 2016 sim = Simulation( situation=combine( diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index c494b0446e..3d41cc4c07 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -59,9 +59,9 @@ (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, for the income-related ESA screen, -pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether Pension Credit -pays the couple (Sch 1 para 6(1)(c) and (d)): no one drawn here has earnings, -and in 2025 no one has tax credits, so the model's pension_credit is the same +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether the model pays +the couple Pension Credit (Sch 1 para 6(1)(c) and (d)): no one drawn here has +earnings, and in 2025 no one has tax credits, so the model's pension_credit is the same with or without the couple's income-related ESA; and the means test is out of scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit @@ -406,9 +406,9 @@ def reference_eligibility( """The model's Income Support gate, read family by family. on_pension_credit is the model's proxy for s.124(1)(g); - entitled_to_pension_credit is whether Pension Credit pays the couple, - which the income-related ESA screen reads (WRA 2007 Sch 1 para 6(1)(c) - and (d)).""" + entitled_to_pension_credit records whether the model pays the couple + Pension Credit, which the income-related ESA screen reads (WRA 2007 Sch 1 + para 6(1)(c) and (d)).""" IS = parameters.gov.dwp.income_support WORK = IS.eligibility.remunerative_work JSA = parameters.gov.dwp.JSA.income diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index a25963a7f4..b966270258 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -48,9 +48,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): Core calculates one of them from (replaces_definition), such as the default a family outside its defined_for gets, or freeze Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) whatever the - reform's age conditions. Pension Credit can then pay a family that does - not meet the age conditions or would not claim. A reform can also give - would_claim_pc a formula, which could read the couple's ESA; the screen + reform's age conditions. The model can then pay Pension Credit to a + family that does not meet the age conditions or would not claim. A + reform can also give would_claim_pc a formula, which could read the couple's ESA; the screen would then close a cycle if it read take-up in the simulation itself. """ simulation = benunit.simulation From c3f9cd4fd55f561cabf22c30266ce2298b0859ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:29:57 +0000 Subject: [PATCH 22/33] Save the 2026 awards in six pension-age ESA cases from the abolition Cases 4, 5, 6, 12, 13 and 14 of esa_income_pension_age.yaml assert a full-year income-related ESA award in 2026-27. Each ESA reporter now has the art. 3A(2) appointee saving, so the 1 July 2026 abolition leaves the award payable for the whole year and each case still tests the pension-age bar and the Pension Credit condition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../esa_income/esa_income_pension_age.yaml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 87e715ef82..f460e7cf4b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -139,6 +139,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -175,6 +179,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -203,6 +211,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1] @@ -377,6 +389,10 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -404,10 +420,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -438,11 +462,19 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] From d2ce8b54a32b8cadd689c08d00f7c8f58e70dc5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:37:07 +0000 Subject: [PATCH 23/33] Hold the pension-age ESA properties with the 2026 abolition in force The 2026 examples of test_esa_income_pension_age_properties.py draw saved awards, and the three 2026 example tests give each reporter the art. 3A(2) appointee saving, so each award stays payable for the whole year and the tests still check the pension-age bar and the Pension Credit condition. legacy_award_work_reference.esa_abolition_claim reads the saving. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../tests/legacy_award_work_reference.py | 13 ++-- .../test_esa_income_pension_age_properties.py | 74 ++++++++++++++----- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 53db8306c9..7c00330b0b 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -230,16 +230,19 @@ def esa_abolition_claim(couple, parameters): reports (esa_income_report_closed). The abolition reaches every remaining award on 1 July 2026 in England - (SI 2025/1148 art. 3A), the households' default country, and no member - here has the appointee saving, so it reaches any award reported in a - year with less than all of it payable. would_claim_uc_at_legacy_closure + (SI 2025/1148 art. 3A), the households' default country, so it reaches + any award reported in a year with less than all of it payable. An + appointee saving (art. 3A(2), esa_income_appointee_saving) for a member + who reports the award keeps the whole claim payable, and a saving for a + member who reports nothing keeps nothing. would_claim_uc_at_legacy_closure keeps its default, true. The conditions are those of the other working-age closures: no Pension Credit report, and not all pension age. """ payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain - reports_award = any(m.get("esa_income_reported", 0) > 0 for m in couple) + reporters = [m for m in couple if m.get("esa_income_reported", 0) > 0] + saved = any(m.get("esa_income_appointee_saving", False) for m in reporters) on_pension_credit = any(m.get("pension_credit_reported", 0) > 0 for m in couple) - if not reports_award or payable >= 1 or on_pension_credit: + if not reporters or saved or payable >= 1 or on_pension_credit: return False assert all(not 65 <= adult["age"] < 70 for adult in couple), couple return not all(adult["age"] >= 70 for adult in couple) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 37ae283b49..6603810b3f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -39,7 +39,12 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults have earnings, pensions and household savings, so some families pass +The Secretary of State appointed 1 July 2026 for the abolition of +income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), +and an appointee saving keeps an award in payment (art. 3A(2)), so every +adult drawn in 2026 has the saving (esa_income_appointee_saving) and each +award stays payable for the whole year; +test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report working tax credit, which HMRC pays without its income test to a family on the award (TCA 2002 s.7(2)). A member outside the couple claims in @@ -118,7 +123,7 @@ @st.composite -def adults(draw, outside=False, tax_credits=False): +def adults(draw, outside=False, tax_credits=False, saved=False): adult = { "age": draw(st.sampled_from(AGES)), "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), @@ -129,6 +134,8 @@ def adults(draw, outside=False, tax_credits=False): "employment_income": draw(st.sampled_from(EARNINGS)), "private_pension_income": draw(st.sampled_from(PENSIONS)), } + if saved: + adult["esa_income_appointee_saving"] = True if outside: adult["current_education"] = "NOT_IN_EDUCATION" elif tax_credits: @@ -137,14 +144,19 @@ def adults(draw, outside=False, tax_credits=False): @st.composite -def families(draw, tax_credits): +def families(draw, tax_credits, saved): """A claimant, an optional partner, an optional member outside the couple, the couple's saving and the household's savings. Members of the couple - report working tax credit only in a year with tax credits.""" - couple = [draw(adults(tax_credits=tax_credits))] + report working tax credit only in a year with tax credits. With saved, + every adult has the appointee saving for income-related ESA.""" + couple = [draw(adults(tax_credits=tax_credits, saved=saved))] if draw(st.booleans()): - couple.append(draw(adults(tax_credits=tax_credits))) - others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] + couple.append(draw(adults(tax_credits=tax_credits, saved=saved))) + others = ( + [draw(adults(outside=True, saved=saved))] + if draw(st.integers(0, 3)) == 0 + else [] + ) saving = draw(st.booleans()) return couple, others, saving, draw(st.sampled_from(SAVINGS)) @@ -154,7 +166,11 @@ def examples(draw): year = draw(st.sampled_from(YEARS)) # Tax credits ended on 5 April 2025. tax_credits = year < 2025 - units = draw(st.lists(families(tax_credits), min_size=4, max_size=12)) + # The abolition of income-related ESA reaches unsaved awards from 1 July + # 2026 (SI 2025/1148 art. 3A(1)), so every adult drawn in 2026 has the + # appointee saving (art. 3A(2)), which keeps the award payable all year. + saved = year >= 2026 + units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) return units, year @@ -377,6 +393,11 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): start += len(members) +# An appointee saving for income-related ESA (SI 2025/1148 art. 3A(2)), which +# keeps the award payable after the abolition of 1 July 2026. +SAVED = {"esa_income_appointee_saving": True} + + class BranchLog: """Records each branch Core creates, with the simulation it branches.""" @@ -439,15 +460,19 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside a pensioner, and a pensioner reporting the award, who has reached - pensionable age and so has no claim whatever Pension Credit pays.""" + pensionable age and so has no claim whatever Pension Credit pays. Each + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the + abolition of income-related ESA on 1 July 2026 leaves each award payable + for the whole year.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} + reports = {"esa_income_reported": 5_000, **SAVED} sim = Simulation( situation=combine( - household([dict(adult, age=45, esa_income_reported=5_000)], year), + household([dict(adult, age=45, **reports)], year), household( - [dict(adult, age=70), dict(adult, age=60, esa_income_reported=5_000)], + [dict(adult, age=70), dict(adult, age=60, **reports)], year, ), household([dict(adult, age=75)], year), @@ -458,12 +483,12 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( "age": 40, "is_claimant_or_partner": False, "current_education": "NOT_IN_EDUCATION", - "esa_income_reported": 5_000, + **reports, }, ], year, ), - household([dict(adult, age=75, esa_income_reported=5_000)], year), + household([dict(adult, age=75, **reports)], year), ) ) assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000, 0] @@ -669,7 +694,10 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): parameters. A couple aged 70 and 60 without the saving then meets the age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets - them, and the model opens no branch, under the reform or without it.""" + them, and the model opens no branch, under the reform or without it. Each + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the + abolition of income-related ESA on 1 July 2026 leaves each award payable + for the whole year.""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -677,9 +705,11 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): f"{year}-01-01.{year}-12-31": False } } - working_age = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + working_age = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) mixed = household( - [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000, **SAVED)], year, has_mixed_age_couple_pension_credit_saving=False, ) @@ -1100,11 +1130,13 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( remunerative work, and a member outside the couple aged 40 reporting beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of - work passes, and the screen reads both.""" + work passes, and the screen reads both. Each reporter has the appointee + saving (SI 2025/1148 art. 3A(2)), so the abolition of income-related ESA + on 1 July 2026 leaves each report open for the whole year.""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ - household([dict(ADULT, age=75, esa_income_reported=5_000)], year), + household([dict(ADULT, age=75, esa_income_reported=5_000, **SAVED)], year), household( [ dict( @@ -1113,6 +1145,7 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( esa_income_reported=5_000, employment_income=15_000, hours_worked=1_560, + **SAVED, ) ], year, @@ -1125,6 +1158,7 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( "is_claimant_or_partner": False, "current_education": "NOT_IN_EDUCATION", "esa_income_reported": 5_000, + **SAVED, }, ], year, @@ -1136,7 +1170,9 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( assert not PENSION_CREDIT_CONDITIONS & reads.variables(year), reads.reads reads.reads.clear() - out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + out_of_work = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) assert sim.calculate("esa_income_eligible", year).tolist() == [ False, From 71ba27523c06281eeab8a60b644d6098adb8a2c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:48:53 +0000 Subject: [PATCH 24/33] Wrap the pension-age property docstring Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../tests/test_esa_income_pension_age_properties.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 6603810b3f..4d87db386c 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -44,12 +44,13 @@ and an appointee saving keeps an award in payment (art. 3A(2)), so every adult drawn in 2026 has the saving (esa_income_appointee_saving) and each award stays payable for the whole year; -test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass -Pension Credit's means test and others fail it, and in 2016 and 2018 some -report working tax credit, which HMRC pays without its income test to a -family on the award (TCA 2002 s.7(2)). A member outside the couple claims in -their own right and keeps that claim while below pensionable age, whatever -the couple's Pension Credit. +test_esa_income_abolition_properties.py tests unsaved awards. Adults have +earnings, pensions and household savings, so some families pass Pension +Credit's means test and others fail it, and in 2016 and 2018 some report +working tax credit, which HMRC pays without its income test to a family on +the award (TCA 2002 s.7(2)). A member outside the couple claims in their own +right and keeps that claim while below pensionable age, whatever the couple's +Pension Credit. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one From 70563bbde1202082ca3d66307a57a20124b0e0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:39:35 +0000 Subject: [PATCH 25/33] Pay a couple on income-related ESA no Pension Credit WRA 2007 Sch 1 para 6(1)(c) and (d) make the two awards exclusive. Where the couple's Pension Credit without the award is nil, the model pays the award and no Pension Credit, whatever the award would add through the SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregard. Case 18 now expects that, case 25 states the para 4(2) limit, and the combined property requires no family to have both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KPXgYTCw1EWx4JK8T --- changelog.d/2565.fixed.md | 2 +- .../esa_income/esa_income_pension_age.yaml | 100 ++++++++++++++++-- .../test_esa_income_pension_age_properties.py | 25 +++-- .../variables/gov/dwp/esa_income_eligible.py | 27 +++-- .../gov/dwp/pension_credit/pension_credit.py | 30 +++++- 5 files changed, 158 insertions(+), 26 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index c686c53a85..aee0e6c5ca 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit, and it pays no Pension Credit to a couple while it pays the couple income-related ESA (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index f460e7cf4b..ae51c8dad0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -8,7 +8,8 @@ # pension credit". The model reads the Pension Credit the couple would get # with no income-related ESA in the family, B(0), and refuses the couple's # claim where B(0) exceeds nil. B(e) is the Pension Credit the family would -# get with the award e. A mixed-age couple has no entitlement to State Pension +# get with the award e. Where B(0) is nil, the model pays the couple's award +# and no Pension Credit, whatever B(e) (cases 18 and 25). A mixed-age couple has no entitlement to State Pension # Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it # keeps the SI 2019/37 art 4 saving. # Nobody here claims Universal Credit (would_claim_uc false), since a couple @@ -599,7 +600,7 @@ pension_credit: 0 esa_income: 4_919.20 -- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets that Pension Credit too. +- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets no Pension Credit. period: 2016 absolute_error_margin: 0.01 input: @@ -623,16 +624,36 @@ household: members: [person1] output: + # In 2016-17 the minimum guarantee for a single claimant is £155.60 a week. # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): - # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state - # is consistent: with Pension Credit in payment, the claimant could not - # have ESA; without ESA, he gets no Pension Credit. With B(0) nil, the - # model pays the award, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 - # = 525.20. + # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week, or + # 52 x 10.10 = 525.20. B(0) is nil, so he meets para 6(1)(c) and the model + # pays the award, 52 x 105.60 = 5_491.20. + # The model pays no Pension Credit beside it. Parliament allows the award + # only while he "is not entitled to state pension credit" (WRA 2007 Sch 1 + # para 6(1)(c)), so with Pension Credit in payment his ESA would stop, and + # with it the £20 that SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregards + # for a claimant "in receipt of ... employment and support allowance". + # Para 4(2) keeps the £20 after the award ends only where "£20 was + # disregarded in respect of earnings taken into account in that award". He + # works 15 hours a week, under the 16-hour limit, and his £115.50 does not + # exceed the 2016-17 permitted work limit of £115.50: 16 hours at the + # £7.20 national living wage, £115.20, which ESA Regs 2008 reg 45(9A) + # rounds up to the next 50p (esa_exempt_work_higher_earnings_limit, from + # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and + # gov.hmrc.minimum_wage.non_apprentice). So + # ESA Regs 2008 Sch 7 para 5 disregards "any earnings derived from that + # work which do not exceed the limits": all of them, not £20. Para 4(2)(a) + # fails, his Pension Credit would fall back to B(0), nil, and his ESA + # would come back. ESA with no Pension Credit holds; Pension Credit with + # no ESA does not. esa_income_eligible: true esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: 115.50 pension_credit_earnings_disregard: 1_040 - pension_credit: 525.20 + pension_credit_entitlement: 525.20 + pension_credit: 0 - name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. period: 2016 @@ -846,3 +867,66 @@ pension_credit: 0 esa_income_eligible: true esa_income: 5_000 + +- name: Case 25, a couple whose award may count the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £135 a week. + private_pension_income: 7_020 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £115.50 a week for 15 hours, under the 24 hours of para 6(1)(f) + # (ESA Regs 2008 reg 42(1)). + employment_income: 6_006 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The man reports the award and the woman has the earnings, so case 18's + # pension of £50 a week becomes £135 to land the couple in the same + # pairing. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 115.50 + 135 = 250.50 a week, 52 x 250.50 = 13_026. Neither + # member owes income tax or primary Class 1 contributions on £115.50 a + # week in 2016-17. + # B(0): 13_026 - 520 = 12_506, over 12_352.60, so nil. B(e): 13_026 - + # 1_040 = 11_986, so 12_352.60 - 11_986 = 366.60, or 52 x 7.05. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. ESA Regs 2008 Sch 7 para 7(4) + # disregards £20 of earnings "in a case where the claimant's partner is in + # part-time employment and paragraph 6 does not apply". If DWP disregarded + # £20 of the woman's earnings in the man's award, SPC Regs 2002 Sch VI + # para 4(2) keeps the £20 where "£20 was disregarded in respect of + # earnings taken into account in that award" and "the person whose + # earnings qualified for the disegard continues in employment after the + # termination of that award". Once the couple claimed, it could then keep + # 366.60 of Pension Credit with no ESA. The model holds no record of how + # an award started, and pension_credit_earnings_disregard does not apply + # para 4(2), so the model pays this couple the award and no Pension + # Credit, as in case 18. + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 366.60 + pension_credit: 0 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 4d87db386c..0f185b03c8 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -25,13 +25,13 @@ (is_SP_age) and B(0); - nobody who has reached pensionable age is on income-related ESA (is_on_income_related_esa), and a family whose every report comes from such - a member gets none. + a member gets none; +- no family has both Pension Credit and the couple's income-related ESA. DWP disregards £20 a week of earnings while the claimant or partner receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), so some families get -Pension Credit only with the award. Such a family gets both: no state is -consistent there. The test counts such families and asserts nothing more of -them. +Pension Credit only with the award. The model pays such a family the award +and no Pension Credit (pension_credit), and the test counts them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -339,8 +339,10 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): ("Pension Credit pays without the award", barred), ("Pension Credit refuses a couple with a report", refused & reports[claims]), ( - "both awards paid", - (couple_esa[claims] > 0) & (pension_credit[claims] > 0), + "Pension Credit pays only with the award", + refused + & (couple_esa[claims] > 0) + & (sim.calculate("pension_credit_entitlement", year)[claims] > 0), ), ]: if mask.any(): @@ -359,6 +361,17 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): assert np.allclose(esa[claims][refused], esa[declines][refused], atol=0.01), units # Claiming Pension Credit never raises income-related ESA. assert (esa[claims] <= esa[declines] + 0.01).all(), units + # Sch 1 para 6(1)(c) and (d): no family has both the couple's award and + # Pension Credit, whether or not it would claim. + assert not ((couple_esa > 0) & (pension_credit > 0)).any(), units + if units == ROWS: + # The third row is case 18: B(0) nil and B(e) 525.20, so the man + # keeps his award and gets no Pension Credit. + assert couple_esa[2] == pytest.approx(5_491.20, abs=0.01) + assert sim.calculate("pension_credit_entitlement", year)[2] == pytest.approx( + 525.20, abs=0.01 + ) + assert pension_credit[2] == 0 # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index b966270258..a8937b15a0 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -37,7 +37,8 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): (would_claim_pc). pension_credit equals the entitlement where the family would claim and - nil otherwise. The entitlement sums the guarantee and savings credits for + the couple has no income-related ESA (claimant_or_partner_esa_income), + and nil otherwise. The entitlement sums the guarantee and savings credits for a family eligible for Pension Credit (is_pension_credit_eligible), and gives nil to any other, and only a family that meets the age conditions is eligible. A parameter reform keeps these definitions, and the age @@ -90,11 +91,18 @@ def pension_credit_without_income_related_esa(benunit, period): the claim (would_claim_pc) and an entered award. Where that Pension Credit pays, the screen refuses the couple's award, and the simulation's own Pension Credit then equals the branch's. Where it pays - nothing, the model pays the award. If the award then makes Pension - Credit pay, through the higher earnings disregard, no consistent state - exists: with Pension Credit in payment the family could not have the - award, and without the award it would get no Pension Credit. The model - then pays the award beside the Pension Credit it works out on it. + nothing, the model pays the award, and pension_credit pays nothing while + the couple has it, even where the award would make Pension Credit pay + through the higher earnings disregard. With Pension Credit in payment the + award would stop, and with it the disregard of SPC Regs 2002 Sch VI para + 4(1)(a)(vii). Para 4(2) keeps £20 only where £20 of earnings was + disregarded in the award, and ESA Regs 2008 Sch 7 para 5 disregards a + claimant's permitted-work earnings up to the limit in whole, so such a + family would lose its Pension Credit and get the award back. + pension_credit_earnings_disregard does not apply para 4(2), and the model + holds no record of how an award started, so a couple whose award + disregarded £20 of a partner's part-time earnings (Sch 7 para 7(4)) gets + the award and no Pension Credit too. In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in @@ -148,9 +156,10 @@ class esa_income_eligible(Variable): "and would claim, so the screen works it out only for such a couple. DWP " "disregards £20 a week of earnings while the claimant or partner receives " "ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Where the family gets Pension " - "Credit only with that disregard, no consistent state exists, and the family " - "gets both. A member who claims in their own right keeps their claim whatever the " - "couple's Pension Credit. The claimant must not engage in remunerative work " + "Credit only with that disregard, the screen passes the couple's claim, and " + "pension_credit pays nothing while the couple has the award. A member who " + "claims in their own right keeps their claim whatever the couple's Pension " + "Credit. The claimant must not engage in remunerative work " "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " "24 hours a week or more (ESA Regs 2008 reg 42(1)), subject to the existing " diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index d949955407..29f3a4532e 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -7,12 +7,38 @@ class pension_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/contents" + documentation = ( + "State Pension Credit the family receives: its entitlement where it " + "would claim (would_claim_pc), and nil where the claimant or partner " + "has income-related ESA (claimant_or_partner_esa_income above nil). " + "Parliament allows income-related ESA only where neither the claimant " + "nor the other member of the couple has an entitlement to State " + "Pension Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), " + "so the two awards never meet in one couple. The ESA screen " + "(esa_income_eligible) refuses the couple's award where the family " + "would get Pension Credit without it. Where the family gets Pension " + "Credit only with the award, through the higher earnings disregard " + "for a claimant or partner in receipt of ESA (SPC Regs 2002 Sch VI " + "para 4(1)(a)(vii)), the model pays the award, and this formula " + "pays no Pension Credit. A member outside the couple who claims " + "income-related ESA in their own right leaves the couple's Pension " + "Credit unchanged: in " + "para 6(1)(d) Parliament named only the other member of the " + "claimant's couple. Under gov.contrib.freeze_pension_credit, a reform " + "pays the baseline's award." + ) + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/contents", + "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + ) def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - amount = entitlement * would_claim + # WRA 2007 Sch 1 para 6(1)(c) and (d): with the couple's + # income-related ESA in payment, the couple has no Pension Credit. + couple_esa = benunit("claimant_or_partner_esa_income", period) + amount = where(couple_esa > 0, 0, entitlement * would_claim) freeze = parameters(period).gov.contrib.freeze_pension_credit baseline = benunit.simulation.baseline From a1771bb2eef9a5dceec751897e0d053a18e5af2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:50:09 +0000 Subject: [PATCH 26/33] Check the outside reporter's row in the pension-age ESA property Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KPXgYTCw1EWx4JK8T --- .../tests/test_esa_income_pension_age_properties.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 0f185b03c8..5c17a3a7c8 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -372,6 +372,12 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): 525.20, abs=0.01 ) assert pension_credit[2] == 0 + # The fifth row is case 12's pairing: the member outside the couple + # keeps their 3,000 beside the couple's minimum guarantee, 52 x + # 155.60 = 8,091.20, whether or not the couple would claim. + assert esa[[4, n + 4]] == pytest.approx([3_000, 3_000], abs=0.01) + assert couple_esa[4] == 0 + assert pension_credit[4] == pytest.approx(8_091.20, abs=0.01) # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) From 419bccb447a8fbb005c2ade0304cad43046dad28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:16:28 +0000 Subject: [PATCH 27/33] Name every route to the ESA-only row and the other reading of para 4(2)(a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 26 takes a couple whose award could disregard £20 of the partner's earnings under ESA Regs 2008 Sch 7 para 6. The docstring and case 25 name paras 6 and 7, case 18 states the other reading of SPC Regs Sch VI para 4(2)(a), and the comments give DWP or the formula the verb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../esa_income/esa_income_pension_age.yaml | 138 ++++++++++++++---- .../variables/gov/dwp/esa_income_eligible.py | 12 +- .../gov/dwp/pension_credit/pension_credit.py | 4 +- 3 files changed, 118 insertions(+), 36 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index ae51c8dad0..c5365e5325 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -9,9 +9,10 @@ # with no income-related ESA in the family, B(0), and refuses the couple's # claim where B(0) exceeds nil. B(e) is the Pension Credit the family would # get with the award e. Where B(0) is nil, the model pays the couple's award -# and no Pension Credit, whatever B(e) (cases 18 and 25). A mixed-age couple has no entitlement to State Pension -# Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it -# keeps the SI 2019/37 art 4 saving. +# and no Pension Credit, whatever B(e) (cases 18, 25 and 26). A mixed-age +# couple has no entitlement to State Pension Credit from 15 May 2019 (State +# Pension Credit Act 2002 s.4(1A)) unless it keeps the SI 2019/37 art 4 +# saving. # Nobody here claims Universal Credit (would_claim_uc false), since a couple # that claims it loses its income-related ESA anyway. No case has capital, so # no tariff income: the model pays an award that passes at the amount @@ -632,21 +633,26 @@ # The model pays no Pension Credit beside it. Parliament allows the award # only while he "is not entitled to state pension credit" (WRA 2007 Sch 1 # para 6(1)(c)), so with Pension Credit in payment his ESA would stop, and - # with it the £20 that SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregards - # for a claimant "in receipt of ... employment and support allowance". - # Para 4(2) keeps the £20 after the award ends only where "£20 was - # disregarded in respect of earnings taken into account in that award". He - # works 15 hours a week, under the 16-hour limit, and his £115.50 does not - # exceed the 2016-17 permitted work limit of £115.50: 16 hours at the - # £7.20 national living wage, £115.20, which ESA Regs 2008 reg 45(9A) - # rounds up to the next 50p (esa_exempt_work_higher_earnings_limit, from + # with it the £20 that DWP disregards for a claimant "in receipt of ... + # employment and support allowance" (SPC Regs 2002 Sch VI para + # 4(1)(a)(vii)). DWP keeps disregarding the £20 after the award ends only + # where (para 4(2)) "£20 was disregarded in respect of earnings taken into + # account in that award". He works 15 hours a week, under the 16-hour + # limit, and his £115.50 does not exceed the 2016-17 permitted work limit + # of £115.50: 16 hours at the £7.20 national living wage, £115.20, which + # esa_exempt_work_higher_earnings_limit rounds up to the next 50p, citing + # ESA Regs 2008 reg 45(9A) (from # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and - # gov.hmrc.minimum_wage.non_apprentice). So - # ESA Regs 2008 Sch 7 para 5 disregards "any earnings derived from that - # work which do not exceed the limits": all of them, not £20. Para 4(2)(a) - # fails, his Pension Credit would fall back to B(0), nil, and his ESA - # would come back. ESA with no Pension Credit holds; Pension Credit with - # no ESA does not. + # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings + # derived from that work which do not exceed the limits" (ESA Regs 2008 + # Sch 7 para 5): all of them, not £20. He fails para 4(2)(a), his Pension + # Credit would fall back to B(0), nil, and his ESA would come back. ESA + # with no Pension Credit holds; Pension Credit with no ESA does not. + # On another reading, para 4(2)(a) holds wherever the award disregarded at + # least £20 of earnings, and he would then keep £525.20 of Pension Credit + # with no ESA. The model takes "£20 was disregarded" to mean the £20 + # disregard of Sch 7 paras 6 and 7(1), as para 5 disregards "any earnings + # ... which do not exceed the limits", not £20. esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -911,22 +917,98 @@ # 1_040 = 11_986, so 12_352.60 - 11_986 = 366.60, or 52 x 7.05. Neither # member has reached pensionable age, so no savings credit is due (SPCA # 2002 s.3(1)). - # Here the law may differ from the model. ESA Regs 2008 Sch 7 para 7(4) - # disregards £20 of earnings "in a case where the claimant's partner is in - # part-time employment and paragraph 6 does not apply". If DWP disregarded - # £20 of the woman's earnings in the man's award, SPC Regs 2002 Sch VI - # para 4(2) keeps the £20 where "£20 was disregarded in respect of - # earnings taken into account in that award" and "the person whose - # earnings qualified for the disegard continues in employment after the - # termination of that award". Once the couple claimed, it could then keep - # 366.60 of Pension Credit with no ESA. The model holds no record of how + # Here the law may differ from the model. DWP disregards £20 of earnings + # "in a case where the claimant's partner is in part-time employment and + # paragraph 6 does not apply" (ESA Regs 2008 Sch 7 para 7(4)). If DWP + # disregarded £20 of the woman's earnings in the man's award, DWP keeps + # disregarding the £20 (SPC Regs 2002 Sch VI para 4(2)) where "£20 was + # disregarded in respect of earnings taken into account in that award" + # and "the person whose earnings qualified for the disegard continues in + # employment after the termination of that award". Once the couple + # claimed, it could then keep 366.60 of Pension Credit with no ESA. The + # model holds no record of how an award started, and + # pension_credit_earnings_disregard does not apply para 4(2), so the model + # pays this couple the award and no Pension Credit, as in case 18. + # The same holds where DWP disregarded £20 of the partner's earnings + # because the claimant's permitted-work earnings fell short of the limit + # (Sch 7 para 6, case 26), or £20 of the claimant's own earnings (Sch 7 + # para 7(1) and (2)). + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 366.60 + pension_credit: 0 + +- name: Case 26, a couple whose award may count £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £90 a week for 15 hours, under the 16 hours and the £115.50 limit of + # permitted work (ESA Regs 2008 reg 45(4)). + employment_income: 4_680 + hours_worked: 780 + # £130 a week. + private_pension_income: 6_760 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £30 a week for 5 hours, under the 24 hours of para 6(1)(f) (ESA Regs + # 2008 reg 42(1)). + employment_income: 1_560 + hours_worked: 260 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 90 + 130 + 30 = 250 a week, 52 x 250 = 13_000. Neither member + # owes primary Class 1 contributions on £90 or £30 a week in 2016-17. His + # £11,440 of earnings and pension exceeds the £11,000 personal allowance + # by £440, and the model transfers £1,100 of her unused allowance to him + # (marriage_allowance, 10% of £11,000), so neither member owes income tax + # and the earnings count in full. + # B(0): 13_000 - 520 = 12_480, over 12_352.60, so nil. B(e): 13_000 - + # 1_040 = 11_960, so 12_352.60 - 11_960 = 392.60, or 52 x 7.55. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. His £90 falls £25.50 short of + # the £115.50 limit, so DWP disregards the woman's earnings "to the extent + # that the claimant's earnings are less than ... 16 x National Minimum + # Wage, but only up to a maximum of £20" (ESA Regs 2008 Sch 7 para 6): + # £20 of her £30. DWP keeps disregarding the £20 (SPC Regs 2002 Sch VI + # para 4(2)) where "£20 was disregarded in respect of earnings taken into + # account in that award" and "the person whose earnings qualified for the + # disegard continues in employment after the termination of that award". + # Once the couple claimed, it could then keep 392.60 of Pension Credit + # with no ESA while she stays in work. The model holds no record of how # an award started, and pension_credit_earnings_disregard does not apply # para 4(2), so the model pays this couple the award and no Pension - # Credit, as in case 18. + # Credit, as in cases 18 and 25. meets_pension_credit_age_conditions: true esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: [115.50, 115.50] + pension_credit_employment_income_tax: 0 pension_credit_earnings_disregard: 1_040 - pension_credit_entitlement: 366.60 + pension_credit_income: 11_960 + pension_credit_entitlement: 392.60 pension_credit: 0 diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index a8937b15a0..93714aa986 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -95,14 +95,14 @@ def pension_credit_without_income_related_esa(benunit, period): the couple has it, even where the award would make Pension Credit pay through the higher earnings disregard. With Pension Credit in payment the award would stop, and with it the disregard of SPC Regs 2002 Sch VI para - 4(1)(a)(vii). Para 4(2) keeps £20 only where £20 of earnings was - disregarded in the award, and ESA Regs 2008 Sch 7 para 5 disregards a - claimant's permitted-work earnings up to the limit in whole, so such a - family would lose its Pension Credit and get the award back. + 4(1)(a)(vii). DWP keeps disregarding £20 only where it disregarded £20 of + earnings in the award (para 4(2)), and it disregards all of a claimant's + permitted-work earnings up to the limit (ESA Regs 2008 Sch 7 para 5), so + such a family would lose its Pension Credit and get the award back. pension_credit_earnings_disregard does not apply para 4(2), and the model holds no record of how an award started, so a couple whose award - disregarded £20 of a partner's part-time earnings (Sch 7 para 7(4)) gets - the award and no Pension Credit too. + disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7) gets the + award and no Pension Credit too. In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index 29f3a4532e..acff1f9705 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -24,8 +24,8 @@ class pension_credit(Variable): "income-related ESA in their own right leaves the couple's Pension " "Credit unchanged: in " "para 6(1)(d) Parliament named only the other member of the " - "claimant's couple. Under gov.contrib.freeze_pension_credit, a reform " - "pays the baseline's award." + "claimant's couple. Under gov.contrib.freeze_pension_credit, the " + "formula returns the baseline's award." ) reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/contents", From d6bf2f6576a890fbfcd7042d501f5b839679f214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:16:28 +0000 Subject: [PATCH 28/33] Add case 18's man to every generated 2016 example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../tests/test_esa_income_pension_age_properties.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 5c17a3a7c8..2d64b03cba 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -172,6 +172,10 @@ def examples(draw): # appointee saving (art. 3A(2)), which keeps the award payable all year. saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) + # In 2016, add case 18's man, whom Pension Credit pays only with the + # award. + if year == 2016: + units = units + [ROWS[2]] return units, year From d0ffe7fd320c24b5bb070730ca50e2fc8c304a71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:21:06 +0000 Subject: [PATCH 29/33] Give DWP the verb where an appointee saving or a regulation acted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../tests/legacy_award_work_reference.py | 9 +-- .../esa_income/esa_income_pension_age.yaml | 61 ++++++++++--------- .../test_esa_income_pension_age_properties.py | 9 +-- 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 7c00330b0b..3ad35da3b0 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -231,10 +231,11 @@ def esa_abolition_claim(couple, parameters): The abolition reaches every remaining award on 1 July 2026 in England (SI 2025/1148 art. 3A), the households' default country, so it reaches - any award reported in a year with less than all of it payable. An - appointee saving (art. 3A(2), esa_income_appointee_saving) for a member - who reports the award keeps the whole claim payable, and a saving for a - member who reports nothing keeps nothing. would_claim_uc_at_legacy_closure + any award reported in a year with less than all of it payable. Where a + member who reports the award has an appointee saving + (esa_income_appointee_saving), DWP keeps the whole claim payable (art. + 3A(2)); where only a member who reports nothing has one, DWP keeps + nothing in payment. would_claim_uc_at_legacy_closure keeps its default, true. The conditions are those of the other working-age closures: no Pension Credit report, and not all pension age. """ diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index c5365e5325..b56711549f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -141,9 +141,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -181,9 +181,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -213,9 +213,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -391,9 +391,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -422,17 +422,17 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -464,18 +464,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -651,8 +651,8 @@ # On another reading, para 4(2)(a) holds wherever the award disregarded at # least £20 of earnings, and he would then keep £525.20 of Pension Credit # with no ESA. The model takes "£20 was disregarded" to mean the £20 - # disregard of Sch 7 paras 6 and 7(1), as para 5 disregards "any earnings - # ... which do not exceed the limits", not £20. + # disregard of Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any + # earnings ... which do not exceed the limits", not £20. esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -696,10 +696,11 @@ # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts # as Pension Credit income (SPCA 2002 s.15(1)(b)). # B(e): with the award HMRC pays working tax credit without its income - # test (TCA 2002 s.7(2)), so working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over - # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs - # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the - # £212.97 couple threshold: nil. + # test (TCA 2002 s.7(2)), so working tax credit comes to 3_970, and + # 10_000 + 3_970 - 1_040 = 12_930, over 12_352.60: nil. DWP counts no + # working tax credit in the savings credit (SPC Regs 2002 reg 9(a)): + # 10_000 - 1_040 = 8_960, 172.31 a week, under the £212.97 couple + # threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 2d64b03cba..9becee7bb7 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -41,9 +41,9 @@ (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. The Secretary of State appointed 1 July 2026 for the abolition of income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), -and an appointee saving keeps an award in payment (art. 3A(2)), so every -adult drawn in 2026 has the saving (esa_income_appointee_saving) and each -award stays payable for the whole year; +and DWP keeps an award saved for an appointee in payment (art. 3A(2)), so +every adult drawn in 2026 has the saving (esa_income_appointee_saving) and +each award stays payable for the whole year; test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report @@ -169,7 +169,8 @@ def examples(draw): tax_credits = year < 2025 # The abolition of income-related ESA reaches unsaved awards from 1 July # 2026 (SI 2025/1148 art. 3A(1)), so every adult drawn in 2026 has the - # appointee saving (art. 3A(2)), which keeps the award payable all year. + # appointee saving, under which DWP keeps the award payable all year + # (art. 3A(2)). saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) # In 2016, add case 18's man, whom Pension Credit pays only with the From 6cd70815ab7f2c7340408b96bdd8a34a543e01a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:04:18 +0000 Subject: [PATCH 30/33] Add case 26's couple and a 2018 example to the pension-age ESA properties examples() now adds case 26's couple (COUPLE_ROW) beside case 18's man to every generated 2016 example, so a couple with Pension Credit only with the award reaches the combined property on every run. Both the combined property and the differential also run an explicit 2018 example (ROWS_2018): case 19's working tax credit couple and case 20's couple. The couple is appended after the draws, so every drawn family stays the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014eCgZmDCqK2orSpEYYaPgu --- .../test_esa_income_pension_age_properties.py | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 9becee7bb7..d7c828356f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -173,10 +173,10 @@ def examples(draw): # (art. 3A(2)). saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) - # In 2016, add case 18's man, whom Pension Credit pays only with the - # award. + # In 2016, add case 18's man and case 26's couple, who get Pension Credit + # only with the award. if year == 2016: - units = units + [ROWS[2]] + units = units + [ROWS[2], COUPLE_ROW] return units, year @@ -309,6 +309,73 @@ def couple_reports(sim, year): ), ] +# Case 26's couple: the man of case 18 earning £90 a week in 15 hours with a +# £130 weekly pension, and a woman aged 60 earning £30 a week in 5 hours. +COUPLE_ROW = ( + [ + dict( + MAN, + esa_income_reported=5_491.20, + employment_income=4_680, + hours_worked=780, + private_pension_income=6_760, + ), + { + "age": 60, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 260, + "employment_income": 1_560, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, +) + +# Two couples in 2018, a year with tax credits and before the exclusion of +# mixed-age couples. Case 19's couple (ROWS[3]) gets Pension Credit with or +# without the award that year. In case 20's couple, a man aged 66 has a £200 +# weekly pension and a man aged 58 reports support-group income-related ESA +# and earns £124 a week in 15 hours, and the couple gets no Pension Credit +# with or without the award. +ROWS_2018 = [ + ROWS[3], + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 10_400, + "working_tax_credit_reported": 0, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 2_537.60, + "esa_includes_support_component": True, + "hours_worked": 780, + "employment_income": 6_448, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), +] + @settings( max_examples=4, @@ -318,6 +385,7 @@ def couple_reports(sim, year): ) @given(examples()) @example((ROWS, 2016)) +@example((ROWS_2018, 2018)) def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): units, year = drawn n = len(units) @@ -687,6 +755,7 @@ def formula(benunit, period, parameters): ) @given(examples()) @example((ROWS, 2016)) +@example((ROWS_2018, 2018)) def test_reading_pension_credit_for_every_couple_matches_the_model(drawn): """Differential: a reform that calculates the model's Pension Credit by a formula of its own makes the screen read Pension Credit without the award From 0822532e4a97b23d3b53139073f2f4d52ca78f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:12:28 +0000 Subject: [PATCH 31/33] Say which reading of para 4(2)(a) the expected values take, and give DWP the verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 18's comment and the docstring of pension_credit_without_income_related_esa now say which reading of SPC Regs 2002 Sch VI para 4(2)(a) the expected values or the model's result agree with, since no code applies para 4(2), and give the other reading with para 4(2)(b)'s condition. Para 5's limit makes "not £20" "not a fixed £20". The docstring also names pension_credit's own read of the award. Cases 12, 15, 16, 18, 19, 20, 25 and 26 get names in which DWP, HMRC or the couple acts, and case 26's name puts para 6 the right way round. Case 26's comments compare his earnings with 16 x £7.20 = £115.20, name the model's marriage allowance defaults, and cite WRA 2007 Sch 1 para 6(1)(f), as case 25's does. Case 19 limits reg 9(a) to the savings credit's qualifying income. The appointee-saving comments name the claimant's saving, and esa_abolition_claim's docstring says it ignores a saving held only by a member who reports nothing. Comments in this PR's other test files give DWP the verb where Pension Credit acted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014eCgZmDCqK2orSpEYYaPgu --- .../tests/legacy_award_work_reference.py | 21 +-- .../esa_income/esa_income_pension_age.yaml | 136 ++++++++++-------- .../test_esa_income_pension_age_properties.py | 34 ++--- ...t_income_support_eligibility_properties.py | 5 +- ...t_gate_reads_claimant_or_partner_awards.py | 7 +- .../variables/gov/dwp/esa_income_eligible.py | 48 ++++--- 6 files changed, 137 insertions(+), 114 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 3ad35da3b0..e63ecfafab 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -35,16 +35,16 @@ - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would - claim, and the Pension Credit it would get with no income-related ESA in - the family pays. Otherwise no member reports Pension Credit or Housing - Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the + claim, and it would get Pension Credit with no income-related ESA in the + family. Otherwise no member reports Pension Credit or Housing Benefit, so + no mixed-age couple keeps the SI 2019/37 art 4 saving and the exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 s.1(6)), so each of them has reached pensionable age and has no claim - under s.1(3)(c), whether or not Pension Credit pays. A member outside the - couple keeps their own claim even when the couple has an entitlement to - Pension Credit. + under s.1(3)(c), whether or not DWP pays the couple Pension Credit. A + member outside the couple keeps their own claim even when the couple has + an entitlement to Pension Credit. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -234,10 +234,11 @@ def esa_abolition_claim(couple, parameters): any award reported in a year with less than all of it payable. Where a member who reports the award has an appointee saving (esa_income_appointee_saving), DWP keeps the whole claim payable (art. - 3A(2)); where only a member who reports nothing has one, DWP keeps - nothing in payment. would_claim_uc_at_legacy_closure - keeps its default, true. The conditions are those of the other - working-age closures: no Pension Credit report, and not all pension age. + 3A(2)); like esa_income_payable_share, this function ignores a saving + held only by a member who reports nothing. + would_claim_uc_at_legacy_closure keeps its default, true. The conditions + are those of the other working-age closures: no Pension Credit report, + and not all pension age. """ payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain reporters = [m for m in couple if m.get("esa_income_reported", 0) > 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index b56711549f..dadc199e8f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -141,9 +141,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -181,9 +182,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -213,9 +215,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -378,7 +381,7 @@ claimant_or_partner_esa_income: 0 is_on_income_related_esa: [false, false] -- name: Case 12, the couple's Pension Credit does not bar a member outside the couple who claims income-related ESA in their own right. +- name: Case 12, a member outside the couple who claims income-related ESA in their own right keeps the award beside the couple's Pension Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -391,9 +394,10 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -422,17 +426,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -464,18 +469,19 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -508,7 +514,7 @@ # threshold (£155 a week), so he owes no income tax or primary Class 1 # contributions. -- name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. +- name: Case 15, where DWP would pay Pension Credit with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -541,7 +547,7 @@ pension_credit: 3_671.20 pension_credit_earnings_disregard: 260 -- name: Case 16, where Pension Credit pays nothing with or without the award, the man keeps income-related ESA. +- name: Case 16, where DWP would pay no Pension Credit with or without the award, the man keeps income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -601,7 +607,7 @@ pension_credit: 0 esa_income: 4_919.20 -- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets no Pension Credit. +- name: Case 18, where DWP would pay Pension Credit only with the award, the man keeps income-related ESA and gets no Pension Credit. period: 2016 absolute_error_margin: 0.01 input: @@ -645,14 +651,15 @@ # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings # derived from that work which do not exceed the limits" (ESA Regs 2008 - # Sch 7 para 5): all of them, not £20. He fails para 4(2)(a), his Pension - # Credit would fall back to B(0), nil, and his ESA would come back. ESA - # with no Pension Credit holds; Pension Credit with no ESA does not. - # On another reading, para 4(2)(a) holds wherever the award disregarded at - # least £20 of earnings, and he would then keep £525.20 of Pension Credit - # with no ESA. The model takes "£20 was disregarded" to mean the £20 - # disregard of Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any - # earnings ... which do not exceed the limits", not £20. + # Sch 7 para 5): all of them, not £20. The expected values take "£20 was + # disregarded" to mean the £20 disregard of Sch 7 paras 6 and 7(1), as + # under para 5 DWP disregards "any earnings ... which do not exceed the + # limits", not a fixed £20. On that reading he fails para 4(2)(a), his + # Pension Credit would fall back to B(0), nil, and his ESA would come back, + # so ESA with no Pension Credit holds and Pension Credit with no ESA does + # not. On another reading, para 4(2)(a) holds wherever DWP disregarded at + # least £20 of earnings in the award, and he would then keep £525.20 of + # Pension Credit with no ESA while he stays in employment (para 4(2)(b)). esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -661,7 +668,7 @@ pension_credit_entitlement: 525.20 pension_credit: 0 -- name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. +- name: Case 19, a couple aged 66 and 58 to whom DWP would pay Pension Credit only without the award, since HMRC then applies working tax credit's income test, get Pension Credit and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -698,9 +705,9 @@ # B(e): with the award HMRC pays working tax credit without its income # test (TCA 2002 s.7(2)), so working tax credit comes to 3_970, and # 10_000 + 3_970 - 1_040 = 12_930, over 12_352.60: nil. DWP counts no - # working tax credit in the savings credit (SPC Regs 2002 reg 9(a)): - # 10_000 - 1_040 = 8_960, 172.31 a week, under the £212.97 couple - # threshold: nil. + # working tax credit in the savings credit's qualifying income (SPC Regs + # 2002 reg 9(a); savings_credit_income): 10_000 - 1_040 = 8_960, 172.31 a + # week, under the £212.97 couple threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - @@ -712,7 +719,7 @@ working_tax_credit: 2_502.20 pension_credit: 370.40 -- name: Case 20, in 2018 a couple aged 66 and 58 whose income Pension Credit refuses keep support-group ESA and both benefit cap exceptions. +- name: Case 20, in 2018 a couple aged 66 and 58 to whom DWP would pay no Pension Credit on their income keep support-group ESA and both benefit cap exceptions. period: 2018 absolute_error_margin: 0.01 input: @@ -875,7 +882,7 @@ esa_income_eligible: true esa_income: 5_000 -- name: Case 25, a couple whose award may count the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. +- name: Case 25, a couple in whose award DWP may disregard £20 of the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. period: 2016 absolute_error_margin: 0.01 input: @@ -894,8 +901,8 @@ date_of_birth: 19560301 gender: FEMALE is_claimant_or_partner: true - # £115.50 a week for 15 hours, under the 24 hours of para 6(1)(f) - # (ESA Regs 2008 reg 42(1)). + # £115.50 a week for 15 hours, under the 24 hours of WRA 2007 Sch 1 + # para 6(1)(f) (ESA Regs 2008 reg 42(1)). employment_income: 6_006 hours_worked: 780 benunits: @@ -942,7 +949,7 @@ pension_credit_entitlement: 366.60 pension_credit: 0 -- name: Case 26, a couple whose award may count £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. +- name: Case 26, a couple in whose award DWP may disregard £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. period: 2016 absolute_error_margin: 0.01 input: @@ -965,8 +972,8 @@ date_of_birth: 19560301 gender: FEMALE is_claimant_or_partner: true - # £30 a week for 5 hours, under the 24 hours of para 6(1)(f) (ESA Regs - # 2008 reg 42(1)). + # £30 a week for 5 hours, under the 24 hours of WRA 2007 Sch 1 para + # 6(1)(f) (ESA Regs 2008 reg 42(1)). employment_income: 1_560 hours_worked: 260 benunits: @@ -983,26 +990,29 @@ # Income: 90 + 130 + 30 = 250 a week, 52 x 250 = 13_000. Neither member # owes primary Class 1 contributions on £90 or £30 a week in 2016-17. His # £11,440 of earnings and pension exceeds the £11,000 personal allowance - # by £440, and the model transfers £1,100 of her unused allowance to him - # (marriage_allowance, 10% of £11,000), so neither member owes income tax - # and the earnings count in full. + # by £440. The model takes the couple to be married (is_married) and to + # claim the marriage allowance (would_claim_marriage_allowance), so it + # transfers £1,100 of her unused allowance to him (marriage_allowance, 10% + # of £11,000) and neither member owes income tax or contributions on the + # earnings. Without the transfer he would owe £88 and B(e) would come to + # 480.60. # B(0): 13_000 - 520 = 12_480, over 12_352.60, so nil. B(e): 13_000 - # 1_040 = 11_960, so 12_352.60 - 11_960 = 392.60, or 52 x 7.55. Neither # member has reached pensionable age, so no savings credit is due (SPCA # 2002 s.3(1)). - # Here the law may differ from the model. His £90 falls £25.50 short of - # the £115.50 limit, so DWP disregards the woman's earnings "to the extent - # that the claimant's earnings are less than ... 16 x National Minimum - # Wage, but only up to a maximum of £20" (ESA Regs 2008 Sch 7 para 6): - # £20 of her £30. DWP keeps disregarding the £20 (SPC Regs 2002 Sch VI - # para 4(2)) where "£20 was disregarded in respect of earnings taken into - # account in that award" and "the person whose earnings qualified for the - # disegard continues in employment after the termination of that award". - # Once the couple claimed, it could then keep 392.60 of Pension Credit - # with no ESA while she stays in work. The model holds no record of how - # an award started, and pension_credit_earnings_disregard does not apply - # para 4(2), so the model pays this couple the award and no Pension - # Credit, as in cases 18 and 25. + # Here the law may differ from the model. His £90 falls more than £20 + # short of 16 x £7.20 = £115.20, so DWP disregards the woman's earnings + # "to the extent that the claimant's earnings are less than ... 16 x + # National Minimum Wage, but only up to a maximum of £20" (ESA Regs 2008 + # Sch 7 para 6): £20 of her £30. DWP keeps disregarding the £20 (SPC Regs + # 2002 Sch VI para 4(2)) where "£20 was disregarded in respect of earnings + # taken into account in that award" and "the person whose earnings + # qualified for the disegard continues in employment after the termination + # of that award". Once the couple claimed, it could then keep £392.60 of + # Pension Credit with no ESA while she stays in work. The model holds no + # record of how an award started, and pension_credit_earnings_disregard + # does not apply para 4(2), so the model pays this couple the award and + # no Pension Credit, as in cases 18 and 25. meets_pension_credit_age_conditions: true esa_income_eligible: true esa_income: 5_491.20 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index d7c828356f..b1f7fe9ea9 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -41,9 +41,9 @@ (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. The Secretary of State appointed 1 July 2026 for the abolition of income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), -and DWP keeps an award saved for an appointee in payment (art. 3A(2)), so -every adult drawn in 2026 has the saving (esa_income_appointee_saving) and -each award stays payable for the whole year; +and under the claimant's appointee saving DWP keeps the award in payment +(art. 3A(2)), so every adult drawn in 2026 has the saving +(esa_income_appointee_saving) and each award stays payable for the whole year; test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report @@ -421,8 +421,9 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): if mask.any(): event(label) - # Sch 1 para 6(1)(c) and (d): where Pension Credit pays without the award, - # the couple's award is nil and the family gets that Pension Credit. + # Sch 1 para 6(1)(c) and (d): where the family would get Pension Credit + # without the award, the couple's award is nil and the family gets that + # Pension Credit. assert (couple_esa[claims][barred] == 0).all(), units assert np.allclose( pension_credit[claims][barred], without_award[barred], atol=0.01 @@ -486,8 +487,8 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): start += len(members) -# An appointee saving for income-related ESA (SI 2025/1148 art. 3A(2)), which -# keeps the award payable after the abolition of 1 July 2026. +# The appointee saving for income-related ESA, under which DWP keeps the award +# in payment after the abolition of 1 July 2026 (SI 2025/1148 art. 3A(2)). SAVED = {"esa_income_appointee_saving": True} @@ -553,10 +554,10 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside a pensioner, and a pensioner reporting the award, who has reached - pensionable age and so has no claim whatever Pension Credit pays. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the - abolition of income-related ESA on 1 July 2026 leaves each award payable - for the whole year.""" + pensionable age and so has no claim whatever the couple's Pension + Credit. Each reporter has the appointee saving (SI 2025/1148 art. 3A(2)), + so DWP keeps each award in payment after the abolition of income-related + ESA on 1 July 2026, and each award stays payable for the whole year.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -789,9 +790,9 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets them, and the model opens no branch, under the reform or without it. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the - abolition of income-related ESA on 1 July 2026 leaves each award payable - for the whole year.""" + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so DWP keeps + each award in payment after the abolition of income-related ESA on 1 July + 2026, and each award stays payable for the whole year.""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -1225,8 +1226,9 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of work passes, and the screen reads both. Each reporter has the appointee - saving (SI 2025/1148 art. 3A(2)), so the abolition of income-related ESA - on 1 July 2026 leaves each report open for the whole year.""" + saving (SI 2025/1148 art. 3A(2)), so DWP keeps each award in payment + after the abolition of income-related ESA on 1 July 2026, and each report + stays open for the whole year.""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 3d41cc4c07..9556084c54 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -528,8 +528,9 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) - # Entitlement for the ESA screen: Pension Credit pays. Without earnings - # or tax credits, it pays the same with the couple's award as without. + # Entitlement for the ESA screen: the couple would get Pension Credit. + # Without earnings or tax credits, DWP would pay the same Pension Credit + # with the couple's award as without. entitled = on_pension_credit & (sim.calculate("pension_credit", YEAR) > 0) pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index f5cd9310c2..05f1fd1794 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -81,9 +81,10 @@ def reference_report_awards(benunit, period, award): screen = reference.esa_screen if is_esa else reference.jsa_screen if is_esa: # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension - # Credit and it pays. No one here has earnings, and in 2025 no one has - # tax credits, so Pension Credit pays the same with the couple's award - # as without. An outside member keeps their own claim whatever it pays. + # Credit and DWP would pay it. No one here has earnings, and in 2025 no + # one has tax credits, so DWP would pay the same Pension Credit with + # the couple's award as without. An outside member keeps their own + # claim whatever the couple's Pension Credit. on_pension_credit = ( benunit("meets_pension_credit_age_conditions", period) & benunit("would_claim_pc", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 93714aa986..5acdcb0986 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -75,11 +75,13 @@ def pension_credit_without_income_related_esa(benunit, period): Parliament bars the couple's income-related ESA while the claimant, or the other member of the couple, has an entitlement to State Pension Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), and the - model's Pension Credit reads that award. Its higher earnings disregard - counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 - Sch VI para 4(1)(a)(vii)). It counts working tax credit as income, and - the tax credit income test reads the award (TCA 2002 s.7(2)), as does - Income Support's gate, which that test reads. Its route test + model's Pension Credit reads that award in five ways. pension_credit + itself pays nothing while the couple has the award + (claimant_or_partner_esa_income). pension_credit_earnings_disregard reads + the couple's award for the higher earnings disregard (SPC Regs 2002 Sch + VI para 4(1)(a)(vii)). pension_credit_income counts working tax credit, + and the tax credit income test reads the award (TCA 2002 s.7(2)), as + does Income Support's gate, which that test reads. The route test (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income (has_own_esa_award). A formula that read Pension Credit in the @@ -88,21 +90,27 @@ def pension_credit_without_income_related_esa(benunit, period): This function calculates Pension Credit in a branch that sets esa_income, the couple's award and esa_income_eligible to nil, so the screen never runs there. The branch reads pension_credit, so it counts - the claim (would_claim_pc) and an entered award. Where that Pension - Credit pays, the screen refuses the couple's award, and the - simulation's own Pension Credit then equals the branch's. Where it pays - nothing, the model pays the award, and pension_credit pays nothing while - the couple has it, even where the award would make Pension Credit pay - through the higher earnings disregard. With Pension Credit in payment the - award would stop, and with it the disregard of SPC Regs 2002 Sch VI para - 4(1)(a)(vii). DWP keeps disregarding £20 only where it disregarded £20 of - earnings in the award (para 4(2)), and it disregards all of a claimant's - permitted-work earnings up to the limit (ESA Regs 2008 Sch 7 para 5), so - such a family would lose its Pension Credit and get the award back. - pension_credit_earnings_disregard does not apply para 4(2), and the model - holds no record of how an award started, so a couple whose award - disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7) gets the - award and no Pension Credit too. + the claim (would_claim_pc) and an entered award. Where the branch's + Pension Credit exceeds nil, the screen refuses the couple's award, and + the simulation's own Pension Credit then equals the branch's. Where it + comes to nil, the model pays the award, and pension_credit pays nothing + while the couple has it, even where the family would get Pension Credit + with the award through the higher earnings disregard. With Pension + Credit in payment the award would stop, and with it the disregard of SPC + Regs 2002 Sch VI para 4(1)(a)(vii). DWP keeps disregarding £20 only + where it disregarded £20 of earnings in the award (para 4(2)), and it + disregards all of a claimant's permitted-work earnings up to the limit + (ESA Regs 2008 Sch 7 para 5). For a family whose only earnings are + those, the model's result agrees with the reading of para 4(2)(a) under + which "£20 was disregarded" means the £20 disregard of Sch 7 paras 6 and + 7(1): the family would lose its Pension Credit and get the award back. + On the reading under which any disregard of at least £20 meets para + 4(2)(a), DWP could keep that family on Pension Credit with no award. + pension_credit_earnings_disregard does not apply para 4(2), and the + model holds no record of how an award started, so the model also pays + the award and no Pension Credit to a couple in whose award DWP + disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7), though + DWP could keep that couple on Pension Credit with no award (para 4(2)). In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in From 44ea4c161eef9bb2130281efce280a8f41c5eda1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:41:08 +0000 Subject: [PATCH 32/33] Say which reading case 18's values agree with, and turn pension_credit's guard comment round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 18's comment now says which reading of SPC Regs 2002 Sch VI para 4(2)(a) the expected values agree with, as the docstring of pension_credit_without_income_related_esa says which reading the model's result agrees with. The comment on pension_credit's guard cites WRA 2007 Sch 1 para 6(1)(c) and (d) for the award depending on the absence of Pension Credit, not the reverse. Case 25 writes £366.60 with its pound sign, as case 26 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwxAaGSkVVvGDuMZzZR --- .../esa_income/esa_income_pension_age.yaml | 21 ++++++++++--------- .../gov/dwp/pension_credit/pension_credit.py | 6 ++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index dadc199e8f..e776243db7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -651,15 +651,16 @@ # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings # derived from that work which do not exceed the limits" (ESA Regs 2008 - # Sch 7 para 5): all of them, not £20. The expected values take "£20 was - # disregarded" to mean the £20 disregard of Sch 7 paras 6 and 7(1), as - # under para 5 DWP disregards "any earnings ... which do not exceed the - # limits", not a fixed £20. On that reading he fails para 4(2)(a), his - # Pension Credit would fall back to B(0), nil, and his ESA would come back, - # so ESA with no Pension Credit holds and Pension Credit with no ESA does - # not. On another reading, para 4(2)(a) holds wherever DWP disregarded at - # least £20 of earnings in the award, and he would then keep £525.20 of - # Pension Credit with no ESA while he stays in employment (para 4(2)(b)). + # Sch 7 para 5): all of them, not £20. The expected values agree with the + # reading under which "£20 was disregarded" means the £20 disregard of + # Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any earnings ... + # which do not exceed the limits", not a fixed £20. On that reading he + # fails para 4(2)(a), his Pension Credit would fall back to B(0), nil, and + # his ESA would come back, so ESA with no Pension Credit holds and Pension + # Credit with no ESA does not. On another reading, para 4(2)(a) holds + # wherever DWP disregarded at least £20 of earnings in the award, and he + # would then keep £525.20 of Pension Credit with no ESA while he stays in + # employment (para 4(2)(b)). esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -933,7 +934,7 @@ # disregarded in respect of earnings taken into account in that award" # and "the person whose earnings qualified for the disegard continues in # employment after the termination of that award". Once the couple - # claimed, it could then keep 366.60 of Pension Credit with no ESA. The + # claimed, it could then keep £366.60 of Pension Credit with no ESA. The # model holds no record of how an award started, and # pension_credit_earnings_disregard does not apply para 4(2), so the model # pays this couple the award and no Pension Credit, as in case 18. diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index acff1f9705..3611dd82a1 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -35,8 +35,10 @@ class pension_credit(Variable): def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - # WRA 2007 Sch 1 para 6(1)(c) and (d): with the couple's - # income-related ESA in payment, the couple has no Pension Credit. + # The formula pays nothing while the couple has income-related ESA, + # so that no couple gets both: Parliament allows the award only where + # neither member of the couple is entitled to State Pension Credit + # (WRA 2007 Sch 1 para 6(1)(c) and (d)). couple_esa = benunit("claimant_or_partner_esa_income", period) amount = where(couple_esa > 0, 0, entitlement * would_claim) From 741054b167a9f060d56eba9c170227ec9a05e281 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:41:55 +0000 Subject: [PATCH 33/33] Give DWP or the formula the verb in the PR's remaining comments, and scope two readings Case 19's B(0) comment, case 23's guarantee credit comment, the docstring of couple_on_pension_credit and a comment in the combined property no longer give Pension Credit the verb. Three property docstrings say the model keeps each saved report open for 2026-27, since some of their reporters have no claim. The docstring of pension_credit_without_income_related_esa gives the other reading of para 4(2)(a) with DWP as the actor and with para 4(2)(b)'s condition, and the comment on pension_credit's guard says "has an entitlement". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwxAaGSkVVvGDuMZzZR --- .../tests/legacy_award_work_reference.py | 4 ++-- .../esa_income/esa_income_pension_age.yaml | 15 ++++++++------- .../test_esa_income_pension_age_properties.py | 18 +++++++++--------- .../variables/gov/dwp/esa_income_eligible.py | 6 ++++-- .../gov/dwp/pension_credit/pension_credit.py | 4 ++-- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index e63ecfafab..f9f85076b6 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -185,8 +185,8 @@ def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing Benefit reports in 2025 or 2026: a couple every member of which has reached the qualifying age, each of whom has also reached pensionable - age and so has no claim under s.1(3)(c), whether or not Pension Credit - pays.""" + age and so has no claim under s.1(3)(c), whether or not DWP pays the + couple Pension Credit.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index e776243db7..6987ebea0d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -709,11 +709,12 @@ # working tax credit in the savings credit's qualifying income (SPC Regs # 2002 reg 9(a); savings_credit_income): 10_000 - 1_040 = 8_960, 172.31 a # week, under the £212.97 couple threshold: nil. - # B(0): Pension Credit on 3_970 would be nil too, so it gives no - # passport. The income test applies: 41% of income over £6,420 comes off, - # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - - # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) exceeds nil, so - # the younger member fails para 6(1)(d), and the couple gets B(0). + # B(0): the couple would get no Pension Credit on 3_970 either, so it has + # no passport, and HMRC applies the income test: 41% of income over £6,420 + # comes off, 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + + # 2_502.20 - 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) + # exceeds nil, so the younger member fails para 6(1)(d), and the couple + # gets B(0). esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -848,8 +849,8 @@ members: [person1] output: meets_pension_credit_age_conditions: false - # Without the award his income is nil, so the guarantee credit pays the - # minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg + # Without the award his income is nil, so his guarantee credit comes to + # the minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg # 6(1)(b)). Under 65 he gets no savings credit. guarantee_credit: 8_091.20 pension_credit: 8_091.20 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index b1f7fe9ea9..9331151624 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -428,7 +428,8 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): assert np.allclose( pension_credit[claims][barred], without_award[barred], atol=0.01 ), units - # Where it pays nothing, take-up changes no award. + # Where the family would get no Pension Credit without the award, take-up + # changes no award. assert np.allclose( couple_esa[claims][refused], couple_esa[declines][refused], atol=0.01 ), units @@ -556,8 +557,8 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( a pensioner, and a pensioner reporting the award, who has reached pensionable age and so has no claim whatever the couple's Pension Credit. Each reporter has the appointee saving (SI 2025/1148 art. 3A(2)), - so DWP keeps each award in payment after the abolition of income-related - ESA on 1 July 2026, and each award stays payable for the whole year.""" + so the model keeps each report open for the whole of 2026-27 + (esa_income_payable_share is 1).""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -790,9 +791,9 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets them, and the model opens no branch, under the reform or without it. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so DWP keeps - each award in payment after the abolition of income-related ESA on 1 July - 2026, and each award stays payable for the whole year.""" + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the model + keeps each report open for the whole of 2026-27 (esa_income_payable_share + is 1).""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -1226,9 +1227,8 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of work passes, and the screen reads both. Each reporter has the appointee - saving (SI 2025/1148 art. 3A(2)), so DWP keeps each award in payment - after the abolition of income-related ESA on 1 July 2026, and each report - stays open for the whole year.""" + saving (SI 2025/1148 art. 3A(2)), so the model keeps each report open for + the whole of 2026-27 (esa_income_payable_share is 1).""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 5acdcb0986..9c00f34dc1 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -104,8 +104,10 @@ def pension_credit_without_income_related_esa(benunit, period): those, the model's result agrees with the reading of para 4(2)(a) under which "£20 was disregarded" means the £20 disregard of Sch 7 paras 6 and 7(1): the family would lose its Pension Credit and get the award back. - On the reading under which any disregard of at least £20 meets para - 4(2)(a), DWP could keep that family on Pension Credit with no award. + On the reading under which para 4(2)(a) holds wherever DWP disregarded + at least £20 of earnings in the award, DWP could keep such a family on + Pension Credit with no award where it disregarded at least £20 of those + earnings and the earner stays in employment (para 4(2)(b)). pension_credit_earnings_disregard does not apply para 4(2), and the model holds no record of how an award started, so the model also pays the award and no Pension Credit to a couple in whose award DWP diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index 3611dd82a1..97295a64ff 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -37,8 +37,8 @@ def formula(benunit, period, parameters): would_claim = benunit("would_claim_pc", period) # The formula pays nothing while the couple has income-related ESA, # so that no couple gets both: Parliament allows the award only where - # neither member of the couple is entitled to State Pension Credit - # (WRA 2007 Sch 1 para 6(1)(c) and (d)). + # neither member of the couple has an entitlement to State Pension + # Credit (WRA 2007 Sch 1 para 6(1)(c) and (d)). couple_esa = benunit("claimant_or_partner_esa_income", period) amount = where(couple_esa > 0, 0, entitlement * would_claim)