From 13a8fa0c9de8b8159d9827aea450f26bc07783d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:52:45 -0400 Subject: [PATCH 1/3] Narrow the legacy award readers property to what is true test_other_members_awards_never_change_the_readers claimed that adding a benefit-unit member who is not the claimant, the partner or a dependant never changes a reader of the legacy income-related awards. An added adult aged 18 or over is a non-dependant, which ends the family's severe disability premium (IS Regs 1987 Sch 2 para 13(2)(a)(ii)); Income Support falls by the premium and so does the council tax reduction income that counts it. The test passed only because its 15 derandomised examples missed that case. The property now builds each family three ways (without the member, with them reporting nothing, with them as drawn) and checks: - what the member reports never changes a reader or the premium; - their presence changes a reader only where it ends the premium, and only a member aged 18 or over does that. The counterexample is pinned as explicit examples, with a fixed test that Income Support and the council tax reduction income fall by exactly the premium and no other reader moves. Fixes #2293 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2293.fixed.md | 1 + .../test_legacy_award_readers_properties.py | 130 ++++++++++++++++-- 2 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 changelog.d/2293.fixed.md diff --git a/changelog.d/2293.fixed.md b/changelog.d/2293.fixed.md new file mode 100644 index 0000000000..ec67c5eb29 --- /dev/null +++ b/changelog.d/2293.fixed.md @@ -0,0 +1 @@ +- Corrected a property test that claimed adding an adult outside the claimant's family never changes the legacy award readers: an adult non-dependant ends the family's severe disability premium, so the test now checks that what such a member reports never changes a reader and that their presence changes one only through that premium. diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 7c6ef57d72..b5c23ed3a5 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -25,9 +25,15 @@ child or young person they are responsible for (for example a non-dependent adult) claims in their own right, so: -- adding such a member, whatever Income Support, income-based JSA or - income-related ESA they report, never changes any of these readers for the - claimant's family or for its existing members; +- whatever Income Support, income-based JSA or income-related ESA such a + member reports never changes any of these readers for the claimant's family + or for its existing members; +- adding such a member changes a reader only by ending the family's severe + disability premium, which a non-dependant aged 18 or over does (IS Regs + 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). The + family's Income Support then falls by the premium, and so does the council + tax reduction income that counts it. A member under 18 never changes a + reader; - the claimant-or-partner awards are bounded by the benefit-unit awards (0 <= claimant_or_partner_esa_income <= esa_income, likewise for JSA) and equal them when no other member reports an award; @@ -45,10 +51,11 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system YEAR = 2025 # Merton's working-age council tax reduction scheme has parameters from @@ -204,37 +211,132 @@ def situation(units, year=YEAR): ) +NO_AWARDS = { + "esa_income_reported": 0, + "jsa_income_reported": 0, + "income_support_reported": 0, +} +# A disabled carer aged 18 on Income Support, who has the severe disability +# premium until an adult non-dependant joins the household. Hypothesis found +# this family (PolicyEngine/policyengine-uk#2293). +SEVERELY_DISABLED_CLAIMANT = ( + [ + { + "age": 18, + "employment_income": 0, + "is_parent": False, + "receives_carer_benefit": True, + "is_disabled_for_benefits": True, + "pip_dl": 2_000, + "care_hours": 0, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 0, + "jsa_income_reported": 0, + "income_support_reported": 200, + } + ], + [], + {"country": "ENGLAND", "savings": 0}, +) + + +def outside_adult(age, **awards): + return {"age": age, "current_education": "NOT_IN_EDUCATION", **NO_AWARDS, **awards} + + @SETTINGS +@example([(SEVERELY_DISABLED_CLAIMANT, outside_adult(18))]) +@example( + [(SEVERELY_DISABLED_CLAIMANT, outside_adult(18, income_support_reported=3_000))] +) +@example([(SEVERELY_DISABLED_CLAIMANT, outside_adult(17, esa_income_reported=3_000))]) @given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) def test_other_members_awards_never_change_the_readers(drawn): + # Each family three ways: without the other member, with them reporting + # no award, and with them as drawn. without = [(*family, None) for family, _ in drawn] + silent = [(*family, {**other, **NO_AWARDS}) for family, other in drawn] with_other = [(*family, other) for family, other in drawn] - sim = Simulation(situation=situation(without + with_other)) + units = without + silent + with_other + sim = Simulation(situation=situation(units)) 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) + (o is not None) for a, d, _, o in without + with_other] + sizes = [len(a) + len(d) + (o is not None) for a, d, _, o in units] starts = np.cumsum([0] + sizes[:-1]) k = len(drawn) - for i in range(k): + for i in range(k, 3 * k): # The added member is the last of its benefit unit: neither claimant, # partner nor a dependant. - last = starts[k + i] + sizes[k + i] - 1 - assert not flags[last] and not dependant[last], drawn[i] + last = starts[i] + sizes[i] - 1 + assert not flags[last] and not dependant[last], drawn[i % k] + # A non-dependant aged 18 or over ends the family's severe disability + # premium (IS Regs 1987 Sch 2 para 13(2)(a)(ii)); nobody younger does, and + # what the member reports makes no difference. + non_dependant_age = system.parameters( + f"{YEAR}-06-01" + ).gov.dwp.disability_premia.severe_non_dependant_age + premium = sim.calculate("severe_disability_premium", YEAR) + premium_ended = [ + not np.isclose(premium[i], premium[k + i], atol=0.01) for i in range(k) + ] + for i, (_, other) in enumerate(drawn): + assert np.isclose(premium[k + i], premium[2 * k + i], atol=0.01), drawn[i] + if premium_ended[i]: + assert premium[k + i] == 0 < premium[i], drawn[i] + assert other["age"] >= non_dependant_age, drawn[i] for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): - assert np.isclose(values[i], values[k + i], atol=0.01), ( + # What the other member reports never changes a reader. + assert np.isclose(values[k + i], values[2 * k + i], atol=0.01), ( variable, drawn[i], ) + # Their presence changes one only by ending the premium. + if not premium_ended[i]: + assert np.isclose(values[i], values[k + i], atol=0.01), ( + variable, + drawn[i], + ) for variable in MEMBER_READERS: values = sim.calculate(variable, YEAR) for i in range(k): n = sizes[i] - assert np.array_equal( - values[starts[i] : starts[i] + n], - values[starts[k + i] : starts[k + i] + n], - ), (variable, drawn[i]) + existing = [ + values[starts[j] : starts[j] + n] for j in (i, k + i, 2 * k + i) + ] + assert np.array_equal(existing[1], existing[2]), (variable, drawn[i]) + if not premium_ended[i]: + assert np.array_equal(existing[0], existing[1]), (variable, drawn[i]) + + +def test_adult_non_dependant_ends_the_severe_disability_premium(): + # The family Hypothesis found, with an added member aged 17, then 18. + # Only the 18-year-old is a non-dependant who ends the premium; Income + # Support falls by exactly the premium, and so does the council tax + # reduction income that counts Income Support. + units = [ + (*SEVERELY_DISABLED_CLAIMANT, None), + (*SEVERELY_DISABLED_CLAIMANT, outside_adult(17)), + (*SEVERELY_DISABLED_CLAIMANT, outside_adult(18)), + ] + sim = Simulation(situation=situation(units)) + premium = sim.calculate("severe_disability_premium", YEAR) + income_support = sim.calculate("income_support", YEAR) + council_tax_reduction_income = sim.calculate( + "council_tax_reduction_applicable_income", YEAR + ) + assert premium[0] > 0 + assert premium[1] == premium[0] and premium[2] == 0 + for values in (income_support, council_tax_reduction_income): + assert np.isclose(values[1], values[0], atol=0.01) + assert np.isclose(values[0] - values[2], premium[0], atol=0.01) + # Every other reader is the same in all three. + for variable in FAMILY_READERS: + if variable == "council_tax_reduction_applicable_income": + continue + values = sim.calculate(variable, YEAR) + assert np.allclose(values, values[0], atol=0.01), variable @SETTINGS From 4380010bf3e3d28ab16ea17c30454d3abfff1d4b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 22:38:55 -0400 Subject: [PATCH 2/3] Hold the benefit cap and bound the Income Support fall in the readers property Review round 1 of #2294: - The benefit cap sums the whole unit's income-based JSA and income-related ESA, so an added member's own award can raise the cap reduction and lower Universal Credit and the council tax reduction income. That is a model defect, filed as #2298. The property holds the cap at zero until it is fixed and pins the family the review found. - Income Support falls by at most the lost premium (an award is never negative), not always by all of it. The docstring says so and the test asserts the bound. The awards the family's own members report, and whether a member is on one, must not change even when the premium ends. - The adult non-dependant rule is qualified to these fixtures (para 13(3)). Co-Authored-By: Claude Opus 5.5 --- changelog.d/2293.fixed.md | 2 +- .../test_legacy_award_readers_properties.py | 78 ++++++++++++++++--- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/changelog.d/2293.fixed.md b/changelog.d/2293.fixed.md index ec67c5eb29..7042dd3079 100644 --- a/changelog.d/2293.fixed.md +++ b/changelog.d/2293.fixed.md @@ -1 +1 @@ -- Corrected a property test that claimed adding an adult outside the claimant's family never changes the legacy award readers: an adult non-dependant ends the family's severe disability premium, so the test now checks that what such a member reports never changes a reader and that their presence changes one only through that premium. +- Corrected a property test that claimed adding an adult outside the claimant's family never changes the legacy award readers: an adult non-dependant ends the family's severe disability premium, so the test now checks that what such a member reports never changes a reader, that their presence changes one only through that premium, and that Income Support then falls by at most the premium. The benefit cap, which counts such a member's own awards, is held at zero in that test until #2298 is fixed. diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index b5c23ed3a5..e154f352ed 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -30,10 +30,14 @@ or for its existing members; - adding such a member changes a reader only by ending the family's severe disability premium, which a non-dependant aged 18 or over does (IS Regs - 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). The - family's Income Support then falls by the premium, and so does the council - tax reduction income that counts it. A member under 18 never changes a - reader; + 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). That + rule is subject to para 13(3), which ignores a non-dependant on a + qualifying disability benefit, a blind one and some carers; the added + members here are none of those. The family's Income Support then falls by + at most the premium: less where the award is already below it, since an + award is never negative, and not at all where the family is not entitled. + The awards the family's own members report never change, and nor does + whether a member is on one. A member under 18 never changes a reader; - the claimant-or-partner awards are bounded by the benefit-unit awards (0 <= claimant_or_partner_esa_income <= esa_income, likewise for JSA) and equal them when no other member reports an award; @@ -85,6 +89,14 @@ "is_on_income_based_jsa", "is_on_income_support", ] +# Readers built from the members' own reports and the household's capital +# alone, which the severe disability premium cannot reach. +PREMIUM_FREE_READERS = [ + "claimant_or_partner_esa_income", + "claimant_or_partner_jsa_income", + "is_on_income_related_esa", + "is_on_income_based_jsa", +] AWARDS = st.sampled_from([0, 0, 200, 3_000]) @@ -177,7 +189,7 @@ def other_members(draw): } -def situation(units, year=YEAR): +def situation(units, year=YEAR, benunit_inputs=None): people, benunits, households = {}, {}, {} for i, (adults, non_couple, household, other) in enumerate(units): members = [(m, True) for m in adults] + [(m, False) for m in non_couple] @@ -195,6 +207,7 @@ def situation(units, year=YEAR): benunits[f"b{i}"] = { "members": names, "claims_all_entitled_benefits": {year: False}, + **{k: {year: v} for k, v in (benunit_inputs or {}).items()}, } households[f"h{i}"] = { "members": names, @@ -244,12 +257,45 @@ def outside_adult(age, **awards): return {"age": age, "current_education": "NOT_IN_EDUCATION", **NO_AWARDS, **awards} +def parent(age, **awards): + return { + "age": age, + "employment_income": 0, + "is_parent": True, + "receives_carer_benefit": False, + "care_hours": 0, + "current_education": "NOT_IN_EDUCATION", + **NO_AWARDS, + **awards, + } + + +# A couple on income-based JSA with two children and a lodger already in the +# benefit unit. The benefit cap sums the whole unit's JSA, so an added +# member's own JSA raised the cap reduction, which lowers Universal Credit +# and so the council tax reduction income +# (PolicyEngine/policyengine-uk#2298). The review of +# PolicyEngine/policyengine-uk#2294 found this family. +JSA_COUPLE_WITH_LODGER = ( + [parent(35, jsa_income_reported=3_000), parent(35, jsa_income_reported=3_000)], + [{"age": 15}, {"age": 15}, outside_adult(30)], + {"country": "ENGLAND", "savings": 0}, +) +# The benefit cap counts every member's own income-based JSA and +# income-related ESA, not only the claimant's and partner's +# (PolicyEngine/policyengine-uk#2298). The cap is not one of the readers this +# file tests, so it is held at zero here until that is fixed; drop the hold +# when it is. +CAP_HELD = {"benefit_cap_reduction": 0} + + @SETTINGS @example([(SEVERELY_DISABLED_CLAIMANT, outside_adult(18))]) @example( [(SEVERELY_DISABLED_CLAIMANT, outside_adult(18, income_support_reported=3_000))] ) @example([(SEVERELY_DISABLED_CLAIMANT, outside_adult(17, esa_income_reported=3_000))]) +@example([(JSA_COUPLE_WITH_LODGER, outside_adult(20, jsa_income_reported=3_000))]) @given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) def test_other_members_awards_never_change_the_readers(drawn): # Each family three ways: without the other member, with them reporting @@ -258,7 +304,7 @@ def test_other_members_awards_never_change_the_readers(drawn): silent = [(*family, {**other, **NO_AWARDS}) for family, other in drawn] with_other = [(*family, other) for family, other in drawn] units = without + silent + with_other - sim = Simulation(situation=situation(units)) + sim = Simulation(situation=situation(units, benunit_inputs=CAP_HELD)) 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) + (o is not None) for a, d, _, o in units] @@ -284,6 +330,12 @@ def test_other_members_awards_never_change_the_readers(drawn): if premium_ended[i]: assert premium[k + i] == 0 < premium[i], drawn[i] assert other["age"] >= non_dependant_age, drawn[i] + # Income Support falls by at most the premium, and never rises. + income_support = sim.calculate("income_support", YEAR) + for i in range(k): + fall = income_support[i] - income_support[k + i] + lost = premium[i] - premium[k + i] + assert -0.01 <= fall <= lost + 0.01, drawn[i] for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): @@ -292,8 +344,9 @@ def test_other_members_awards_never_change_the_readers(drawn): variable, drawn[i], ) - # Their presence changes one only by ending the premium. - if not premium_ended[i]: + # Their presence changes one only by ending the premium, and + # never the awards the family's own members report. + if not premium_ended[i] or variable in PREMIUM_FREE_READERS: assert np.isclose(values[i], values[k + i], atol=0.01), ( variable, drawn[i], @@ -306,15 +359,16 @@ def test_other_members_awards_never_change_the_readers(drawn): values[starts[j] : starts[j] + n] for j in (i, k + i, 2 * k + i) ] assert np.array_equal(existing[1], existing[2]), (variable, drawn[i]) - if not premium_ended[i]: + if not premium_ended[i] or variable in PREMIUM_FREE_READERS: assert np.array_equal(existing[0], existing[1]), (variable, drawn[i]) def test_adult_non_dependant_ends_the_severe_disability_premium(): # The family Hypothesis found, with an added member aged 17, then 18. - # Only the 18-year-old is a non-dependant who ends the premium; Income - # Support falls by exactly the premium, and so does the council tax - # reduction income that counts Income Support. + # Only the 18-year-old is a non-dependant who ends the premium. This + # family's Income Support is above the premium, so it falls by exactly + # the premium, and so does the council tax reduction income that counts + # Income Support. units = [ (*SEVERELY_DISABLED_CLAIMANT, None), (*SEVERELY_DISABLED_CLAIMANT, outside_adult(17)), From 757e4061749f57e744a6ce391bf36bf5a0d7dfe5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:06:15 -0400 Subject: [PATCH 3/3] Restrict the readers property's receipt claim to ESA and JSA Review round 2 of #2294: whether a member is on Income Support can change when the lost severe disability premium takes the award to zero. The docstring and the PREMIUM_FREE_READERS comment now say that only the ESA and JSA awards and receipt flags never change. Docstring and comment only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_legacy_award_readers_properties.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index e154f352ed..5cba93171c 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -36,8 +36,11 @@ members here are none of those. The family's Income Support then falls by at most the premium: less where the award is already below it, since an award is never negative, and not at all where the family is not entitled. - The awards the family's own members report never change, and nor does - whether a member is on one. A member under 18 never changes a reader; + The family's income-related ESA and income-based JSA awards never change, + and nor does whether a member is on one of them: they read the reports and + capital only. Whether a member is on Income Support can change, when the + lost premium takes the award to zero. A member under 18 never changes a + reader; - the claimant-or-partner awards are bounded by the benefit-unit awards (0 <= claimant_or_partner_esa_income <= esa_income, likewise for JSA) and equal them when no other member reports an award; @@ -89,8 +92,10 @@ "is_on_income_based_jsa", "is_on_income_support", ] -# Readers built from the members' own reports and the household's capital -# alone, which the severe disability premium cannot reach. +# Readers built from the members' own ESA and JSA reports and the +# household's capital alone, which the severe disability premium cannot reach. +# Income Support is not among them: losing the premium can take its award to +# zero, and is_on_income_support with it. PREMIUM_FREE_READERS = [ "claimant_or_partner_esa_income", "claimant_or_partner_jsa_income",