Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2293.fixed.md
Original file line number Diff line number Diff line change
@@ -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, 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.
191 changes: 176 additions & 15 deletions policyengine_uk/tests/test_legacy_award_readers_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,22 @@
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)). 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 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;
Expand All @@ -45,10 +58,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
Expand Down Expand Up @@ -78,6 +92,16 @@
"is_on_income_based_jsa",
"is_on_income_support",
]
# 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",
"is_on_income_related_esa",
"is_on_income_based_jsa",
]
AWARDS = st.sampled_from([0, 0, 200, 3_000])


Expand Down Expand Up @@ -170,7 +194,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]
Expand All @@ -188,6 +212,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,
Expand All @@ -204,37 +229,173 @@ 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}


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
# 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, 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 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]
# 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):
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, 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],
)
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] 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. 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)),
(*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
Expand Down
Loading