From 973cfbaaef3e82dfad2a47d7dbb93b93381dc710 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:28:43 -0400 Subject: [PATCH 01/12] Apply the Income Support conditions to the claimant and partner only SSCBA 1992 s.124(1) sets each Income Support condition for the claimant and, in paras (c), (f), (g) and (h), for the other member of a couple. income_support_eligible applied three of them to every benefit-unit member: - (aa) no member could be over state pension age, so an excluded elderly adult barred a caring claimant; - (h) any member's income-related ESA barred the claim (esa_income sums every member's reported award); - any member's reported Income Support counted as an existing award. A couple choose which of them claims (Claims and Payments Regs 1987 reg 4(3)), so the benefit unit is now eligible when the claimant or the partner could claim: under state pension age, in a prescribed category (carer, or lone parent of a young child) and with no ESA of either kind. Neither may have income-related ESA after the ESA capital test, and one of them must report an existing award (UC (TP) Regs 2014 reg 6A(1)). The fix is at the gate, not in esa_income's summation: esa_income is also the benefit unit's income-related ESA income for household income, HBAI, government spending and the benefit cap, where an excluded member's own award is still real income. An award entered directly as esa_income, with no reported awards, is taken to be the claimant's or partner's. Two behaviour changes follow from reading the conditions as the law names them: a mixed-age couple can claim through the younger partner (s.124(1)(g) bars it only if the partner is entitled to State Pension Credit, which SPCA 2002 s.4(1A) rules out), and the claimant's own contributory ESA now bars the claim (s.124(1)(h)). Tests: 17 YAML cases (8 fail on the previous formula), a Hypothesis property that adding an excluded member never changes eligibility, and a differential test against a direct per-family reading of s.124(1). Both properties fail on the previous formula. Co-Authored-By: Claude Opus 5.5 --- ...income_support_claimant_partner_gates.yaml | 381 ++++++++++++++++++ ...t_income_support_eligibility_properties.py | 197 +++++++++ .../gov/dwp/income_support_eligible.py | 99 ++++- 3 files changed, 655 insertions(+), 22 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml create mode 100644 policyengine_uk/tests/test_income_support_eligibility_properties.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml new file mode 100644 index 000000000..b168802fb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml @@ -0,0 +1,381 @@ +# Income Support conditions name the claimant and, for some, the partner. +# SSCBA 1992 s.124(1): a person is entitled to Income Support if +# (aa) "he has not attained the qualifying age for state pension credit"; +# (e) "he falls within a prescribed category of person" (reg 4ZA, Sch 1B: +# para 1 lone parent of a child under 5, para 4 carer); +# (g) "if he is a member of a couple, the other member of the couple is not +# entitled to state pension credit"; and +# (h) "he is not entitled to an employment and support allowance and, if he +# is a member of a couple, the other member of the couple is not entitled +# to an income-related employment and support allowance". +# A couple choose which of them claims (Claims and Payments Regs 1987 +# reg 4(3)), so the benefit unit is eligible when either partner could claim. +# No new claims can be made (UC (Transitional Provisions) Regs 2014 +# reg 6A(1)), so the claimant or partner must have an existing award. +# +# A member who is neither the claimant, the partner nor a child or young +# person they are responsible for (here an adult given +# is_claimant_or_partner: false) is named by none of these conditions, so it +# never changes eligibility. 2025 is used because the model pays Income +# Support until 2025-26; state pension age is 66 in 2025. + +- name: An excluded member over state pension age does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 70 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + is_SP_age: [false, true] + income_support_eligible: true + +- name: An excluded member's income-related ESA does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_income_reported: 5_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + # The benefit unit's reported award is still paid; it is the excluded + # member's own award, not the claimant's or partner's. + esa_income: 5_000 + income_support_eligible: true + +- name: An excluded member's contributory ESA does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_contrib_reported: 5_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +- name: An excluded member's reported Income Support is not the claimant's award + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + other_adult: + age: 30 + is_claimant_or_partner: false + income_support_reported: 1_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: An excluded member's caring does not open the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + receives_carer_benefit: true + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + is_carer_for_benefits: [false, true] + income_support_eligible: false + +- name: A lone parent's route is unchanged by an excluded adult in the benefit unit + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + income_support_reported: 1_000 + child: + age: 3 + is_claimant_or_partner: false + grandparent: + age: 72 + is_claimant_or_partner: false + esa_income_reported: 2_000 + esa_contrib_reported: 1_000 + benunits: + family: + members: [parent, child, grandparent] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + is_lone_parent: true + youngest_child_age_for_legacy_benefits: 3 + income_support_eligible: true + +- name: The partner reporting the award satisfies the existing-award condition + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + partner: + age: 38 + is_claimant_or_partner: true + income_support_reported: 1_000 + benunits: + family: + members: [claimant, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +# s.124(1)(aa) and (g): the claimant's age, and the partner's SPC entitlement. + +- name: A mixed-age couple can claim through the younger partner who cares + period: 2025 + input: + people: + carer: + age: 60 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 70 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + is_SP_age: [false, true] + # SPCA 2002 s.4(1A): a member of a mixed-age couple is not entitled to + # State Pension Credit, so s.124(1)(g) does not bar the younger partner. + is_pension_credit_eligible: false + income_support_eligible: true + +- name: A carer over state pension age cannot claim, and the younger partner is not a carer + period: 2025 + input: + people: + carer: + age: 70 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 60 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: A single carer over state pension age cannot claim + period: 2025 + input: + people: + carer: + age: 70 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +# s.124(1)(h): the claimant has no ESA of either kind; the partner has no +# income-related ESA. + +- name: A single carer entitled to contributory ESA cannot claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_contrib_reported: 3_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: The carer can claim when only the partner has contributory ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_contrib_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +- name: Neither partner can claim when the carer has contributory ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_contrib_reported: 3_000 + partner: + age: 42 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: The partner's income-related ESA bars the carer route + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + esa_income: 3_000 + income_support_eligible: false + +- name: A reported award the ESA capital test screens out does not bar the carer route + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + # Above the £16,000 ESA capital limit (ESA Regs 2008 reg 110). + esa_income_assessable_capital: 20_000 + output: + esa_income: 0 + income_support_eligible: true + +- name: An income-related ESA award entered directly is the claimant's or partner's + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + esa_income: 3_000 + output: + income_support_eligible: false + +# Roles inferred rather than given: a couple flagged as parents, their child +# and a grandparent. The grandparent is neither claimant nor partner +# (is_claimant_or_partner), so their age, ESA and caring do not count. + +- name: An inferred grandparent does not change a couple's carer route + period: 2025 + input: + people: + mother: + age: 34 + is_parent: true + is_benunit_head: true + receives_carer_benefit: true + income_support_reported: 1_000 + father: + age: 36 + is_parent: true + child: + age: 8 + grandparent: + age: 71 + esa_income_reported: 2_000 + benunits: + family: + members: [mother, father, child, grandparent] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + is_claimant_or_partner: [true, true, false, false] + income_support_eligible: true diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py new file mode 100644 index 000000000..e5ad6f041 --- /dev/null +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -0,0 +1,197 @@ +"""Income Support eligibility follows only the claimant and partner. + +SSCBA 1992 s.124(1) sets each condition for the claimant ("he") and, in +paras (c), (f), (g) and (h), for "the other member of the couple". A couple +choose which of them claims (Claims and Payments Regs 1987 reg 4(3)). Nobody +else in a benefit unit is named, so: + +- adding a member who is neither the claimant, the partner nor a child or + young person they are responsible for never changes income_support_eligible, + whatever that member's age, ESA, Income Support or caring; +- income_support_eligible equals a direct reading of the conditions: one of + the claimant and partner is under state pension age, is a carer (or a lone + parent of a child aged 5 or under, the model's reading of Sch 1B para 1) + and has no contributory ESA (s.124(1)(aa), (e), (h)); neither has + income-related ESA after the ESA capital test (s.124(1)(h)); one of them + reports an existing award (UC (Transitional Provisions) Regs 2014 reg 6A); + and capital is within the Income Support limit. + +Roles are given explicitly (is_claimant_or_partner), so the properties test +the eligibility rule rather than the role inference; the inferred case is +covered in income_support_claimant_partner_gates.yaml. Each example builds +many families in one simulation, in separate households and benefit units. +""" + +import math + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 + + +@st.composite +def adult_inputs(draw, min_age=18): + return { + "age": draw(st.integers(min_age, 90)), + "receives_carer_benefit": draw(st.booleans()), + "care_hours": draw(st.sampled_from([0, 34, 35])), + "esa_income_reported": draw(st.sampled_from([0, 0, 200, 3_000])), + "esa_contrib_reported": draw(st.sampled_from([0, 0, 3_000])), + "income_support_reported": draw(st.sampled_from([0, 1_000, 1_000])), + } + + +@st.composite +def families(draw): + """A claimant, an optional partner and up to three dependants.""" + n_dependants = draw(st.integers(0, 3)) + dependants = [] + for _ in range(n_dependants): + if draw(st.booleans()): + dependant = {"age": draw(st.integers(0, 15))} + else: + # A qualifying young person: 16-19 in non-advanced education. + dependant = { + "age": draw(st.integers(16, 19)), + "current_education": "UPPER_SECONDARY", + } + dependants.append(dependant) + eldest_dependant = max([d["age"] for d in dependants], default=0) + adults = [draw(adult_inputs(min_age=max(18, eldest_dependant + 16)))] + if draw(st.booleans()): + adults.append(draw(adult_inputs())) + for adult in adults: + adult["is_parent"] = n_dependants > 0 + capital = { + "income_support_assessable_capital": draw(st.sampled_from([0, 10_000, 20_000])), + "esa_income_assessable_capital": draw( + st.sampled_from([0, 6_250, 10_000, 20_000]) + ), + } + return adults, dependants, capital + + +@st.composite +def excluded_members(draw): + """An adult who is neither claimant, partner nor a dependant. + + They are not in education, so a 16 to 19 year old is not a qualifying + young person. + """ + return { + **draw(adult_inputs(min_age=16)), + "current_education": "NOT_IN_EDUCATION", + } + + +def situation(units): + people, benunits, households = {}, {}, {} + for i, (adults, dependants, capital, extra) in enumerate(units): + members = [(m, True) for m in adults] + [(m, False) for m in dependants] + if extra is not None: + members.append((extra, False)) + names = [] + 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} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + **{k: {YEAR: v} for k, v in capital.items()}, + } + households[f"h{i}"] = {"members": names} + return {"people": people, "benunits": benunits, "households": households} + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8)) +def test_excluded_member_never_changes_is_eligibility(drawn): + without = [(*family, None) for family, _ in drawn] + with_extra = [(*family, extra) for family, extra in drawn] + sim = Simulation(situation=situation(without + with_extra)) + eligible = sim.calculate("income_support_eligible", YEAR) + flags = sim.calculate("is_claimant_or_partner", YEAR) + dependant = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) + sizes = [len(a) + len(d) + (e is not None) for a, d, _, e in without + with_extra] + ends = np.cumsum(sizes) + k = len(drawn) + for i in range(k): + # The added member is the last of its benefit unit: neither claimant, + # partner nor a dependant. + last = ends[k + i] - 1 + assert not flags[last] and not dependant[last], drawn[i] + assert eligible[i] == eligible[k + i], drawn[i] + + +def reference_eligibility(adults, dependants, capital, sp_age, parameters): + """SSCBA 1992 s.124(1) as the model abstracts it, read family by family.""" + IS = parameters.gov.dwp.income_support + ESA = parameters.gov.dwp.ESA.income.capital + child_ages = [d["age"] for d in dependants if d["age"] < 16] + lone_parent_with_young_child = ( + len(adults) == 1 + and len(dependants) > 0 + and min(child_ages, default=math.inf) + <= IS.eligibility.lone_parent_youngest_child_age_limit + ) + + def could_claim(adult, over_qualifying_age): + carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 + return ( + (carer or lone_parent_with_young_child) + and not over_qualifying_age + and adult["esa_contrib_reported"] == 0 + ) + + reported_esa = sum(a["esa_income_reported"] for a in adults) + esa_capital = capital["esa_income_assessable_capital"] + tariff = ( + math.ceil( + max(0, esa_capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + ) + * ESA.tariff_income.amount + * 52 + ) + income_related_esa = ( + reported_esa > 0 and esa_capital <= ESA.limit and reported_esa > tariff + ) + return ( + any(could_claim(a, s) for a, s in zip(adults, sp_age)) + and not income_related_esa + and any(a["income_support_reported"] > 0 for a in adults) + and capital["income_support_assessable_capital"] <= IS.means_test.capital.limit + ) + + +@SETTINGS +@given(st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8)) +def test_is_eligibility_matches_a_direct_reading_of_s124(drawn): + units = [(*family, extra) for family, extra in drawn] + sim = Simulation(situation=situation(units)) + eligible = sim.calculate("income_support_eligible", YEAR) + sp_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): + expected = reference_eligibility( + adults, + dependants, + capital, + sp_age[start : start + len(adults)], + parameters, + ) + assert eligible[i] == expected, units[i] + start += len(adults) + len(dependants) + 1 diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index ee9e0be81..e88f3210c 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -5,41 +5,96 @@ class income_support_eligible(Variable): value_type = bool entity = BenUnit label = "Whether eligible for Income Support" + documentation = ( + "SSCBA 1992 s.124(1) sets the conditions for the claimant and, for " + "some of them, the claimant's partner. A couple choose which of them " + "claims (Claims and Payments Regs 1987 reg 4(3)), so the benefit unit " + "is eligible when either the claimant or the partner could claim: " + "they are under the qualifying age for State Pension Credit, fall " + "within a prescribed category (a carer, or a lone parent of a young " + "child) and are not entitled to Employment and Support Allowance. " + "Neither of them may be entitled to income-related ESA, " + "and one of them must already have an award, because new claims are " + "closed. Other members of the benefit unit, such as a non-dependent " + "adult, do not affect eligibility." + ) definition_period = YEAR reference = ( - "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", - "https://www.legislation.gov.uk/uksi/1987/1967/regulation/4ZA", "https://www.legislation.gov.uk/ukpga/1992/4/section/124", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/4ZA", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", "https://www.legislation.gov.uk/uksi/1987/1968/regulation/4", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/6A", ) def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - # Schedule 1B para 1 says "under 5". Retain the existing inclusive - # comparison pending a separate decision on the annual-age model. + person = benunit.members + # SSCBA s.124(1) names the claimant and, in paras (c), (f), (g) and + # (h), the other member of a couple. A couple choose which of them + # claims (Claims and Payments Regs 1987 reg 4(3)), so either of them + # can be the claimant; nobody else in the benefit unit is named. + claimant_or_partner = person("is_claimant_or_partner", period) + # s.124(1)(e), reg 4ZA and Sch 1B: the claimant falls within a + # prescribed category. Para 1 is a lone parent responsible for a child + # under 5; Schedule 1B para 1 says "under 5", and the model retains + # the existing inclusive comparison pending a separate decision on + # the annual-age model. Para 4 is a carer. youngest_child_5_or_under = ( benunit("youngest_child_age_for_legacy_benefits", period) <= IS.eligibility.lone_parent_youngest_child_age_limit ) - lone_parent = benunit("is_lone_parent", period) - lone_parent_with_young_child = lone_parent & youngest_child_5_or_under - # Sch 1B para 4 prescribes the carer, and SSCBA s.124(1)(e) requires - # the claimant to fall within a prescribed category. A couple choose - # which of them claims (Claims and Payments Regs 1987 reg 4(3)), so - # either partner's caring qualifies; a child's or young person's - # caring does not. - claimant_or_partner = benunit.members("is_claimant_or_partner", period) - carer = benunit.members("is_carer_for_benefits", period) - claimant_or_partner_cares = benunit.any(claimant_or_partner & carer) - none_SP_age = ~benunit.any(benunit.members("is_SP_age", period)) - has_esa_income = benunit("esa_income", period) > 0 - already_claiming = add(benunit, period, ["income_support_reported"]) > 0 + lone_parent_with_young_child = ( + benunit("is_lone_parent", period) & youngest_child_5_or_under + ) + prescribed_category = person("is_carer_for_benefits", period) | benunit.project( + lone_parent_with_young_child + ) + # s.124(1)(aa): the claimant has not attained the qualifying age for + # State Pension Credit, which is state pension age (SPCA 2002 s.1(6)). + # A partner over that age does not bar the claim; s.124(1)(g) bars it + # only if the partner is entitled to State Pension Credit, which a + # mixed-age couple cannot be (SPCA 2002 s.4(1A); the SI 2019/37 + # art. 4 savings are not modelled, as in is_pension_credit_eligible). + under_qualifying_age = ~person("is_SP_age", period) + # s.124(1)(h): the claimant is not entitled to an employment and + # support allowance of either kind ... + no_contributory_esa = person("esa_contrib", period) <= 0 + could_claim = ( + claimant_or_partner + & prescribed_category + & under_qualifying_age + & no_contributory_esa + ) + # ... and the other member of a couple is not entitled to an + # income-related allowance. An income-related allowance covers the + # couple, so it bars Income Support whichever of them has it. This is + # esa_income restricted to the claimant's and partner's reported + # awards, with the same capital screen. An award entered directly as + # esa_income, with no reported awards, is taken to be theirs. + reported_income_related_esa = benunit.sum( + person("esa_income_reported", period) * claimant_or_partner + ) + income_related_esa = where( + add(benunit, period, ["esa_income_reported"]) > 0, + benunit("esa_income_eligible", period) + & ( + reported_income_related_esa + > benunit("esa_income_tariff_income", period) + ), + benunit("esa_income", period) > 0, + ) + # No new claims for Income Support can be made (Universal Credit + # (Transitional Provisions) Regs 2014 reg 6A(1)), so the claimant or + # partner must already have an award. + already_claiming = ( + benunit.sum(person("income_support_reported", period) * claimant_or_partner) + > 0 + ) capital = benunit("income_support_assessable_capital", period) - limit = IS.means_test.capital.limit return ( - (claimant_or_partner_cares | lone_parent_with_young_child) - & none_SP_age - & ~has_esa_income + benunit.any(could_claim) + & ~income_related_esa & already_claiming - & (capital <= limit) + & (capital <= IS.means_test.capital.limit) ) From 5bbbc3ca85c119b8ccb0accb85e1b6101a8b3ac7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:32:45 -0400 Subject: [PATCH 02/12] Add changelog fragment for the Income Support claimant and partner gates Co-Authored-By: Claude Opus 5.5 --- changelog.d/is-eligibility-claimant-partner-gates.fixed.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/is-eligibility-claimant-partner-gates.fixed.md diff --git a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md new file mode 100644 index 000000000..598bc2366 --- /dev/null +++ b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md @@ -0,0 +1,3 @@ +- Apply the Income Support entitlement conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another member of the benefit unit, such as a non-dependent adult, no longer bars the claim by being over state pension age or by having income-related ESA, and no longer counts as having an existing Income Support award. +- Let a couple claim Income Support through either partner (Claims and Payments Regs 1987 reg 4(3)): the partner who claims must be under state pension age, in a prescribed category and without ESA, so a mixed-age couple can claim through the younger partner. +- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. From 8d8673a3384cb640cd495992bf6f085e318312cc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 17:46:09 -0400 Subject: [PATCH 03/12] Make the award holder the Income Support claimant; respect a direct esa_income Review r1 of this PR found three ways a member outside the claimant and partner, or the wrong partner, still decided eligibility: - An excluded member's reported ESA switched off the fallback for an esa_income entered directly. The gate now asks the simulation whether esa_income is an input (simulation.input_variables, as marginal_tax_rate_on_capital_gains does). If it is, it reads it as the claimant's or partner's award. Otherwise it applies the esa_income calculation, now the shared income_related_esa_award helper, to the claimant's and partner's reported amounts. - The award and the conditions could sit on different partners. No new claim can be made (UC (TP) Regs 2014 reg 6A(1)), and a partner who takes over an award makes a claim (Claims and Payments Regs 1987 reg 4(4)). So the claimant is the partner who reports the existing award, and that person must satisfy (aa), (e) and the first limb of (h). - youngest_child_age_for_legacy_benefits counted a child placed by a local authority, who is not a member of the claimant's household (IS Regs 1987 reg 16(4)). It now uses is_child_or_young_person_for_legacy_benefits. #2001's "partner is the carer" case now puts the award on the caring partner. YAML: 21 cases, 5 of which fail on this PR's previous head and 10 on #2001's. The Hypothesis generators add placed children, esa_income entered directly and household savings, and the differential test follows the award holder. Co-Authored-By: Claude Opus 5.5 --- ...ligibility-claimant-partner-gates.fixed.md | 7 +- .../income_support_carer_route.yaml | 11 +- ...income_support_claimant_partner_gates.yaml | 118 +++++++++++-- ...t_income_support_eligibility_properties.py | 166 ++++++++++++------ .../variables/gov/dwp/esa_income.py | 13 +- .../gov/dwp/income_support_eligible.py | 83 +++++---- .../youngest_child_age_for_legacy_benefits.py | 15 +- 7 files changed, 291 insertions(+), 122 deletions(-) diff --git a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md index 598bc2366..6816d7284 100644 --- a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md +++ b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md @@ -1,3 +1,4 @@ -- Apply the Income Support entitlement conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another member of the benefit unit, such as a non-dependent adult, no longer bars the claim by being over state pension age or by having income-related ESA, and no longer counts as having an existing Income Support award. -- Let a couple claim Income Support through either partner (Claims and Payments Regs 1987 reg 4(3)): the partner who claims must be under state pension age, in a prescribed category and without ESA, so a mixed-age couple can claim through the younger partner. -- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. +- Apply the Income Support entitlement conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another member of the benefit unit, such as a non-dependent adult, no longer bars the claim by being over state pension age or having income-related ESA, and no longer counts as having an existing Income Support award. +- Treat the claimant or partner who reports the existing Income Support award as the claimant. No new claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)), and a partner who takes over an award makes a claim (Claims and Payments Regs 1987 reg 4(4)). That person must be under state pension age, in a prescribed category and without ESA. A partner over state pension age no longer bars a younger partner who has the award. +- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. An `esa_income` entered directly is taken to be theirs. +- Leave a child placed by a local authority out of `youngest_child_age_for_legacy_benefits`; such a child is not a member of the claimant's household (IS Regs 1987 reg 16(4)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml index b17f87ee0..a5d7c28be 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml @@ -3,8 +3,11 @@ # IS Regs 1987 reg 4ZA(1) prescribes the persons in Schedule 1B, and Sch 1B # para 4 includes "A person (the carer)" regularly and substantially caring # for a disabled person or entitled to Carer's Allowance. A couple choose -# which of them claims (Claims and Payments Regs 1987 reg 4(3)), so either -# partner's caring qualifies; a child's or young person's caring does not. +# which of them claims (Claims and Payments Regs 1987 reg 4(3)), but no new +# claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)) and a +# partner who takes over an award does so by claiming (reg 4(4)), so the +# claimant is whichever partner has the existing award. The route opens +# through that partner's caring; a child's or young person's caring does not. - name: IS carer route for a single claimant who cares period: 2025 @@ -22,16 +25,16 @@ output: income_support_eligible: true -- name: IS carer route when the partner is the carer +- name: IS carer route when the partner who has the award is the carer period: 2025 input: people: claimant: age: 40 - income_support_reported: 1000 partner: age: 38 receives_carer_benefit: true + income_support_reported: 1000 benunits: family: members: [claimant, partner] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml index b168802fb..f4cc04eaf 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml @@ -8,16 +8,20 @@ # (h) "he is not entitled to an employment and support allowance and, if he # is a member of a couple, the other member of the couple is not entitled # to an income-related employment and support allowance". -# A couple choose which of them claims (Claims and Payments Regs 1987 -# reg 4(3)), so the benefit unit is eligible when either partner could claim. -# No new claims can be made (UC (Transitional Provisions) Regs 2014 -# reg 6A(1)), so the claimant or partner must have an existing award. +# No new claim for Income Support can be made (UC (Transitional Provisions) +# Regs 2014 reg 6A(1)). A couple choose which of them claims (Claims and +# Payments Regs 1987 reg 4(3)), but a partner who takes over an award does so +# by claiming (reg 4(4)), so the claimant is the partner with the existing +# award. The model takes that to be the claimant or partner who reports +# Income Support. # # A member who is neither the claimant, the partner nor a child or young -# person they are responsible for (here an adult given -# is_claimant_or_partner: false) is named by none of these conditions, so it -# never changes eligibility. 2025 is used because the model pays Income -# Support until 2025-26; state pension age is 66 in 2025. +# person in the family (here an adult given is_claimant_or_partner: false, or +# a child placed by a local authority) is named by none of these conditions, +# so it never changes eligibility. These cases test the model's bounded gate: +# caring is is_carer_for_benefits, ESA and Income Support are reported +# awards, and the means test is out of scope. 2025 is used because the model +# pays Income Support until 2025-26; state pension age is 66 in 2025. - name: An excluded member over state pension age does not bar the carer route period: 2025 @@ -148,11 +152,11 @@ youngest_child_age_for_legacy_benefits: 3 income_support_eligible: true -- name: The partner reporting the award satisfies the existing-award condition +- name: A carer cannot take over the award of a partner who does not care period: 2025 input: people: - claimant: + carer: age: 40 is_claimant_or_partner: true receives_carer_benefit: true @@ -162,10 +166,39 @@ income_support_reported: 1_000 benunits: family: - members: [claimant, partner] + members: [carer, partner] income_support_assessable_capital: 0 output: - income_support_eligible: true + # The partner has the award but is in no prescribed category; the carer + # could only claim, and reg 6A(1) bars a new claim. + income_support_eligible: false + +- name: A child placed by a local authority does not make the claimant a lone parent of a young child + period: 2025 + input: + people: + parent: + age: 40 + is_claimant_or_partner: true + is_parent: true + income_support_reported: 1_000 + child: + age: 8 + is_claimant_or_partner: false + placed_child: + age: 3 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [parent, child, placed_child] + income_support_assessable_capital: 0 + output: + # IS Regs 1987 reg 16(4): a child placed by a local authority is not a + # member of the claimant's household. + is_child_or_young_person_for_legacy_benefits: [false, true, false] + youngest_child_age_for_legacy_benefits: 8 + income_support_eligible: false # s.124(1)(aa) and (g): the claimant's age, and the partner's SPC entitlement. @@ -192,6 +225,27 @@ is_pension_credit_eligible: false income_support_eligible: true +- name: A younger carer cannot take over an older partner's award + period: 2025 + input: + people: + holder: + age: 67 + is_claimant_or_partner: true + income_support_reported: 1_000 + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + benunits: + family: + members: [holder, carer] + income_support_assessable_capital: 0 + output: + # The award holder is over state pension age and not a carer; the carer + # would have to make a new claim (reg 4(4)), which reg 6A(1) bars. + income_support_eligible: false + - name: A carer over state pension age cannot claim, and the younger partner is not a carer period: 2025 input: @@ -332,6 +386,46 @@ esa_income: 0 income_support_eligible: true +- name: An award entered directly bars the claim even when an excluded member reports ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_income_reported: 5_000 + benunits: + family: + members: [carer, other_adult] + income_support_assessable_capital: 0 + esa_income: 3_000 + output: + income_support_eligible: false + +- name: A zero award entered directly overrides the claimant's reported ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_income_reported: 2_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + esa_income: 0 + output: + # The model pays no income-related ESA, so none bars Income Support. + income_support_eligible: true + - name: An income-related ESA award entered directly is the claimant's or partner's period: 2025 input: diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index e5ad6f041..a29178397 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -1,31 +1,42 @@ """Income Support eligibility follows only the claimant and partner. SSCBA 1992 s.124(1) sets each condition for the claimant ("he") and, in -paras (c), (f), (g) and (h), for "the other member of the couple". A couple -choose which of them claims (Claims and Payments Regs 1987 reg 4(3)). Nobody -else in a benefit unit is named, so: +paras (c), (f), (g) and (h), for "the other member of the couple". No new +claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)) and a +partner who takes over an award does so by claiming (Claims and Payments +Regs 1987 reg 4(4)), so the claimant is the partner with the existing award. +Nobody else in a benefit unit is named, so: - adding a member who is neither the claimant, the partner nor a child or - young person they are responsible for never changes income_support_eligible, + young person in the family (an adult outside the family, or a child placed + by a local authority, IS reg 16(4)) never changes income_support_eligible, whatever that member's age, ESA, Income Support or caring; -- income_support_eligible equals a direct reading of the conditions: one of - the claimant and partner is under state pension age, is a carer (or a lone - parent of a child aged 5 or under, the model's reading of Sch 1B para 1) - and has no contributory ESA (s.124(1)(aa), (e), (h)); neither has - income-related ESA after the ESA capital test (s.124(1)(h)); one of them - reports an existing award (UC (Transitional Provisions) Regs 2014 reg 6A); - and capital is within the Income Support limit. +- income_support_eligible equals a family-by-family reading of the model's + gate: one of the claimant and partner reports Income Support, is under + state pension age, is a carer (or a lone parent of a child aged 5 or under, + the model's reading of Sch 1B para 1) and has no contributory ESA + (s.124(1)(aa), (e), (h)); neither has income-related ESA (s.124(1)(h)), + meaning the award on their reported amounts after the ESA capital test, or + an esa_income entered directly; and capital is within the Income Support + limit. + +The second property is a reference check of the bounded model gate, not of +legal entitlement: caring, ESA and Income Support are the model's reported +or proxy inputs, it reads state pension age from the model (is_SP_age), and +the means test is out of scope. Roles are given explicitly (is_claimant_or_partner), so the properties test the eligibility rule rather than the role inference; the inferred case is covered in income_support_claimant_partner_gates.yaml. Each example builds many families in one simulation, in separate households and benefit units. +Capital is either entered as each benefit unit's assessable capital or as +household savings, which both capital tests read. """ import math import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, event, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -47,7 +58,7 @@ def adult_inputs(draw, min_age=18): @st.composite def families(draw): - """A claimant, an optional partner and up to three dependants.""" + """A claimant, an optional partner, up to three dependants and capital.""" n_dependants = draw(st.integers(0, 3)) dependants = [] for _ in range(n_dependants): @@ -66,29 +77,63 @@ def families(draw): adults.append(draw(adult_inputs())) for adult in adults: adult["is_parent"] = n_dependants > 0 - capital = { - "income_support_assessable_capital": draw(st.sampled_from([0, 10_000, 20_000])), - "esa_income_assessable_capital": draw( - st.sampled_from([0, 6_250, 10_000, 20_000]) - ), - } + capital = draw(st.sampled_from([0, 6_250, 10_000, 20_000])) return adults, dependants, capital @st.composite def excluded_members(draw): - """An adult who is neither claimant, partner nor a dependant. + """A member who is neither claimant, partner nor in the family. - They are not in education, so a 16 to 19 year old is not a qualifying - young person. + Either an adult not in education (so a 16 to 19 year old is not a + qualifying young person), or a child placed by a local authority. """ + if draw(st.integers(0, 3)) == 0: + return { + "age": draw(st.integers(0, 15)), + "is_looked_after_by_local_authority": True, + "receives_carer_benefit": draw(st.booleans()), + } return { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", } -def situation(units): +@st.composite +def input_settings(draw, n): + """How capital and income-related ESA are entered for n families. + + Capital goes in as assessable capital or as household savings. When + esa_income is entered directly, it is entered for every family, so it is + a simulation input and the formula does not run. + """ + capital_as_savings = draw(st.booleans()) + esa_income = ( + draw(st.lists(st.sampled_from([0, 3_000]), min_size=n, max_size=n)) + if draw(st.integers(0, 3)) == 0 + else None + ) + return capital_as_savings, esa_income + + +def label(units, capital_as_savings, esa_income): + """Record which input surfaces an example reaches (--hypothesis-show-statistics).""" + event( + "capital as household savings" if capital_as_savings else "assessable capital" + ) + event("esa_income entered directly" if esa_income else "esa_income calculated") + extras = [extra for *_, extra in units if extra is not None] + if any(e.get("is_looked_after_by_local_authority") for e in extras): + event("placed child added") + if any( + e.get("income_support_reported") or e.get("esa_income_reported") for e in extras + ): + event("added member reports IS or ESA") + + +def situation(units, capital_as_savings, esa_income): + label(units, capital_as_savings, esa_income) people, benunits, households = {}, {}, {} for i, (adults, dependants, capital, extra) in enumerate(units): members = [(m, True) for m in adults] + [(m, False) for m in dependants] @@ -100,11 +145,15 @@ def situation(units): 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, - **{k: {YEAR: v} for k, v in capital.items()}, - } + benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = {"members": names} + if capital_as_savings: + households[f"h{i}"]["savings"] = {YEAR: capital} + else: + benunits[f"b{i}"]["income_support_assessable_capital"] = {YEAR: capital} + benunits[f"b{i}"]["esa_income_assessable_capital"] = {YEAR: capital} + if esa_income is not None: + benunits[f"b{i}"]["esa_income"] = {YEAR: esa_income[i % len(esa_income)]} return {"people": people, "benunits": benunits, "households": households} @@ -115,14 +164,21 @@ def situation(units): suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) +FAMILIES = st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8) + @SETTINGS -@given(st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8)) -def test_excluded_member_never_changes_is_eligibility(drawn): +@given(FAMILIES, st.data()) +def test_excluded_member_never_changes_is_eligibility(drawn, data): + capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) without = [(*family, None) for family, _ in drawn] with_extra = [(*family, extra) for family, extra in drawn] - sim = Simulation(situation=situation(without + with_extra)) + sim = Simulation( + situation=situation(without + with_extra, capital_as_savings, esa_income) + ) eligible = sim.calculate("income_support_eligible", YEAR) + if eligible.any(): + event("some family eligible") flags = sim.calculate("is_claimant_or_partner", YEAR) dependant = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) sizes = [len(a) + len(d) + (e is not None) for a, d, _, e in without + with_extra] @@ -130,14 +186,14 @@ def test_excluded_member_never_changes_is_eligibility(drawn): k = len(drawn) for i in range(k): # The added member is the last of its benefit unit: neither claimant, - # partner nor a dependant. + # partner nor in the family. last = ends[k + i] - 1 assert not flags[last] and not dependant[last], drawn[i] assert eligible[i] == eligible[k + i], drawn[i] -def reference_eligibility(adults, dependants, capital, sp_age, parameters): - """SSCBA 1992 s.124(1) as the model abstracts it, read family by family.""" +def reference_eligibility(adults, dependants, capital, esa_income, sp_age, parameters): + """The model's Income Support gate, read family by family.""" IS = parameters.gov.dwp.income_support ESA = parameters.gov.dwp.ESA.income.capital child_ages = [d["age"] for d in dependants if d["age"] < 16] @@ -148,40 +204,45 @@ def reference_eligibility(adults, dependants, capital, sp_age, parameters): <= IS.eligibility.lone_parent_youngest_child_age_limit ) - def could_claim(adult, over_qualifying_age): + def is_claimant(adult, over_qualifying_age): carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 return ( - (carer or lone_parent_with_young_child) + adult["income_support_reported"] > 0 + and (carer or lone_parent_with_young_child) and not over_qualifying_age and adult["esa_contrib_reported"] == 0 ) - reported_esa = sum(a["esa_income_reported"] for a in adults) - esa_capital = capital["esa_income_assessable_capital"] - tariff = ( - math.ceil( - max(0, esa_capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + if esa_income is not None: + income_related_esa = esa_income > 0 + else: + reported_esa = sum(a["esa_income_reported"] for a in adults) + tariff = ( + math.ceil( + max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + ) + * ESA.tariff_income.amount + * 52 + ) + income_related_esa = ( + reported_esa > 0 and capital <= ESA.limit and reported_esa > tariff ) - * ESA.tariff_income.amount - * 52 - ) - income_related_esa = ( - reported_esa > 0 and esa_capital <= ESA.limit and reported_esa > tariff - ) return ( - any(could_claim(a, s) for a, s in zip(adults, sp_age)) + any(is_claimant(a, s) for a, s in zip(adults, sp_age)) and not income_related_esa - and any(a["income_support_reported"] > 0 for a in adults) - and capital["income_support_assessable_capital"] <= IS.means_test.capital.limit + and capital <= IS.means_test.capital.limit ) @SETTINGS -@given(st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8)) -def test_is_eligibility_matches_a_direct_reading_of_s124(drawn): +@given(FAMILIES, st.data()) +def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): + capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) units = [(*family, extra) for family, extra in drawn] - sim = Simulation(situation=situation(units)) + sim = Simulation(situation=situation(units, capital_as_savings, esa_income)) eligible = sim.calculate("income_support_eligible", YEAR) + if eligible.any(): + event("some family eligible") sp_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 @@ -190,6 +251,7 @@ def test_is_eligibility_matches_a_direct_reading_of_s124(drawn): adults, dependants, capital, + None if esa_income is None else esa_income[i], sp_age[start : start + len(adults)], parameters, ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index d6f3d7483..2dc6a4415 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -1,6 +1,15 @@ from policyengine_uk.model_api import * +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 capital + test (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) + + class esa_income(Variable): value_type = float entity = BenUnit @@ -14,6 +23,4 @@ class esa_income(Variable): def formula(benunit, period, parameters): reported_award = add(benunit, period, ["esa_income_reported"]) - tariff_income = benunit("esa_income_tariff_income", period) - eligible = benunit("esa_income_eligible", period) - return where(eligible, max_(0, reported_award - tariff_income), 0) + return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index e88f3210c..ab8f7b390 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award class income_support_eligible(Variable): @@ -7,16 +8,17 @@ class income_support_eligible(Variable): label = "Whether eligible for Income Support" documentation = ( "SSCBA 1992 s.124(1) sets the conditions for the claimant and, for " - "some of them, the claimant's partner. A couple choose which of them " - "claims (Claims and Payments Regs 1987 reg 4(3)), so the benefit unit " - "is eligible when either the claimant or the partner could claim: " - "they are under the qualifying age for State Pension Credit, fall " - "within a prescribed category (a carer, or a lone parent of a young " - "child) and are not entitled to Employment and Support Allowance. " - "Neither of them may be entitled to income-related ESA, " - "and one of them must already have an award, because new claims are " - "closed. Other members of the benefit unit, such as a non-dependent " - "adult, do not affect eligibility." + "some of them, the claimant's partner. No new claims for Income " + "Support can be made, and a partner who takes over an award makes a " + "new claim, so the claimant is the one of the claimant and partner " + "who has the existing award (income_support_reported). They must be " + "under the qualifying age for State Pension Credit, fall within a " + "prescribed category (a carer, or a lone parent of a young child) " + "and not be entitled to Employment and Support Allowance. Neither " + "the claimant nor the partner may be entitled to income-related ESA. " + "A member of the benefit unit who is neither the claimant, the " + "partner nor a child or young person in the family (such as a " + "non-dependent adult) does not affect eligibility." ) definition_period = YEAR reference = ( @@ -31,10 +33,17 @@ def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support person = benunit.members # SSCBA s.124(1) names the claimant and, in paras (c), (f), (g) and - # (h), the other member of a couple. A couple choose which of them - # claims (Claims and Payments Regs 1987 reg 4(3)), so either of them - # can be the claimant; nobody else in the benefit unit is named. + # (h), the other member of a couple; nobody else in the benefit unit. claimant_or_partner = person("is_claimant_or_partner", period) + # No new claims for Income Support can be made (Universal Credit + # (Transitional Provisions) Regs 2014 reg 6A(1)). A couple choose + # which of them claims (Claims and Payments Regs 1987 reg 4(3)), but a + # partner who takes over an award does so by claiming (reg 4(4)), so + # the claimant is whichever of them has the existing award. The model + # takes that to be the claimant or partner who reports Income Support. + has_award = claimant_or_partner & ( + person("income_support_reported", period) > 0 + ) # s.124(1)(e), reg 4ZA and Sch 1B: the claimant falls within a # prescribed category. Para 1 is a lone parent responsible for a child # under 5; Schedule 1B para 1 says "under 5", and the model retains @@ -56,45 +65,33 @@ def formula(benunit, period, parameters): # only if the partner is entitled to State Pension Credit, which a # mixed-age couple cannot be (SPCA 2002 s.4(1A); the SI 2019/37 # art. 4 savings are not modelled, as in is_pension_credit_eligible). + # Reading Pension Credit here would make a dependency cycle through + # Working Tax Credit. under_qualifying_age = ~person("is_SP_age", period) # s.124(1)(h): the claimant is not entitled to an employment and # support allowance of either kind ... no_contributory_esa = person("esa_contrib", period) <= 0 - could_claim = ( - claimant_or_partner - & prescribed_category - & under_qualifying_age - & no_contributory_esa + claimant = ( + has_award & prescribed_category & under_qualifying_age & no_contributory_esa ) # ... and the other member of a couple is not entitled to an # income-related allowance. An income-related allowance covers the - # couple, so it bars Income Support whichever of them has it. This is - # esa_income restricted to the claimant's and partner's reported - # awards, with the same capital screen. An award entered directly as - # esa_income, with no reported awards, is taken to be theirs. - reported_income_related_esa = benunit.sum( - person("esa_income_reported", period) * claimant_or_partner - ) - income_related_esa = where( - add(benunit, period, ["esa_income_reported"]) > 0, - benunit("esa_income_eligible", period) - & ( - reported_income_related_esa - > benunit("esa_income_tariff_income", period) - ), - benunit("esa_income", period) > 0, - ) - # No new claims for Income Support can be made (Universal Credit - # (Transitional Provisions) Regs 2014 reg 6A(1)), so the claimant or - # partner must already have an award. - already_claiming = ( - benunit.sum(person("income_support_reported", period) * claimant_or_partner) - > 0 - ) + # couple, so it bars Income Support whichever of them has it: the + # award on the claimant's and partner's reported amounts, after the + # same capital test as esa_income. When esa_income is entered + # directly (a simulation input) rather than calculated, the reported + # amounts do not say whose award it is, and it is taken to be the + # claimant's or partner's. + if "esa_income" in benunit.simulation.input_variables: + income_related_esa = benunit("esa_income", period) > 0 + else: + reported = benunit.sum( + person("esa_income_reported", period) * claimant_or_partner + ) + income_related_esa = income_related_esa_award(benunit, period, reported) > 0 capital = benunit("income_support_assessable_capital", period) return ( - benunit.any(could_claim) + benunit.any(claimant) & ~income_related_esa - & already_claiming & (capital <= IS.means_test.capital.limit) ) diff --git a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py index de2c1449f..54bdcdbb6 100644 --- a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py @@ -6,9 +6,13 @@ class youngest_child_age_for_legacy_benefits(Variable): entity = BenUnit label = "Age of youngest child for legacy means-tested benefits" documentation = ( - "Youngest family member under 16, excluding the claimant and partner. " - "SSCBA sections 137(1) and 142(1) use the same child age definition. " - "Infinity if the benefit unit contains no such child." + "Youngest child under 16 in the family for the legacy means-tested " + "benefits (is_child_or_young_person_for_legacy_benefits). This " + "excludes the claimant and partner, and a child placed with the family " + "by a local authority, who is not a member of the claimant's household " + "(IS Regs 1987 reg 16(4)). SSCBA sections 137(1) and 142(1) use the " + "same child age definition. Infinity if the benefit unit contains no " + "such child." ) definition_period = YEAR unit = "year" @@ -16,11 +20,12 @@ class youngest_child_age_for_legacy_benefits(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/section/137", "https://www.legislation.gov.uk/ukpga/1992/4/section/142", "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/16", ) def formula(benunit, period, parameters): person = benunit.members - child = person("is_child_for_child_benefit", period) & ~person( - "is_claimant_or_partner", period + child = person("is_child_for_child_benefit", period) & person( + "is_child_or_young_person_for_legacy_benefits", period ) return benunit.min(where(child, person("age", period), np.inf)) From b1977d936461fc485fdec9d563c92199892ae2ea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 17:53:14 -0400 Subject: [PATCH 04/12] Prime half the property-test families at the edge of Income Support eligibility With 12 random families, the invariance property never reached a placed young child in a non-caring lone parent's family, or a directly entered esa_income beside an added member's ESA, so it passed on this PR's previous head. Half the families are now a caring claimant with an award, or such a lone parent with only children over 5. Placed children and direct esa_income are drawn half the time, and the invariance property runs 25 examples. Co-Authored-By: Claude Opus 5.5 --- ...t_income_support_eligibility_properties.py | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index a29178397..4649202ba 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -56,13 +56,29 @@ def adult_inputs(draw, min_age=18): } +PRIMED_CLAIMANT = { + "income_support_reported": 1_000, + "esa_income_reported": 0, + "esa_contrib_reported": 0, +} + + @st.composite def families(draw): - """A claimant, an optional partner, up to three dependants and capital.""" - n_dependants = draw(st.integers(0, 3)) + """A claimant, an optional partner, up to three dependants and capital. + + Half the families are drawn at random. The rest are primed to sit at the + edge of eligibility, where an added member could change the result: a + claimant who reports Income Support and cares, or a lone parent who + reports it, does not care and has only children over 5. + """ + shape = draw(st.sampled_from(["random", "random", "carer", "lone_parent"])) + n_dependants = draw(st.integers(1 if shape == "lone_parent" else 0, 3)) dependants = [] for _ in range(n_dependants): - if draw(st.booleans()): + if shape == "lone_parent": + dependant = {"age": draw(st.integers(6, 15))} + elif draw(st.booleans()): dependant = {"age": draw(st.integers(0, 15))} else: # A qualifying young person: 16-19 in non-advanced education. @@ -73,8 +89,19 @@ def families(draw): dependants.append(dependant) eldest_dependant = max([d["age"] for d in dependants], default=0) adults = [draw(adult_inputs(min_age=max(18, eldest_dependant + 16)))] - if draw(st.booleans()): + if shape != "lone_parent" and draw(st.booleans()): adults.append(draw(adult_inputs())) + if shape == "carer": + adults[0].update( + PRIMED_CLAIMANT, age=min(adults[0]["age"], 65), receives_carer_benefit=True + ) + elif shape == "lone_parent": + adults[0].update( + PRIMED_CLAIMANT, + age=min(adults[0]["age"], 65), + receives_carer_benefit=False, + care_hours=0, + ) for adult in adults: adult["is_parent"] = n_dependants > 0 capital = draw(st.sampled_from([0, 6_250, 10_000, 20_000])) @@ -88,9 +115,10 @@ def excluded_members(draw): Either an adult not in education (so a 16 to 19 year old is not a qualifying young person), or a child placed by a local authority. """ - if draw(st.integers(0, 3)) == 0: + if draw(st.booleans()): + # Mostly 5 or under, the ages that could open the lone-parent route. return { - "age": draw(st.integers(0, 15)), + "age": draw(st.one_of(st.integers(0, 5), st.integers(6, 15))), "is_looked_after_by_local_authority": True, "receives_carer_benefit": draw(st.booleans()), } @@ -111,7 +139,7 @@ def input_settings(draw, n): capital_as_savings = draw(st.booleans()) esa_income = ( draw(st.lists(st.sampled_from([0, 3_000]), min_size=n, max_size=n)) - if draw(st.integers(0, 3)) == 0 + if draw(st.booleans()) else None ) return capital_as_savings, esa_income @@ -164,10 +192,15 @@ def situation(units, capital_as_savings, esa_income): suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) +# The invariance property needs rarer combinations (a placed young child in a +# lone parent's family, a direct esa_income beside an added member's ESA), so +# it runs more examples. +INVARIANCE_SETTINGS = settings(SETTINGS, max_examples=25) + FAMILIES = st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8) -@SETTINGS +@INVARIANCE_SETTINGS @given(FAMILIES, st.data()) def test_excluded_member_never_changes_is_eligibility(drawn, data): capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) From 06e186b5c641bfb915e49343ca4cf0e4f2e94194 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 18:47:59 -0400 Subject: [PATCH 05/12] Treat esa_income as entered directly only for the period it was input Simulation.input_variables is fixed when the simulation is built and ignores the period. Three synthetic cases showed the gate getting it wrong: - an esa_income set with set_input after construction did not bar IS; - the same on a branch did not bar IS either; - an esa_income input for 2024 made the 2025 gate read esa_income as entered directly, even though 2025's value came from the formula over every member's reports, an excluded adult's included. The #2025 review found this on that PR's JSA copy of the pattern. policyengine_uk/utils/inputs.py (entered_directly, identical to #2025's copy) reads core's own record of explicit inputs (_user_input_keys: variable, branch, period), the record that to_input_dataframe reads. It counts an input set on this branch or one it inherits from, provided the value is still stored. test_income_support_esa_entered_directly.py covers all three cases; each fails on the previous gate. Co-Authored-By: Claude Opus 5.5 --- ...est_income_support_esa_entered_directly.py | 78 +++++++++++++++++++ policyengine_uk/utils/inputs.py | 29 +++++++ .../gov/dwp/income_support_eligible.py | 5 +- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 policyengine_uk/tests/test_income_support_esa_entered_directly.py create mode 100644 policyengine_uk/utils/inputs.py diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py new file mode 100644 index 000000000..e25132c3e --- /dev/null +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -0,0 +1,78 @@ +"""An income-related ESA award entered directly bars Income Support only for +the period it was entered for. + +income_support_eligible takes an esa_income entered directly (an input, not +the formula) to be the claimant's or partner's award, because the reported +amounts do not say whose it is. Otherwise it uses the award on the claimant's +and partner's reported amounts. "Entered directly" has to follow the input: + +- set with set_input after the simulation is built; +- set on a branch; +- for this year only, not for another year. +""" + +import numpy as np + +from policyengine_uk import Simulation + +YEAR = 2025 +CARER = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, +} + + +def simulation(people, benunit=None): + names = list(people) + return Simulation( + situation={ + "people": people, + "benunits": { + "b": { + "members": names, + "income_support_assessable_capital": {YEAR: 0}, + "esa_income_assessable_capital": {YEAR: 0}, + **(benunit or {}), + } + }, + "households": {"h": {"members": names}}, + } + ) + + +def test_award_set_after_construction_bars_income_support(): + sim = simulation({"carer": CARER}) + assert sim.calculate("income_support_eligible", YEAR)[0] + sim.set_input("esa_income", YEAR, np.array([3_000.0])) + sim.delete_arrays("income_support_eligible") + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert not sim.calculate("income_support_eligible", YEAR)[0] + + +def test_award_set_on_a_branch_bars_income_support_there(): + sim = simulation({"carer": CARER}) + branch = sim.get_branch("with_esa", clone_system=False) + branch.set_input("esa_income", YEAR, np.array([3_000.0])) + assert not branch.calculate("income_support_eligible", YEAR)[0] + assert sim.calculate("income_support_eligible", YEAR)[0] + + +def test_award_entered_for_another_year_does_not_count_this_year(): + # An excluded adult reports ESA in 2025; esa_income is entered only for + # 2024. In 2025 esa_income comes from the formula, so the gate reads the + # claimant's and partner's reports, and the excluded adult's award does + # not bar the claim. + excluded_adult = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "esa_income_reported": {YEAR: 3_000}, + } + sim = simulation( + {"carer": CARER, "other_adult": excluded_adult}, + {"esa_income": {YEAR - 1: 0}}, + ) + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert sim.calculate("income_support_eligible", YEAR)[0] diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py new file mode 100644 index 000000000..899a344c6 --- /dev/null +++ b/policyengine_uk/utils/inputs.py @@ -0,0 +1,29 @@ +"""Whether a variable was entered directly as a simulation input.""" + +from policyengine_core import periods + + +def entered_directly(population, variable, period): + """Whether ``variable`` was set as an input for ``period``. + + The input counts if it was set on this simulation's branch or a branch it + inherits from, at construction or later with ``set_input``. The value + must also still be stored. ``Simulation.input_variables`` cannot answer + this: it is fixed when the simulation is built and ignores the period, so + it misses later inputs and treats an input for one year as an input for + every year. Core records each explicit input as (variable, branch, + period) in ``_user_input_keys``, which is what ``to_input_dataframe`` + reads; this reads the same record. + """ + simulation = population.simulation + period = periods.period(period) + if hasattr(simulation, "_get_visible_branch_names"): + branches = set(simulation._get_visible_branch_names()) + else: + branches = {getattr(simulation, "branch_name", "default"), "default"} + if period not in simulation.get_holder(variable).get_known_periods(): + return False + return any( + name == variable and branch in branches and periods.period(key) == period + for name, branch, key in getattr(simulation, "_user_input_keys", ()) + ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index ab8f7b390..e960d3386 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award @@ -79,10 +80,10 @@ def formula(benunit, period, parameters): # couple, so it bars Income Support whichever of them has it: the # award on the claimant's and partner's reported amounts, after the # same capital test as esa_income. When esa_income is entered - # directly (a simulation input) rather than calculated, the reported + # directly for this period (an input, not the formula), the reported # amounts do not say whose award it is, and it is taken to be the # claimant's or partner's. - if "esa_income" in benunit.simulation.input_variables: + if entered_directly(benunit, "esa_income", period): income_related_esa = benunit("esa_income", period) > 0 else: reported = benunit.sum( From 277be7f1157f7ebbae7a4762faab7299ea538a33 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:54:10 -0400 Subject: [PATCH 06/12] Keep the record of direct inputs in step with stored values Cherry-picked from PolicyEngine/policyengine-uk#2025 (48abd8817). simulation.py and utils/inputs.py are identical to that commit. Its lifecycle test file is parametrised over jsa_income too, and this PR has no JSA gate, so it stays in #2025; the ESA cases are mirrored in test_income_support_esa_entered_directly.py. entered_directly read core's _user_input_keys, but in policyengine-core 3.32.9 that record drifts from storage: - clone() copies the holders' stored arrays but shares the record. An input set on a clone, or on a parent after a branch was made, was recorded against arrays it never reached, so an excluded adult's reported ESA then barred Income Support on the original or on the earlier branch. - delete_arrays() removes arrays but keeps their record, so a formula result calculated later for the same period looked like an input. The UK Simulation (and so Microsimulation) now gives each clone its own copy of the record, and drops the entries for deleted arrays. Both changes only remove stale or foreign entries. entered_directly now asks about the stored value the simulation reads: the first visible branch (its own, then its parents', then the default) that stores one, which is the order the holder reads in. The 11 ESA cases pass. With the new inputs.py on the previous simulation.py, 3 of them fail: deletion, clone, and an earlier branch. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 40 +++++++ ...est_income_support_esa_entered_directly.py | 101 ++++++++++++++++++ policyengine_uk/utils/inputs.py | 39 +++---- 3 files changed, 161 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index eee6214d4..397667cef 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -222,6 +222,46 @@ def reset_calculations(self): if variable not in self.input_variables: self.delete_arrays(variable) + def clone(self, *args, **kwargs) -> "Simulation": + """Clone the simulation, with its own record of explicit inputs. + + Core's ``clone`` copies the holders' stored arrays but shares + ``_user_input_keys``, the (variable, branch, period) record of values + set with ``set_input``. An input set afterwards on the clone (or on a + branch, which is a clone) was then recorded against the original's + own stored arrays too. Each copy keeps its own record here, matching + its own storage. ``entered_directly`` reads that record. + """ + new = super().clone(*args, **kwargs) + new._user_input_keys = set(getattr(self, "_user_input_keys", ())) + return new + + def delete_arrays(self, variable: str, period=None) -> None: + """Delete stored values, and forget that deleted inputs were inputs. + + Core removes the arrays but keeps their ``_user_input_keys`` entries, + so a formula result calculated later for the same period would be + taken for a user input. The entries for the variable, on the + branches whose storage this deletes and in the deleted periods, are + dropped with the arrays. + """ + super().delete_arrays(variable, period) + keys = getattr(self, "_user_input_keys", None) + if not keys: + return + if period is not None: + period = period_(period) + branches = set(self._get_visible_branch_names()) + self._user_input_keys = { + key + for key in keys + if not ( + key[0] == variable + and key[1] in branches + and (period is None or period.contains(period_(key[2]))) + ) + } + def get_known_variables(self): variables = [] for variable in self.tax_benefit_system.variables: diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index e25132c3e..031c57aac 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -14,6 +14,7 @@ import numpy as np from policyengine_uk import Simulation +from policyengine_uk.utils.inputs import entered_directly YEAR = 2025 CARER = { @@ -76,3 +77,103 @@ def test_award_entered_for_another_year_does_not_count_this_year(): ) assert sim.calculate("esa_income", YEAR)[0] == 3_000 assert sim.calculate("income_support_eligible", YEAR)[0] + + +# The record of direct inputs follows the stored value through deletion, +# clones and branches (the ESA cases of #2025's +# test_entered_directly_lifecycle.py, which also covers jsa_income). In each, +# an adult outside the couple reports £3,000 of income-related ESA, so the +# formula's esa_income is £3,000; that report alone must never bar the claim. + +EXCLUDED_ADULT = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "esa_income_reported": {YEAR: 3_000}, +} + + +def family(): + return simulation({"carer": CARER, "other_adult": EXCLUDED_ADULT}) + + +def eligible(sim): + sim.delete_arrays("income_support_eligible") + return bool(sim.calculate("income_support_eligible", YEAR)[0]) + + +def direct(sim, period=YEAR): + return entered_directly(sim.benunit, "esa_income", period) + + +def test_the_excluded_adults_report_never_bars_the_claim(): + sim = family() + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert not direct(sim) + assert eligible(sim) + + +def test_a_deleted_input_does_not_make_a_later_formula_result_direct(): + sim = family() + sim.set_input("esa_income", YEAR, [0]) + assert direct(sim) and eligible(sim) + sim.delete_arrays("esa_income") + assert not direct(sim) + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert not direct(sim) + assert eligible(sim) + + +def test_a_deleted_input_then_the_gate_reads_reports(): + sim = family() + sim.set_input("esa_income", YEAR, [3_000]) + assert not eligible(sim) + sim.delete_arrays("esa_income") + assert eligible(sim) + + +def test_an_input_on_a_clone_does_not_reach_the_original(): + sim = family() + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + clone = sim.clone() + clone.set_input("esa_income", YEAR, [0]) + assert direct(clone) + assert not direct(sim) + assert eligible(sim) + + +def test_a_parent_input_does_not_reach_an_earlier_branch(): + sim = family() + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + branch = sim.get_branch("before", clone_system=False) + sim.set_input("esa_income", YEAR, [3_000]) + assert direct(sim) and not eligible(sim) + assert not direct(branch) + assert eligible(branch) + + +def test_a_branch_made_after_a_parent_input_inherits_it(): + sim = family() + sim.set_input("esa_income", YEAR, [3_000]) + branch = sim.get_branch("after", clone_system=False) + assert direct(branch) + assert not eligible(branch) + + +def test_nested_branches_read_the_nearest_stored_input(): + sim = family() + outer = sim.get_branch("outer", clone_system=False) + outer.set_input("esa_income", YEAR, [3_000]) + inner = outer.get_branch("inner", clone_system=False) + assert direct(inner) and not eligible(inner) + inner.set_input("esa_income", YEAR, [0]) + assert direct(inner) and eligible(inner) + assert not eligible(outer) + assert not direct(sim) and eligible(sim) + + +def test_a_period_given_as_a_string_is_the_same_year(): + sim = family() + sim.set_input("esa_income", YEAR, [0]) + assert direct(sim, str(YEAR)) + assert not direct(sim, str(YEAR + 1)) diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py index 899a344c6..828ce93aa 100644 --- a/policyengine_uk/utils/inputs.py +++ b/policyengine_uk/utils/inputs.py @@ -4,26 +4,27 @@ def entered_directly(population, variable, period): - """Whether ``variable`` was set as an input for ``period``. + """Whether the value of ``variable`` for ``period`` is a user input. - The input counts if it was set on this simulation's branch or a branch it - inherits from, at construction or later with ``set_input``. The value - must also still be stored. ``Simulation.input_variables`` cannot answer - this: it is fixed when the simulation is built and ignores the period, so - it misses later inputs and treats an input for one year as an input for - every year. Core records each explicit input as (variable, branch, - period) in ``_user_input_keys``, which is what ``to_input_dataframe`` - reads; this reads the same record. + The simulation reads the value from the first of its visible branches + (its own, then its parents', then the default) that stores one, so this + asks whether that stored value was set with ``set_input``, when the + simulation was built or later. ``Simulation.input_variables`` cannot + answer this: it is fixed when the simulation is built and ignores the + period, so it misses later inputs and treats an input for one year as an + input for every year. + + Core records each explicit input as (variable, branch, period) in + ``_user_input_keys``. The UK ``Simulation`` keeps that record in step + with storage: a clone gets its own copy, and ``delete_arrays`` drops the + entries for the arrays it deletes, so a formula result stored later is + not taken for an input. """ simulation = population.simulation period = periods.period(period) - if hasattr(simulation, "_get_visible_branch_names"): - branches = set(simulation._get_visible_branch_names()) - else: - branches = {getattr(simulation, "branch_name", "default"), "default"} - if period not in simulation.get_holder(variable).get_known_periods(): - return False - return any( - name == variable and branch in branches and periods.period(key) == period - for name, branch, key in getattr(simulation, "_user_input_keys", ()) - ) + keys = simulation._user_input_keys + stored = set(simulation.get_holder(variable).get_known_branch_periods()) + for branch in simulation._get_visible_branch_names(): + if (branch, period) in stored: + return (variable, branch, period) in keys + return False From 97b7a1c65edf2cd56a86e9b30c3784f277966de7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:18:56 -0400 Subject: [PATCH 07/12] Decide direct ESA by value; add Sch 1B para 2; strengthen the properties Review r2 of this PR, and review r3 of #2025, showed that deciding "entered directly" from core's record of inputs (_user_input_keys) is unsound in policyengine-core 3.32.9. The record drifts across delete_arrays, clone, disk storage and core-built simulations, and a UK Simulation built with no inputs has no record, so the gate raised AttributeError. The record is no longer read: utils/inputs.py is deleted and simulation.py is restored to its state before this PR. The gate now reads the value of esa_income. If the value is what the reported awards give, either the award after the capital test (the formula) or their plain total (disable_simulated_benefits), the gate reads the claimant's and partner's reports. Any other value (an award entered directly, or a reform that replaces or neutralises esa_income) is taken to be theirs. Two consequences are intended and tested: abolishing income-related ESA removes the bar, and an entered award exactly equal to what the reports give is read through them. The rule is the same as #2025's for JSA. IS Regs 1987 Sch 1B para 2 is now a prescribed category: "A single claimant or a lone parent with whom a child is placed" by a local authority, with is_looked_after_by_local_authority as the proxy. A lone parent of an 8-year-old with a placed 3-year-old is eligible through para 2, not para 1 (the placed child is outside the household, reg 16(4)). The YAML case now expects true; new cases cover a single claimant with only a placed child, and a couple, who are not covered. The documentation calls the award-holder rule a model convention (if both partners report IS, either can be the claimant). The youngest-child documentation cites reg 16(4)-(6) and calls the flag a proxy. Property tests: - the invariance property adds only adults outside the family, primed as over state pension age, ESA, an IS report or a carer; - placed children are family members, read through para 2 in the oracle; - a new split-couple shape: the award holder fails a condition and the qualifying partner has no award; - direct ESA entries (0 or 4,000; 4,000 is never a report total) are drawn more often. The oracle's signature is unchanged; the caller applies the value rule. Co-Authored-By: Claude Opus 5.5 --- ...ligibility-claimant-partner-gates.fixed.md | 5 +- policyengine_uk/simulation.py | 40 ---- ...income_support_claimant_partner_gates.yaml | 65 +++++- ...t_income_support_eligibility_properties.py | 152 ++++++++++--- ...est_income_support_esa_entered_directly.py | 214 +++++++++--------- policyengine_uk/utils/inputs.py | 30 --- .../gov/dwp/income_support_eligible.py | 69 ++++-- .../youngest_child_age_for_legacy_benefits.py | 12 +- 8 files changed, 335 insertions(+), 252 deletions(-) delete mode 100644 policyengine_uk/utils/inputs.py diff --git a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md index 6816d7284..0955fe49b 100644 --- a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md +++ b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md @@ -1,4 +1,5 @@ -- Apply the Income Support entitlement conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another member of the benefit unit, such as a non-dependent adult, no longer bars the claim by being over state pension age or having income-related ESA, and no longer counts as having an existing Income Support award. +- Apply the Income Support entitlement conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another adult in the benefit unit, such as a non-dependent adult, no longer bars the claim by being over state pension age or having income-related ESA, and no longer counts as having an existing Income Support award. - Treat the claimant or partner who reports the existing Income Support award as the claimant. No new claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)), and a partner who takes over an award makes a claim (Claims and Payments Regs 1987 reg 4(4)). That person must be under state pension age, in a prescribed category and without ESA. A partner over state pension age no longer bars a younger partner who has the award. -- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. An `esa_income` entered directly is taken to be theirs. +- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. An `esa_income` that the reported awards do not explain (one entered directly, or set by a reform) is taken to be theirs. +- Add the IS Regs 1987 Sch 1B para 2 prescribed category: a single claimant or lone parent with a child placed by a local authority (proxied by `is_looked_after_by_local_authority`). - Leave a child placed by a local authority out of `youngest_child_age_for_legacy_benefits`; such a child is not a member of the claimant's household (IS Regs 1987 reg 16(4)). diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 397667cef..eee6214d4 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -222,46 +222,6 @@ def reset_calculations(self): if variable not in self.input_variables: self.delete_arrays(variable) - def clone(self, *args, **kwargs) -> "Simulation": - """Clone the simulation, with its own record of explicit inputs. - - Core's ``clone`` copies the holders' stored arrays but shares - ``_user_input_keys``, the (variable, branch, period) record of values - set with ``set_input``. An input set afterwards on the clone (or on a - branch, which is a clone) was then recorded against the original's - own stored arrays too. Each copy keeps its own record here, matching - its own storage. ``entered_directly`` reads that record. - """ - new = super().clone(*args, **kwargs) - new._user_input_keys = set(getattr(self, "_user_input_keys", ())) - return new - - def delete_arrays(self, variable: str, period=None) -> None: - """Delete stored values, and forget that deleted inputs were inputs. - - Core removes the arrays but keeps their ``_user_input_keys`` entries, - so a formula result calculated later for the same period would be - taken for a user input. The entries for the variable, on the - branches whose storage this deletes and in the deleted periods, are - dropped with the arrays. - """ - super().delete_arrays(variable, period) - keys = getattr(self, "_user_input_keys", None) - if not keys: - return - if period is not None: - period = period_(period) - branches = set(self._get_visible_branch_names()) - self._user_input_keys = { - key - for key in keys - if not ( - key[0] == variable - and key[1] in branches - and (period is None or period.contains(period_(key[2]))) - ) - } - def get_known_variables(self): variables = [] for variable in self.tax_benefit_system.variables: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml index f4cc04eaf..89c25b31d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml @@ -1,8 +1,10 @@ # Income Support conditions name the claimant and, for some, the partner. # SSCBA 1992 s.124(1): a person is entitled to Income Support if # (aa) "he has not attained the qualifying age for state pension credit"; -# (e) "he falls within a prescribed category of person" (reg 4ZA, Sch 1B: -# para 1 lone parent of a child under 5, para 4 carer); +# (e) "he falls within a prescribed category of person" (reg 4ZA, Sch 1B; +# the model covers para 1, a lone parent of a child under 5; para 2, +# a single claimant or lone parent with whom a child is placed; and +# para 4, a carer); # (g) "if he is a member of a couple, the other member of the couple is not # entitled to state pension credit"; and # (h) "he is not entitled to an employment and support allowance and, if he @@ -15,10 +17,12 @@ # award. The model takes that to be the claimant or partner who reports # Income Support. # -# A member who is neither the claimant, the partner nor a child or young -# person in the family (here an adult given is_claimant_or_partner: false, or -# a child placed by a local authority) is named by none of these conditions, -# so it never changes eligibility. These cases test the model's bounded gate: +# An adult who is neither the claimant nor the partner (here given +# is_claimant_or_partner: false) is named by none of these conditions, so it +# never changes eligibility. A child placed by a local authority is not a +# member of the claimant's household (IS Regs 1987 reg 16(4)), so it is not a +# young child for para 1, but it brings a single claimant within para 2. +# These cases test the model's bounded gate: # caring is is_carer_for_benefits, ESA and Income Support are reported # awards, and the means test is out of scope. 2025 is used because the model # pays Income Support until 2025-26; state pension age is 66 in 2025. @@ -173,7 +177,7 @@ # could only claim, and reg 6A(1) bars a new claim. income_support_eligible: false -- name: A child placed by a local authority does not make the claimant a lone parent of a young child +- name: A child placed with a lone parent brings them within Sch 1B para 2, not para 1 period: 2025 input: people: @@ -195,9 +199,54 @@ income_support_assessable_capital: 0 output: # IS Regs 1987 reg 16(4): a child placed by a local authority is not a - # member of the claimant's household. + # member of the claimant's household, so the youngest child is 8 and + # para 1 does not apply. Para 2: "A single claimant or a lone parent with + # whom a child is placed" by a local authority. is_child_or_young_person_for_legacy_benefits: [false, true, false] youngest_child_age_for_legacy_benefits: 8 + income_support_eligible: true + +- name: A single claimant with only a placed child is within Sch 1B para 2 + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed_child: + age: 10 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [claimant, placed_child] + income_support_assessable_capital: 0 + output: + is_lone_parent: false + income_support_eligible: true + +- name: A couple with a placed child are not within Sch 1B para 2 + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + partner: + age: 38 + is_claimant_or_partner: true + placed_child: + age: 3 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [claimant, partner, placed_child] + income_support_assessable_capital: 0 + output: + # Para 2 needs a single claimant or lone parent; neither partner cares. income_support_eligible: false # s.124(1)(aa) and (g): the claimant's age, and the partner's SPC entitlement. diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 4649202ba..24b587752 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -5,20 +5,23 @@ claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)) and a partner who takes over an award does so by claiming (Claims and Payments Regs 1987 reg 4(4)), so the claimant is the partner with the existing award. -Nobody else in a benefit unit is named, so: +No adult outside the couple is named, so: -- adding a member who is neither the claimant, the partner nor a child or - young person in the family (an adult outside the family, or a child placed - by a local authority, IS reg 16(4)) never changes income_support_eligible, - whatever that member's age, ESA, Income Support or caring; +- adding an adult who is neither the claimant, the partner nor a young person + in the family never changes income_support_eligible, whatever that adult's + age, ESA, Income Support or caring; - income_support_eligible equals a family-by-family reading of the model's gate: one of the claimant and partner reports Income Support, is under - state pension age, is a carer (or a lone parent of a child aged 5 or under, - the model's reading of Sch 1B para 1) and has no contributory ESA + state pension age, is in a prescribed category the model covers (a carer; + a lone parent of a child aged 5 or under, the model's reading of Sch 1B + para 1, counting only children in the household, reg 16(4); or a single + claimant with a child placed by a local authority, para 2) and has no + contributory ESA (s.124(1)(aa), (e), (h)); neither has income-related ESA (s.124(1)(h)), meaning the award on their reported amounts after the ESA capital test, or - an esa_income entered directly; and capital is within the Income Support - limit. + an esa_income the reported amounts do not explain (one that equals neither + the award on everyone's reported amounts nor their plain total); and + capital is within the Income Support limit. The second property is a reference check of the bounded model gate, not of legal entitlement: caring, ESA and Income Support are the model's reported @@ -67,12 +70,21 @@ def adult_inputs(draw, min_age=18): def families(draw): """A claimant, an optional partner, up to three dependants and capital. - Half the families are drawn at random. The rest are primed to sit at the - edge of eligibility, where an added member could change the result: a - claimant who reports Income Support and cares, or a lone parent who - reports it, does not care and has only children over 5. + Some families are drawn at random. The rest are primed to sit at the + edge of eligibility: + + - carer: a claimant who reports Income Support and cares; + - lone_parent: a lone parent who reports it, does not care and has only + children over 5; + - split_couple: one partner reports Income Support but fails a + condition, and the other qualifies but has no award, so the carer + cannot take the award over. + + A quarter of families also have a child placed by a local authority. """ - shape = draw(st.sampled_from(["random", "random", "carer", "lone_parent"])) + shape = draw( + st.sampled_from(["random", "random", "carer", "lone_parent", "split_couple"]) + ) n_dependants = draw(st.integers(1 if shape == "lone_parent" else 0, 3)) dependants = [] for _ in range(n_dependants): @@ -89,9 +101,27 @@ def families(draw): dependants.append(dependant) eldest_dependant = max([d["age"] for d in dependants], default=0) adults = [draw(adult_inputs(min_age=max(18, eldest_dependant + 16)))] - if shape != "lone_parent" and draw(st.booleans()): + if shape == "split_couple" or (shape != "lone_parent" and draw(st.booleans())): adults.append(draw(adult_inputs())) - if shape == "carer": + if shape == "split_couple": + failure = draw(st.sampled_from(["over_qualifying_age", "no_category", "esa"])) + adults[0].update( + PRIMED_CLAIMANT, + age=draw(st.integers(max(adults[0]["age"], 66), 90)) + if failure == "over_qualifying_age" + else min(adults[0]["age"], 65), + receives_carer_benefit=failure != "no_category", + care_hours=0, + esa_contrib_reported=3_000 if failure == "esa" else 0, + ) + adults[1].update( + age=min(adults[1]["age"], 65), + receives_carer_benefit=True, + income_support_reported=0, + esa_income_reported=0, + esa_contrib_reported=0, + ) + elif shape == "carer": adults[0].update( PRIMED_CLAIMANT, age=min(adults[0]["age"], 65), receives_carer_benefit=True ) @@ -102,6 +132,13 @@ def families(draw): receives_carer_benefit=False, care_hours=0, ) + if draw(st.integers(0, 3)) == 0: + dependants.append( + { + "age": draw(st.integers(0, 15)), + "is_looked_after_by_local_authority": True, + } + ) for adult in adults: adult["is_parent"] = n_dependants > 0 capital = draw(st.sampled_from([0, 6_250, 10_000, 20_000])) @@ -110,22 +147,27 @@ def families(draw): @st.composite def excluded_members(draw): - """A member who is neither claimant, partner nor in the family. + """An adult who is neither claimant, partner nor in the family. - Either an adult not in education (so a 16 to 19 year old is not a - qualifying young person), or a child placed by a local authority. + They are not in education, so a 16 to 19 year old is not a qualifying + young person. Most are primed with what barred or opened the claim when + every member counted: over state pension age, income-related ESA, an + Income Support report, or caring. """ - if draw(st.booleans()): - # Mostly 5 or under, the ages that could open the lone-parent route. - return { - "age": draw(st.one_of(st.integers(0, 5), st.integers(6, 15))), - "is_looked_after_by_local_authority": True, - "receives_carer_benefit": draw(st.booleans()), - } - return { + adult = { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", } + primed = draw(st.sampled_from(["random", "elderly", "esa", "award", "carer"])) + if primed == "elderly": + adult["age"] = draw(st.integers(66, 90)) + elif primed == "esa": + adult["esa_income_reported"] = 3_000 + elif primed == "award": + adult["income_support_reported"] = 1_000 + elif primed == "carer": + adult["receives_carer_benefit"] = True + return adult @st.composite @@ -137,8 +179,10 @@ def input_settings(draw, n): a simulation input and the formula does not run. """ capital_as_savings = draw(st.booleans()) + # £4,000 is neither a total of the reported amounts drawn here nor such a + # total less tariff income, so it is always read as entered directly. esa_income = ( - draw(st.lists(st.sampled_from([0, 3_000]), min_size=n, max_size=n)) + draw(st.lists(st.sampled_from([0, 4_000, 4_000]), min_size=n, max_size=n)) if draw(st.booleans()) else None ) @@ -152,8 +196,12 @@ def label(units, capital_as_savings, esa_income): ) event("esa_income entered directly" if esa_income else "esa_income calculated") extras = [extra for *_, extra in units if extra is not None] - if any(e.get("is_looked_after_by_local_authority") for e in extras): - event("placed child added") + if any( + d.get("is_looked_after_by_local_authority") + for _, dependants, *_ in units + for d in dependants + ): + event("family with a placed child") if any( e.get("income_support_reported") or e.get("esa_income_reported") for e in extras ): @@ -229,19 +277,25 @@ def reference_eligibility(adults, dependants, capital, esa_income, sp_age, param """The model's Income Support gate, read family by family.""" IS = parameters.gov.dwp.income_support ESA = parameters.gov.dwp.ESA.income.capital - child_ages = [d["age"] for d in dependants if d["age"] < 16] + # A child placed by a local authority is not a member of the household + # (reg 16(4)), so not a child for para 1, but brings a single claimant + # within para 2. + placed = [d for d in dependants if d.get("is_looked_after_by_local_authority")] + in_household = [d for d in dependants if d not in placed] + child_ages = [d["age"] for d in in_household if d["age"] < 16] lone_parent_with_young_child = ( len(adults) == 1 - and len(dependants) > 0 + and len(in_household) > 0 and min(child_ages, default=math.inf) <= IS.eligibility.lone_parent_youngest_child_age_limit ) + single_with_placed_child = len(adults) == 1 and len(placed) > 0 def is_claimant(adult, over_qualifying_age): carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 return ( adult["income_support_reported"] > 0 - and (carer or lone_parent_with_young_child) + and (carer or lone_parent_with_young_child or single_with_placed_child) and not over_qualifying_age and adult["esa_contrib_reported"] == 0 ) @@ -267,6 +321,25 @@ def is_claimant(adult, over_qualifying_age): ) +def explained_by_reports(esa_income, reported_total, capital, parameters): + """Whether an esa_income equals what the reported amounts give: the + award after the capital test, or their plain total.""" + ESA = parameters.gov.dwp.ESA.income.capital + tariff = ( + math.ceil( + max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + ) + * ESA.tariff_income.amount + * 52 + ) + award = ( + max(0, reported_total - tariff) + if reported_total > 0 and capital <= ESA.limit + else 0 + ) + return abs(esa_income - award) <= 0.005 or abs(esa_income - reported_total) <= 0.005 + + @SETTINGS @given(FAMILIES, st.data()) def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): @@ -280,11 +353,22 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 for i, (adults, dependants, capital, extra) in enumerate(units): + # An esa_income the reported amounts explain is read through them, + # like a calculated one. + entered = None + if esa_income is not None: + reported_total = sum( + member.get("esa_income_reported", 0) for member in adults + [extra] + ) + if not explained_by_reports( + esa_income[i], reported_total, capital, parameters + ): + entered = esa_income[i] expected = reference_eligibility( adults, dependants, capital, - None if esa_income is None else esa_income[i], + entered, sp_age[start : start + len(adults)], parameters, ) diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index 031c57aac..88e245e56 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -1,20 +1,24 @@ -"""An income-related ESA award entered directly bars Income Support only for -the period it was entered for. - -income_support_eligible takes an esa_income entered directly (an input, not -the formula) to be the claimant's or partner's award, because the reported -amounts do not say whose it is. Otherwise it uses the award on the claimant's -and partner's reported amounts. "Entered directly" has to follow the input: - -- set with set_input after the simulation is built; -- set on a branch; -- for this year only, not for another year. +"""Income-related ESA that is not the award on the reported amounts. + +income_support_eligible reads the claimant's and partner's reported +income-related ESA, after the esa_income capital test. When esa_income holds +anything else (an award entered directly, or a reform that replaces or +removes it), the reported amounts do not say whose award it is, so the gate +takes esa_income itself to be the claimant's or partner's. The test is on the +value the simulation reads, so it holds however that value got there: set +before or after the simulation is built, on a clone or a branch, for this year +or another, deleted and recalculated. + +In each case below an adult outside the couple reports £3,000 of +income-related ESA, so the award on the reported amounts is £3,000. That +report alone must never bar the claim. Entered awards use £4,000, which no +reported amount here produces. """ import numpy as np from policyengine_uk import Simulation -from policyengine_uk.utils.inputs import entered_directly +from policyengine_uk.utils.scenario import Scenario YEAR = 2025 CARER = { @@ -23,6 +27,13 @@ "receives_carer_benefit": {YEAR: True}, "income_support_reported": {YEAR: 1_000}, } +EXCLUDED_ADULT = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "esa_income_reported": {YEAR: 3_000}, +} +ENTERED = 4_000 def simulation(people, benunit=None): @@ -43,58 +54,8 @@ def simulation(people, benunit=None): ) -def test_award_set_after_construction_bars_income_support(): - sim = simulation({"carer": CARER}) - assert sim.calculate("income_support_eligible", YEAR)[0] - sim.set_input("esa_income", YEAR, np.array([3_000.0])) - sim.delete_arrays("income_support_eligible") - assert sim.calculate("esa_income", YEAR)[0] == 3_000 - assert not sim.calculate("income_support_eligible", YEAR)[0] - - -def test_award_set_on_a_branch_bars_income_support_there(): - sim = simulation({"carer": CARER}) - branch = sim.get_branch("with_esa", clone_system=False) - branch.set_input("esa_income", YEAR, np.array([3_000.0])) - assert not branch.calculate("income_support_eligible", YEAR)[0] - assert sim.calculate("income_support_eligible", YEAR)[0] - - -def test_award_entered_for_another_year_does_not_count_this_year(): - # An excluded adult reports ESA in 2025; esa_income is entered only for - # 2024. In 2025 esa_income comes from the formula, so the gate reads the - # claimant's and partner's reports, and the excluded adult's award does - # not bar the claim. - excluded_adult = { - "age": {YEAR: 30}, - "is_claimant_or_partner": {YEAR: False}, - "current_education": {YEAR: "NOT_IN_EDUCATION"}, - "esa_income_reported": {YEAR: 3_000}, - } - sim = simulation( - {"carer": CARER, "other_adult": excluded_adult}, - {"esa_income": {YEAR - 1: 0}}, - ) - assert sim.calculate("esa_income", YEAR)[0] == 3_000 - assert sim.calculate("income_support_eligible", YEAR)[0] - - -# The record of direct inputs follows the stored value through deletion, -# clones and branches (the ESA cases of #2025's -# test_entered_directly_lifecycle.py, which also covers jsa_income). In each, -# an adult outside the couple reports £3,000 of income-related ESA, so the -# formula's esa_income is £3,000; that report alone must never bar the claim. - -EXCLUDED_ADULT = { - "age": {YEAR: 30}, - "is_claimant_or_partner": {YEAR: False}, - "current_education": {YEAR: "NOT_IN_EDUCATION"}, - "esa_income_reported": {YEAR: 3_000}, -} - - -def family(): - return simulation({"carer": CARER, "other_adult": EXCLUDED_ADULT}) +def family(benunit=None): + return simulation({"carer": CARER, "other_adult": EXCLUDED_ADULT}, benunit) def eligible(sim): @@ -102,78 +63,115 @@ def eligible(sim): return bool(sim.calculate("income_support_eligible", YEAR)[0]) -def direct(sim, period=YEAR): - return entered_directly(sim.benunit, "esa_income", period) - - def test_the_excluded_adults_report_never_bars_the_claim(): sim = family() assert sim.calculate("esa_income", YEAR)[0] == 3_000 - assert not direct(sim) assert eligible(sim) -def test_a_deleted_input_does_not_make_a_later_formula_result_direct(): +def test_an_award_entered_in_the_situation_bars_the_claim(): + assert not eligible(family({"esa_income": {YEAR: ENTERED}})) + + +def test_an_award_set_after_construction_bars_the_claim(): sim = family() - sim.set_input("esa_income", YEAR, [0]) - assert direct(sim) and eligible(sim) - sim.delete_arrays("esa_income") - assert not direct(sim) - assert sim.calculate("esa_income", YEAR)[0] == 3_000 - assert not direct(sim) assert eligible(sim) + sim.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(sim) -def test_a_deleted_input_then_the_gate_reads_reports(): +def test_an_award_set_on_a_branch_bars_the_claim_there_only(): sim = family() - sim.set_input("esa_income", YEAR, [3_000]) - assert not eligible(sim) - sim.delete_arrays("esa_income") + branch = sim.get_branch("with_esa", clone_system=False) + branch.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(branch) assert eligible(sim) -def test_an_input_on_a_clone_does_not_reach_the_original(): - sim = family() +def test_an_award_entered_for_another_year_does_not_count_this_year(): + sim = family({"esa_income": {YEAR - 1: 0}}) assert sim.calculate("esa_income", YEAR)[0] == 3_000 - clone = sim.clone() - clone.set_input("esa_income", YEAR, [0]) - assert direct(clone) - assert not direct(sim) assert eligible(sim) -def test_a_parent_input_does_not_reach_an_earlier_branch(): +def test_a_deleted_award_is_recalculated_from_the_reports(): sim = family() + sim.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(sim) + sim.delete_arrays("esa_income") assert sim.calculate("esa_income", YEAR)[0] == 3_000 - branch = sim.get_branch("before", clone_system=False) - sim.set_input("esa_income", YEAR, [3_000]) - assert direct(sim) and not eligible(sim) - assert not direct(branch) - assert eligible(branch) + assert eligible(sim) -def test_a_branch_made_after_a_parent_input_inherits_it(): +def test_an_award_set_on_a_clone_does_not_reach_the_original(): sim = family() - sim.set_input("esa_income", YEAR, [3_000]) - branch = sim.get_branch("after", clone_system=False) - assert direct(branch) - assert not eligible(branch) + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + clone = sim.clone() + clone.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(clone) + assert eligible(sim) -def test_nested_branches_read_the_nearest_stored_input(): +def test_nested_branches_read_the_value_they_see(): sim = family() outer = sim.get_branch("outer", clone_system=False) - outer.set_input("esa_income", YEAR, [3_000]) + outer.set_input("esa_income", YEAR, np.array([ENTERED])) inner = outer.get_branch("inner", clone_system=False) - assert direct(inner) and not eligible(inner) - inner.set_input("esa_income", YEAR, [0]) - assert direct(inner) and eligible(inner) + assert not eligible(inner) + inner.set_input("esa_income", YEAR, np.array([0.0])) + assert eligible(inner) assert not eligible(outer) - assert not direct(sim) and eligible(sim) + assert eligible(sim) -def test_a_period_given_as_a_string_is_the_same_year(): - sim = family() - sim.set_input("esa_income", YEAR, [0]) - assert direct(sim, str(YEAR)) - assert not direct(sim, str(YEAR + 1)) +def test_a_zero_award_overrides_the_claimants_reported_esa(): + carer_with_esa = {**CARER, "esa_income_reported": {YEAR: 2_000}} + assert not eligible(simulation({"carer": carer_with_esa})) + assert eligible(simulation({"carer": carer_with_esa}, {"esa_income": {YEAR: 0}})) + + +def test_abolishing_income_related_esa_removes_the_bar(): + # A reform that neutralises esa_income pays no income-related ESA, so + # the claimant's reported award no longer bars Income Support. + sim = simulation({"carer": {**CARER, "esa_income_reported": {YEAR: 2_000}}}) + assert not eligible(sim) + sim.tax_benefit_system.neutralize_variable("esa_income") + sim.delete_arrays("esa_income") + assert sim.calculate("esa_income", YEAR)[0] == 0 + assert eligible(sim) + + +def test_the_plain_reported_total_is_read_through_the_reports(): + # disable_simulated_benefits sets esa_income to the plain total of the + # reported awards, before the capital test. With £10,000 of capital the + # tariff income (£832 a year) extinguishes the partner's own £200, so the + # claimant and partner have no income-related ESA; the £3,000 is the + # excluded adult's. + partner = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "esa_income_reported": {YEAR: 200}, + } + sim = simulation( + {"carer": CARER, "partner": partner, "other_adult": EXCLUDED_ADULT}, + {"esa_income_assessable_capital": {YEAR: 10_000}}, + ) + sim.set_input("esa_income", YEAR, np.array([3_200.0])) + assert eligible(sim) + + +def test_an_award_equal_to_the_reported_amounts_is_read_through_them(): + # Intended: an entered award of exactly the £3,000 the reports give is + # read as the excluded adult's award, so it does not bar the claim. + assert eligible(family({"esa_income": {YEAR: 3_000}})) + + +def test_a_simulation_with_no_inputs_calculates_the_gate(): + sim = Simulation( + situation={}, + scenario=Scenario( + applied_before_data_load=True, + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": False}, + ), + ) + assert sim.calculate("income_support_eligible", YEAR).shape == (1,) diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py deleted file mode 100644 index 828ce93aa..000000000 --- a/policyengine_uk/utils/inputs.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Whether a variable was entered directly as a simulation input.""" - -from policyengine_core import periods - - -def entered_directly(population, variable, period): - """Whether the value of ``variable`` for ``period`` is a user input. - - The simulation reads the value from the first of its visible branches - (its own, then its parents', then the default) that stores one, so this - asks whether that stored value was set with ``set_input``, when the - simulation was built or later. ``Simulation.input_variables`` cannot - answer this: it is fixed when the simulation is built and ignores the - period, so it misses later inputs and treats an input for one year as an - input for every year. - - Core records each explicit input as (variable, branch, period) in - ``_user_input_keys``. The UK ``Simulation`` keeps that record in step - with storage: a clone gets its own copy, and ``delete_arrays`` drops the - entries for the arrays it deletes, so a formula result stored later is - not taken for an input. - """ - simulation = population.simulation - period = periods.period(period) - keys = simulation._user_input_keys - stored = set(simulation.get_holder(variable).get_known_branch_periods()) - for branch in simulation._get_visible_branch_names(): - if (branch, period) in stored: - return (variable, branch, period) in keys - return False diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index e960d3386..9dae72c07 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,5 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award @@ -12,14 +11,17 @@ class income_support_eligible(Variable): "some of them, the claimant's partner. No new claims for Income " "Support can be made, and a partner who takes over an award makes a " "new claim, so the claimant is the one of the claimant and partner " - "who has the existing award (income_support_reported). They must be " - "under the qualifying age for State Pension Credit, fall within a " - "prescribed category (a carer, or a lone parent of a young child) " - "and not be entitled to Employment and Support Allowance. Neither " - "the claimant nor the partner may be entitled to income-related ESA. " - "A member of the benefit unit who is neither the claimant, the " - "partner nor a child or young person in the family (such as a " - "non-dependent adult) does not affect eligibility." + "who has the existing award. The model takes that to be whichever of " + "them reports Income Support (income_support_reported); if both do, " + "either can be the claimant. The claimant must be under the " + "qualifying age for State Pension Credit, fall within a prescribed " + "category the model covers (a carer, a lone parent of a young child, " + "or a single claimant with a child placed by a local authority) and " + "not be entitled to Employment and Support Allowance. Neither the " + "claimant nor the partner may be entitled to income-related ESA. An " + "adult in the benefit unit who is neither the claimant nor the " + "partner (such as a non-dependent adult) does not affect " + "eligibility." ) definition_period = YEAR reference = ( @@ -46,10 +48,14 @@ def formula(benunit, period, parameters): person("income_support_reported", period) > 0 ) # s.124(1)(e), reg 4ZA and Sch 1B: the claimant falls within a - # prescribed category. Para 1 is a lone parent responsible for a child - # under 5; Schedule 1B para 1 says "under 5", and the model retains - # the existing inclusive comparison pending a separate decision on - # the annual-age model. Para 4 is a carer. + # prescribed category. The model covers three of them. Para 1 is a + # lone parent responsible for a child under 5; Schedule 1B para 1 + # says "under 5", and the model retains the existing inclusive + # comparison pending a separate decision on the annual-age model. + # Para 2 is "a single claimant or a lone parent with whom a child is + # placed" by a local authority or voluntary organisation; the model's + # proxy is a member flagged is_looked_after_by_local_authority. Para 4 + # is a carer. youngest_child_5_or_under = ( benunit("youngest_child_age_for_legacy_benefits", period) <= IS.eligibility.lone_parent_youngest_child_age_limit @@ -57,8 +63,11 @@ def formula(benunit, period, parameters): lone_parent_with_young_child = ( benunit("is_lone_parent", period) & youngest_child_5_or_under ) + single_with_placed_child = benunit("is_single", period) & benunit.any( + person("is_looked_after_by_local_authority", period) & ~claimant_or_partner + ) prescribed_category = person("is_carer_for_benefits", period) | benunit.project( - lone_parent_with_young_child + lone_parent_with_young_child | single_with_placed_child ) # s.124(1)(aa): the claimant has not attained the qualifying age for # State Pension Credit, which is state pension age (SPCA 2002 s.1(6)). @@ -79,17 +88,27 @@ def formula(benunit, period, parameters): # income-related allowance. An income-related allowance covers the # couple, so it bars Income Support whichever of them has it: the # award on the claimant's and partner's reported amounts, after the - # same capital test as esa_income. When esa_income is entered - # directly for this period (an input, not the formula), the reported - # amounts do not say whose award it is, and it is taken to be the - # claimant's or partner's. - if entered_directly(benunit, "esa_income", period): - income_related_esa = benunit("esa_income", period) > 0 - else: - reported = benunit.sum( - person("esa_income_reported", period) * claimant_or_partner - ) - income_related_esa = income_related_esa_award(benunit, period, reported) > 0 + # same capital test as esa_income. When esa_income holds what the + # reported amounts give, either after that test (the formula) or as + # their plain total (the disable_simulated_benefits reform), the + # reports say whose award it is. When it holds anything else (an + # award entered directly, or a reform that replaces or removes it), + # they do not, and it is taken to be the claimant's or partner's. An + # entered award equal to either amount is read through the reports. + esa_income = benunit("esa_income", period) + reported_total = add(benunit, period, ["esa_income_reported"]) + award_on_all_reports = income_related_esa_award(benunit, period, reported_total) + award_on_claimant_or_partner_reports = income_related_esa_award( + benunit, + period, + benunit.sum(person("esa_income_reported", period) * claimant_or_partner), + ) + as_reported = np.isclose( + esa_income, award_on_all_reports, rtol=0, atol=0.005 + ) | np.isclose(esa_income, reported_total, rtol=0, atol=0.005) + income_related_esa = where( + as_reported, award_on_claimant_or_partner_reports > 0, esa_income > 0 + ) capital = benunit("income_support_assessable_capital", period) return ( benunit.any(claimant) diff --git a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py index 54bdcdbb6..b50b66873 100644 --- a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py @@ -8,11 +8,13 @@ class youngest_child_age_for_legacy_benefits(Variable): documentation = ( "Youngest child under 16 in the family for the legacy means-tested " "benefits (is_child_or_young_person_for_legacy_benefits). This " - "excludes the claimant and partner, and a child placed with the family " - "by a local authority, who is not a member of the claimant's household " - "(IS Regs 1987 reg 16(4)). SSCBA sections 137(1) and 142(1) use the " - "same child age definition. Infinity if the benefit unit contains no " - "such child." + "excludes the claimant and partner, and a child flagged " + "is_looked_after_by_local_authority: the model's proxy for a child " + "placed with the family by a local authority, or living away in its " + "care, who is not treated as a member of the claimant's household " + "(IS Regs 1987 reg 16(4)-(6)). SSCBA sections 137(1) and 142(1) use " + "the same child age definition. Infinity if the benefit unit contains " + "no such child." ) definition_period = YEAR unit = "year" From 79306e789caf7ff883cd671b1475d33a148cf6e0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:54:39 -0400 Subject: [PATCH 08/12] Run the differential property on 25 examples At 12 derandomized examples it never met a directly entered ESA award in an otherwise eligible family, so mutants that ignore the direct award (M4) or let the award and the conditions sit on different partners (M1) passed it. At 25 it fails both. The invariance property already ran 25. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_support_eligibility_properties.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 24b587752..e03df2f41 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -244,6 +244,9 @@ def situation(units, capital_as_savings, esa_income): # lone parent's family, a direct esa_income beside an added member's ESA), so # it runs more examples. INVARIANCE_SETTINGS = settings(SETTINGS, max_examples=25) +# The differential property needs a directly entered award to meet an otherwise +# eligible family, so it runs as many. +DIFFERENTIAL_SETTINGS = settings(SETTINGS, max_examples=25) FAMILIES = st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8) @@ -340,7 +343,7 @@ def explained_by_reports(esa_income, reported_total, capital, parameters): return abs(esa_income - award) <= 0.005 or abs(esa_income - reported_total) <= 0.005 -@SETTINGS +@DIFFERENTIAL_SETTINGS @given(FAMILIES, st.data()) def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) From c7f8a048c9b7d51d946f3386f331f53863a22494 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:03:25 -0400 Subject: [PATCH 09/12] Describe the income-related ESA screen without naming its tests esa_income_eligible is the screen esa_income applies. It is a capital test here, but #2031 (on #2025) adds remunerative-work tests to it, so the comments now name the variable rather than the test. Comment-only. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/esa_income.py | 4 ++-- policyengine_uk/variables/gov/dwp/income_support_eligible.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 2dc6a4415..ed3c43cdf 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 capital - test (esa_income_eligible).""" + income from capital, or nothing if the benefit unit fails the 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) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 9dae72c07..db43677db 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -88,8 +88,9 @@ def formula(benunit, period, parameters): # income-related allowance. An income-related allowance covers the # couple, so it bars Income Support whichever of them has it: the # award on the claimant's and partner's reported amounts, after the - # same capital test as esa_income. When esa_income holds what the - # reported amounts give, either after that test (the formula) or as + # same screen as esa_income (esa_income_eligible). When esa_income + # holds what the reported amounts give, either after that screen (the + # formula) or as # their plain total (the disable_simulated_benefits reform), the # reports say whose award it is. When it holds anything else (an # award entered directly, or a reform that replaces or removes it), From 44d59d90908f63b117380d25dfcf67da1c052f3e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:03:48 -0400 Subject: [PATCH 10/12] Reflow the income-related ESA comment in income_support_eligible Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/dwp/income_support_eligible.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index db43677db..1bfb9bd9d 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -90,12 +90,12 @@ def formula(benunit, period, parameters): # award on the claimant's and partner's reported amounts, after the # same screen as esa_income (esa_income_eligible). When esa_income # holds what the reported amounts give, either after that screen (the - # formula) or as - # their plain total (the disable_simulated_benefits reform), the - # reports say whose award it is. When it holds anything else (an - # award entered directly, or a reform that replaces or removes it), - # they do not, and it is taken to be the claimant's or partner's. An - # entered award equal to either amount is read through the reports. + # formula) or as their plain total (the disable_simulated_benefits + # reform), the reports say whose award it is. When it holds anything + # else (an award entered directly, or a reform that replaces or + # removes it), they do not, and it is taken to be the claimant's or + # partner's. An entered award equal to either amount is read through + # the reports. esa_income = benunit("esa_income", period) reported_total = add(benunit, period, ["esa_income_reported"]) award_on_all_reports = income_related_esa_award(benunit, period, reported_total) From 3402629f178387fb594512b0c72930acf13e6bea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 04:07:45 -0400 Subject: [PATCH 11/12] Compare esa_income in its stored precision; qualify the excluded-adult claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2025's review round 4 found two things that apply to this ESA limb. - esa_income is stored as float32, but the award and plain total the gate recomputes are float64. With two reports of £65,536.01 and £65,536.00, the stored award (£131,072.00) is 0.0078 below the float64 total, so the formula's own award looked entered and barred the claim. The gate now casts both to the stored dtype before comparing. The new test fails on the previous gate. - Under the value rule, a directly entered award is read through the reports when it equals what they give, so another member's report can change how an entry is read. For example, £4,000 entered with no reports bars the claim; once an excluded adult reports £4,000, the claim is eligible. That is the declared convention. The variable documentation and the invariance bullet in the property docstring now say so, and a test pins the transition. Co-Authored-By: Claude Opus 5.5 --- ...t_income_support_eligibility_properties.py | 5 +++- ...est_income_support_esa_entered_directly.py | 28 +++++++++++++++++++ .../gov/dwp/income_support_eligible.py | 13 +++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index e03df2f41..96bbceea2 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -9,7 +9,10 @@ - adding an adult who is neither the claimant, the partner nor a young person in the family never changes income_support_eligible, whatever that adult's - age, ESA, Income Support or caring; + age, ESA, Income Support or caring. The declared exception, outside the + values drawn here, is an esa_income entered directly that the new adult's + report makes equal to what the reports give: it is then read through the + reports (test_income_support_esa_entered_directly.py); - income_support_eligible equals a family-by-family reading of the model's gate: one of the claimant and partner reports Income Support, is under state pension age, is in a prescribed category the model covers (a carer; diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index 88e245e56..9aa2d8092 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -166,6 +166,34 @@ def test_an_award_equal_to_the_reported_amounts_is_read_through_them(): assert eligible(family({"esa_income": {YEAR: 3_000}})) +def test_large_reported_awards_match_the_stored_award(): + # esa_income is stored as float32; £65,536.01 + £65,536.00 is stored as + # £131,072.00, a penny below the exact total. The formula's own award must + # still be read through the reports. + big = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: 65_536.01}} + big_too = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: 65_536.00}} + sim = simulation({"carer": CARER, "a": big, "b": big_too}) + assert eligible(sim) + # disable_simulated_benefits enters the plain total the model computes. + raw_total = sim.calculate("esa_income_reported", YEAR, map_to="benunit") + sim.set_input("esa_income", YEAR, raw_total) + assert eligible(sim) + + +def test_a_report_can_change_how_an_entered_award_is_read(): + # Intended: the reports explain an entered award only when they give the + # same amount. £4,000 entered with no reports bars the claim; once an + # excluded adult reports £4,000, the entry is read as theirs. + assert not eligible(simulation({"carer": CARER}, {"esa_income": {YEAR: ENTERED}})) + four_thousand = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: ENTERED}} + assert eligible( + simulation( + {"carer": CARER, "other_adult": four_thousand}, + {"esa_income": {YEAR: ENTERED}}, + ) + ) + + def test_a_simulation_with_no_inputs_calculates_the_gate(): sim = Simulation( situation={}, diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 1bfb9bd9d..faafe206a 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -21,7 +21,11 @@ class income_support_eligible(Variable): "claimant nor the partner may be entitled to income-related ESA. An " "adult in the benefit unit who is neither the claimant nor the " "partner (such as a non-dependent adult) does not affect " - "eligibility." + "eligibility, with one declared exception: an esa_income entered " + "directly is read through the reported awards when it equals what " + "they give (the award after esa_income_eligible, or their plain " + "total, to within half a penny), so another member's report can " + "change how such an entry is read." ) definition_period = YEAR reference = ( @@ -104,9 +108,12 @@ def formula(benunit, period, parameters): period, benunit.sum(person("esa_income_reported", period) * claimant_or_partner), ) + # Compare in the precision esa_income is stored in (float32), so the + # formula's own award always matches the award recomputed here. + stored = esa_income.dtype as_reported = np.isclose( - esa_income, award_on_all_reports, rtol=0, atol=0.005 - ) | np.isclose(esa_income, reported_total, rtol=0, atol=0.005) + esa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 + ) | np.isclose(esa_income, reported_total.astype(stored), rtol=0, atol=0.005) income_related_esa = where( as_reported, award_on_claimant_or_partner_reports > 0, esa_income > 0 ) From 0953c8aa6d868e6d2bfe0f223163f1af14738d08 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:45:54 -0400 Subject: [PATCH 12/12] Limit Sch 1B para 2 to placed children under 16; zero ESA never bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r3 of this PR, and #2025's review round 5: - Sch 1B para 2 covers a placed "child", which is a person under 16 (SSCBA s.137(1)). The gate now requires the placed member to be under 16 (is_child_for_child_benefit). YAML pins the boundary: a placed 15 year old counts, a placed 16 year old does not. The property generator draws placed members aged up to 17, and the oracle applies the limit. - A zero esa_income could still bar the claim. Reports of £10.01 and £41.99 sum a little above £52 in stored precision, so after £52 of tariff income the formula leaves a sub-penny residual. A zero entry, or a neutralised esa_income, then matched that residual within half a penny and was read through the reports. The bar now also requires esa_income > 0. - The documentation now says the exception covers any stored value that is not the formula's own result: entered directly, or replaced, scaled or removed by a reform. The comparison is within half a penny after rounding to the stored precision. A tolerance test pins £100.004 against £100.006. - The para 2 comment says the looked-after flag also marks children living away in care, whom para 2 does not cover. Co-Authored-By: Claude Opus 5.5 --- ...income_support_claimant_partner_gates.yaml | 31 ++++++++++++++ ...t_income_support_eligibility_properties.py | 6 ++- ...est_income_support_esa_entered_directly.py | 35 ++++++++++++++++ .../gov/dwp/income_support_eligible.py | 42 ++++++++++++------- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml index 89c25b31d..548439b81 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml @@ -226,6 +226,37 @@ is_lone_parent: false income_support_eligible: true +- name: A placed member aged 15 is a child for Sch 1B para 2, aged 16 is not + period: 2025 + input: + people: + claimant1: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed15: + age: 15 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + claimant2: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed16: + age: 16 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family1: + members: [claimant1, placed15] + income_support_assessable_capital: 0 + family2: + members: [claimant2, placed16] + income_support_assessable_capital: 0 + output: + # A child is a person under 16 (SSCBA 1992 s.137(1)). + income_support_eligible: [true, false] + - name: A couple with a placed child are not within Sch 1B para 2 period: 2025 input: diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 96bbceea2..0c30732c4 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -138,7 +138,8 @@ def families(draw): if draw(st.integers(0, 3)) == 0: dependants.append( { - "age": draw(st.integers(0, 15)), + # Up to 17: para 2 covers only a placed child under 16. + "age": draw(st.integers(0, 17)), "is_looked_after_by_local_authority": True, } ) @@ -295,7 +296,8 @@ def reference_eligibility(adults, dependants, capital, esa_income, sp_age, param and min(child_ages, default=math.inf) <= IS.eligibility.lone_parent_youngest_child_age_limit ) - single_with_placed_child = len(adults) == 1 and len(placed) > 0 + # A child is under 16 (SSCBA s.137(1)). + single_with_placed_child = len(adults) == 1 and any(d["age"] < 16 for d in placed) def is_claimant(adult, over_qualifying_age): carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index 9aa2d8092..ee6703fd8 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -194,6 +194,41 @@ def test_a_report_can_change_how_an_entered_award_is_read(): ) +def test_a_zero_award_never_bars_the_claim(): + # £10.01 + £41.99 sums a little above £52 in stored precision, so after + # £52 of tariff income (capital £6,250) the formula leaves a sub-penny + # residual. Entering zero, or abolishing income-related ESA, pays no ESA, + # so neither may bar Income Support. + partner = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "esa_income_reported": {YEAR: 41.99}, + } + carer = {**CARER, "esa_income_reported": {YEAR: 10.01}} + capital = {"esa_income_assessable_capital": {YEAR: 6_250}} + entered_zero = simulation( + {"carer": carer, "partner": partner}, {**capital, "esa_income": {YEAR: 0}} + ) + assert entered_zero.calculate("esa_income", YEAR)[0] == 0 + assert eligible(entered_zero) + abolished = simulation({"carer": carer, "partner": partner}, capital) + abolished.tax_benefit_system.neutralize_variable("esa_income") + assert eligible(abolished) + + +def test_the_tolerance_is_half_a_penny(): + # An entered £100 is read through an excluded adult's report of £100.004 + # (within half a penny) but not £100.006, which leaves it the couple's. + def with_report(amount): + reporter = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: amount}} + return simulation( + {"carer": CARER, "other_adult": reporter}, {"esa_income": {YEAR: 100}} + ) + + assert eligible(with_report(100.004)) + assert not eligible(with_report(100.006)) + + def test_a_simulation_with_no_inputs_calculates_the_gate(): sim = Simulation( situation={}, diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index faafe206a..14a01ed0f 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -21,11 +21,14 @@ class income_support_eligible(Variable): "claimant nor the partner may be entitled to income-related ESA. An " "adult in the benefit unit who is neither the claimant nor the " "partner (such as a non-dependent adult) does not affect " - "eligibility, with one declared exception: an esa_income entered " - "directly is read through the reported awards when it equals what " - "they give (the award after esa_income_eligible, or their plain " - "total, to within half a penny), so another member's report can " - "change how such an entry is read." + "eligibility, with a declared exception for a stored esa_income " + "that is not the formula's own result. Whether entered directly or " + "replaced by a reform (including one that scales it), such a value " + "is read through the reported awards when it equals what they give " + "(the award after esa_income_eligible, or their plain total, to " + "within half a penny after rounding to the precision it is stored " + "in), and is otherwise taken to be the claimant's or partner's. So " + "another member's report can change how such a value is read." ) definition_period = YEAR reference = ( @@ -57,9 +60,11 @@ def formula(benunit, period, parameters): # says "under 5", and the model retains the existing inclusive # comparison pending a separate decision on the annual-age model. # Para 2 is "a single claimant or a lone parent with whom a child is - # placed" by a local authority or voluntary organisation; the model's - # proxy is a member flagged is_looked_after_by_local_authority. Para 4 - # is a carer. + # placed" by a local authority or voluntary organisation. A child is + # under 16 (SSCBA s.137(1)). The model's proxy is a member under 16 + # flagged is_looked_after_by_local_authority; the flag also marks a + # child living away in a local authority's care, whom para 2 does not + # cover, and the model cannot tell the two apart. Para 4 is a carer. youngest_child_5_or_under = ( benunit("youngest_child_age_for_legacy_benefits", period) <= IS.eligibility.lone_parent_youngest_child_age_limit @@ -67,8 +72,13 @@ def formula(benunit, period, parameters): lone_parent_with_young_child = ( benunit("is_lone_parent", period) & youngest_child_5_or_under ) + placed_child = ( + person("is_looked_after_by_local_authority", period) + & person("is_child_for_child_benefit", period) + & ~claimant_or_partner + ) single_with_placed_child = benunit("is_single", period) & benunit.any( - person("is_looked_after_by_local_authority", period) & ~claimant_or_partner + placed_child ) prescribed_category = person("is_carer_for_benefits", period) | benunit.project( lone_parent_with_young_child | single_with_placed_child @@ -96,10 +106,12 @@ def formula(benunit, period, parameters): # holds what the reported amounts give, either after that screen (the # formula) or as their plain total (the disable_simulated_benefits # reform), the reports say whose award it is. When it holds anything - # else (an award entered directly, or a reform that replaces or - # removes it), they do not, and it is taken to be the claimant's or - # partner's. An entered award equal to either amount is read through - # the reports. + # else (an award entered directly, or a reform that replaces, scales + # or removes it), they do not, and it is taken to be the claimant's + # or partner's. A stored value equal to either amount, to within half + # a penny after rounding to its stored precision, is read through the + # reports. Either way, no income-related ESA is paid when esa_income + # is zero, so a zero never bars the claim. esa_income = benunit("esa_income", period) reported_total = add(benunit, period, ["esa_income_reported"]) award_on_all_reports = income_related_esa_award(benunit, period, reported_total) @@ -114,8 +126,8 @@ def formula(benunit, period, parameters): as_reported = np.isclose( esa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 ) | np.isclose(esa_income, reported_total.astype(stored), rtol=0, atol=0.005) - income_related_esa = where( - as_reported, award_on_claimant_or_partner_reports > 0, esa_income > 0 + income_related_esa = (esa_income > 0) & ( + ~as_reported | (award_on_claimant_or_partner_reports > 0) ) capital = benunit("income_support_assessable_capital", period) return (