From 60ead1e19905c256521a70ce0e6cddb0d8bc74d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:16:42 +0000 Subject: [PATCH 01/20] Apply SI 2014/1230 reg 28 in the UC qualifying young person test Exclude a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, and a claimant or partner on the couple's joint tax credit award, from is_qualifying_young_person_for_universal_credit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HNNv4UarCy16Lmxzo --- changelog.d/2804.fixed.md | 1 + ...ifying_young_person_existing_benefits.yaml | 269 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 32 ++- ...sion_credit_young_person_income_support.py | 8 +- ...ung_person_existing_benefits_properties.py | 257 +++++++++++++++++ ...fying_young_person_for_universal_credit.py | 39 ++- 6 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 changelog.d/2804.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md new file mode 100644 index 0000000000..8f324eac71 --- /dev/null +++ b/changelog.d/2804.fixed.md @@ -0,0 +1 @@ +Excluded a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, or a claimant or partner on the couple's tax credit award, from the Universal Credit qualifying young person test (Universal Credit (Transitional Provisions) Regulations 2014 reg. 28). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml new file mode 100644 index 0000000000..569875fddd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -0,0 +1,269 @@ +# The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 +# excludes from the UC qualifying young person test a person entitled to an +# existing benefit (reg. 2(1)): income-based JSA, income-related ESA, Income +# Support, Housing Benefit, Child Tax Credit and Working Tax Credit. The +# predicate reads the person's own reports and, for a claimant or partner, +# the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). The +# first seven cases follow the table in issue 2804: 2026, North East, owned +# outright, no income. + +- name: A parent's young person who reports their own Income Support attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own Housing Benefit attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-related ESA attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-based JSA attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports no award attracts the child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A parent's young person flagged as receiving benefits in their own right attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + receives_benefits_in_own_right: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: An 18-year-old claimant on a non-advanced course who reports Housing Benefit is not a qualifying young person. + period: 2026 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false] + is_child_or_qualifying_young_person_for_universal_credit: [false] + uc_child_element: 0 + universal_credit: 4_062.96 + +- name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. + period: 2024 + input: + age: [17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + child_tax_credit_reported: [0.01, 0, 0] + working_tax_credit_reported: [0, 0.01, 0] + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A parent's own reports of every existing benefit leave their young person a qualifying young person. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + child_tax_credit_reported: 3_000 + working_tax_credit_reported: 3_000 + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: A couple's Working Tax Credit award reported by one partner excludes both 18-year-old partners. + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: A couple's Child Tax Credit award does not exclude a dependant who reports none. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 3fd69d83e7..d33c9c12e0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,7 +3,9 @@ # is omitted or explicitly false. SPC reg. 4A(5) also lists income support, # which UC reg. 5(5) does not: is_on_income_support reads it, from the # calculated award for a claimant or partner and from their own report for -# anyone else. +# anyone else. For Universal Credit, the Transitional Provisions Regulations +# 2014 reg. 28 excludes a person entitled to Income Support, and the UC +# predicate reads the person's own report. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -428,9 +430,10 @@ is_qualifying_young_person_for_pension_credit: false is_qualifying_young_person_for_child_benefit: true -- name: A 16-year-old reporting their own Income Support does not attract the Pension Credit child addition, but stays a UC young person. - # SPC reg. 4A(5) lists income support; UC reg. 5(5) does not. Income - # Support is in payment in 2025 (gov.dwp.income_support.active). +- name: A 16-year-old reporting their own Income Support is neither a Pension Credit nor a UC young person. + # SPC reg. 4A(5) lists income support; for UC, Transitional Provisions + # reg. 28 excludes it. Income Support is in payment in 2025 + # (gov.dwp.income_support.active). period: 2025 input: people: @@ -459,9 +462,9 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_child_or_qualifying_young_person_for_pension_credit: [false, false] child_minimum_guarantee_addition: 0 - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's own reported Income Support excludes the Pension Credit education route only. +- name: A member's own reported Income Support excludes the Pension Credit and UC education routes. period: 2025 input: people: @@ -484,11 +487,12 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] - name: A member's Income Support report leaves the Pension Credit route available once Income Support stops paying. # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment. + # report then is no award in payment for Pension Credit. The UC predicate + # reads the raw report under Transitional Provisions reg. 28. period: 2026 input: people: @@ -511,7 +515,7 @@ output: is_on_income_support: [false, false] is_qualifying_young_person_for_pension_credit: [false, true] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] - name: A claimant's calculated Income Support excludes them from the Pension Credit status. period: 2025 @@ -524,9 +528,9 @@ income_support: 3_790.80 is_on_income_support: true is_qualifying_young_person_for_pension_credit: false - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false -- name: A claimant's report without a calculated award leaves the Pension Credit status available. +- name: A claimant's report without a calculated award leaves the Pension Credit status available but not the UC one. # The claimant falls within no prescribed category, so the model calculates # no Income Support for them. period: 2025 @@ -538,7 +542,7 @@ income_support: 0 is_on_income_support: false is_qualifying_young_person_for_pension_credit: true - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false - name: The partner of an Income Support claimant keeps the Pension Credit status. # A couple's award goes to the claimant: the partner who reports nothing is @@ -582,7 +586,7 @@ is_qualifying_young_person_for_universal_credit: [false, false, false, false, false] is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] -- name: A member's report of one penny of Income Support excludes them from the Pension Credit status alone. +- name: A member's report of one penny of Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: people: @@ -605,4 +609,4 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] diff --git a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py index 18e2464fd6..a4aaa042af 100644 --- a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -3,8 +3,10 @@ A 16-year-old outside education, in a 70-year-old's benefit unit, reports their own Income Support in 2025, when it is in payment. They are not the claimant's qualifying young person, so the claimant gets no child addition, -until a reform removes Income Support. UC reg. 5(5) does not list income -support, so they stay a UC qualifying young person throughout. +until a reform removes Income Support. For Universal Credit, the +Transitional Provisions Regulations 2014 reg. 28 excludes them through their +own Income Support report, which a reform that removes the award leaves in +place. A young carer who claims Income Support in their own benefit unit follows the calculated award. While they receive it they are no qualifying young @@ -191,7 +193,7 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): for sim in (kept, removed): assert sim.calculate( "is_qualifying_young_person_for_universal_credit", YEAR - ).tolist() == [False, True] + ).tolist() == [False, False] @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py new file mode 100644 index 0000000000..f37d27ef17 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -0,0 +1,257 @@ +"""Properties of the UC qualifying young person test under SI 2014/1230 reg. 28. + +The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 +excludes from the UC qualifying young person test a person entitled to an +existing benefit (reg. 2(1)). The predicate reads the person's own reports of +Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, +for a claimant or partner, the couple's joint tax credit award (Tax Credits +Act 2002 s.3(3)). These tests check that: + +- any positive own report of an existing benefit makes the predicate false; +- with every such report at zero, the predicate equals the UC Regs 2013 + reg. 5 test with the reg. 5(5) exclusions alone; +- a claimant's or partner's reports never change a dependant's predicate; +- a parent's Universal Credit never rises when their young person's own + report turns positive. +""" + +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +YEARS = (2024, 2026) +EXISTING_BENEFITS = ( + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +) +TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") +REG_5_5_REPORTS = ( + "universal_credit_reported", + "esa_income_reported", + "esa_contrib_reported", + "jsa_income_reported", + "jsa_contrib_reported", +) +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +amount = st.one_of(st.just(0.0), st.floats(0.01, 20_000)) +young_member = st.fixed_dictionaries( + { + "age": st.integers(15, 20), + "current_education": st.sampled_from(EDUCATIONS), + "is_in_approved_training": st.booleans(), + "receives_benefits_in_own_right": st.booleans(), + **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, + } +) +adult = st.fixed_dictionaries( + { + "age": st.integers(16, 70), + "current_education": st.sampled_from(EDUCATIONS), + "is_in_approved_training": st.just(False), + "receives_benefits_in_own_right": st.just(False), + **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, + } +) +INPUTS = ( + ( + "age", + "current_education", + "is_in_approved_training", + "receives_benefits_in_own_right", + "is_claimant_or_partner", + "is_parent", + ) + + REG_5_5_REPORTS + + EXISTING_BENEFITS +) + + +UNITS = 4 + + +@st.composite +def families(draw): + """A claimant, then a partner or a dependant, then two dependants.""" + couple = draw(st.booleans()) + claimant = dict(draw(adult), is_claimant_or_partner=True, is_parent=True) + second = draw(adult if couple else young_member) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(young_member), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + +units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) +_SIMULATIONS = {} + + +def simulate(units, year): + """Set the inputs on one cached simulation per shape and year.""" + shape = (tuple(len(members) for members in units), year) + if shape not in _SIMULATIONS: + situation = {"people": {}, "benunits": {}, "households": {}} + for i, members in enumerate(units): + names = [f"p{i}_{j}" for j in range(len(members))] + for name in names: + situation["people"][name] = {} + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + _SIMULATIONS[shape] = Simulation(situation=situation) + sim = _SIMULATIONS[shape] + people = flatten(units) + for variable in INPUTS: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(INPUTS) + sim.reset_calculations() + return sim + + +def flatten(units): + return [p for members in units for p in members] + + +def with_zero(units, names, who): + return [ + [dict(p, **{n: 0.0 for n in names}) if who(p) else p for p in members] + for members in units + ] + + +def predicate(sim, year): + return sim.calculate("is_qualifying_young_person_for_universal_credit", year).copy() + + +def reg_5_test(sim, year): + """UC Regs 2013 reg. 5 with the reg. 5(5) exclusions alone.""" + + def get(name): + return sim.calculate(name, year) + + routes = get("is_before_first_september_following_16th_birthday") | ( + get( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit" + ) + & get("meets_qualifying_young_person_entry_condition_for_universal_credit") + & get( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit" + ) + ) + excluded = get("receives_benefits_in_own_right") + for name in REG_5_5_REPORTS: + excluded = excluded | (get(name) > 0) + return ( + get("meets_qualifying_young_person_age_condition_for_universal_credit") + & routes + & ~excluded + ) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_any_own_existing_benefit_report_excludes_the_person(units, year): + sim = simulate(units, year) + people = flatten(units) + own = np.array([any(p[n] > 0 for n in EXISTING_BENEFITS) for p in people]) + couple_tax_credits = np.array( + [ + p["is_claimant_or_partner"] + and any( + q["is_claimant_or_partner"] and q[n] > 0 + for q in members + for n in TAX_CREDITS + ) + for members in units + for p in members + ] + ) + assert not np.any(predicate(sim, year) & (own | couple_tax_credits)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): + zeroed = with_zero(units, EXISTING_BENEFITS, lambda p: True) + sim = simulate(zeroed, year) + assert predicate(sim, year).tolist() == reg_5_test(sim, year).tolist() + # With reports present, reg. 28 only removes people from the test. + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & ~reg_5_test(sim, year)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): + reported = predicate(simulate(units, year), year) + cleared = predicate( + simulate( + with_zero(units, EXISTING_BENEFITS, lambda p: p["is_claimant_or_partner"]), + year, + ), + year, + ) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + assert reported[dependant].tolist() == cleared[dependant].tolist() + + +@SETTINGS +@given( + parent_age=st.integers(25, 60), + young_age=st.integers(16, 19), + education=st.sampled_from(EDUCATIONS), + benefit=st.sampled_from(EXISTING_BENEFITS), + report=st.floats(0.01, 20_000), + year=st.sampled_from(YEARS), +) +def test_a_young_persons_report_never_raises_the_parents_award( + parent_age, young_age, education, benefit, report, year +): + def family(young_report): + zeros = {n: 0.0 for n in REG_5_5_REPORTS + EXISTING_BENEFITS} + parent = dict( + zeros, + age=parent_age, + current_education="NOT_IN_EDUCATION", + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=True, + is_parent=True, + ) + young = dict( + zeros, + age=young_age, + current_education=education, + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=False, + is_parent=False, + ) + young[benefit] = young_report + return [[parent, young]] + + without = simulate(family(0.0), year).calculate("universal_credit", year)[0] + with_report = simulate(family(report), year).calculate("universal_credit", year)[0] + assert with_report <= without + 1e-6 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 4281403300..d731218ad1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -11,10 +11,20 @@ class is_qualifying_young_person_for_universal_credit(Variable): "non-advanced education or approved training that meets the entry and " "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " "receiving Universal Credit, Employment and Support Allowance or " - "Jobseeker's Allowance (reg. 5(5))." + "Jobseeker's Allowance (reg. 5(5)), or an existing benefit: Income " + "Support, Housing Benefit, Child Tax Credit or Working Tax Credit " + "(Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 " + "and reg. 2(1)). The formula reads the person's own reported awards, " + "and, for a claimant or partner, the couple's joint tax credit award " + "(Tax Credits Act 2002 s.3(3))." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ] def formula(person, period, parameters): # Reg. 5(1)(a): no education or training condition. @@ -36,9 +46,25 @@ def formula(person, period, parameters): period, ) ) - # The exclusions apply to both routes (UC reg. 5(5); SPC reg. 4A(5)). - # Raw person-level reports identify own awards without calculating - # family benefits, which depend on QYP status and could recurse. + # Reg. 5(5) and Transitional Provisions reg. 28 exclude a person + # receiving Universal Credit, any ESA or JSA, or an existing benefit + # (TP reg. 2(1)), on both routes. Reg. 5(5) already covers + # income-related ESA and income-based JSA. Raw person-level reports + # identify own awards without calculating family benefits, which + # depend on QYP status and could recurse. A dependant's own report + # counts; a parent's report does not. + tax_credits_reported = person("child_tax_credit_reported", period) + person( + "working_tax_credit_reported", period + ) + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, + # so the couple's award belongs to the claimant and the partner. + claimant_or_partner = person("is_claimant_or_partner", period) + couple_tax_credits_reported = person.benunit.sum( + where(claimant_or_partner, tax_credits_reported, 0) + ) + receives_tax_credits = (tax_credits_reported > 0) | ( + claimant_or_partner & (couple_tax_credits_reported > 0) + ) receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) @@ -46,6 +72,9 @@ def formula(person, period, parameters): | (person("esa_contrib_reported", period) > 0) | (person("jsa_income_reported", period) > 0) | (person("jsa_contrib_reported", period) > 0) + | (person("income_support_reported", period) > 0) + | (person("housing_benefit_reported", period) > 0) + | receives_tax_credits ) return ( person( From dbaf71872870f26988c802cab622919c450d95f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:39:58 +0000 Subject: [PATCH 02/20] Bias property inputs so reg 28 decides the outcome Draw reg 5(5) reports rarely and each existing benefit report one time in three, and let claimants and partners be 16 to 19, so the properties reach people whom only reg 28 excludes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HNNv4UarCy16Lmxzo --- ...ung_person_existing_benefits_properties.py | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index f37d27ef17..21c832c529 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -51,25 +51,42 @@ suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -amount = st.one_of(st.just(0.0), st.floats(0.01, 20_000)) -young_member = st.fixed_dictionaries( - { - "age": st.integers(15, 20), - "current_education": st.sampled_from(EDUCATIONS), - "is_in_approved_training": st.booleans(), - "receives_benefits_in_own_right": st.booleans(), - **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, - } -) -adult = st.fixed_dictionaries( - { - "age": st.integers(16, 70), - "current_education": st.sampled_from(EDUCATIONS), - "is_in_approved_training": st.just(False), - "receives_benefits_in_own_right": st.just(False), - **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, +amount = st.floats(0.01, 20_000) + + +@st.composite +def sometimes(draw): + """A positive amount one time in three, otherwise zero.""" + return draw(amount) if draw(st.sampled_from((False, False, True))) else 0.0 + + +@st.composite +def member(draw, ages, training): + """A person who usually reports no reg. 5(5) award. + + Reg. 5(5) excludes anyone with such a report, so drawing them rarely + leaves people for reg. 28 to exclude. Each existing benefit report is + positive one time in three. + """ + reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + return { + "age": draw(ages), + "current_education": draw(st.sampled_from(EDUCATIONS)), + "is_in_approved_training": draw(training), + "receives_benefits_in_own_right": draw( + st.sampled_from((False, False, False, True)) + ), + **{ + name: draw(amount) if name == reg_5_5_report else 0.0 + for name in REG_5_5_REPORTS + }, + **{name: draw(sometimes()) for name in EXISTING_BENEFITS}, } -) + + +young_member = member(st.integers(15, 20), st.booleans()) +# Claimants and partners aged 16 to 19 can be qualifying young people. +adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) INPUTS = ( ( "age", From 71299640b42aa078e956284a7adf0742243cefe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:07:52 +0000 Subject: [PATCH 03/20] Read reg 28 as entitlement and keep Scottish CTR on reg 5 Split the UC qualifying young person test: a new reg. 5 variable applies UC Regs reg. 5 with its own reg. 5(5) exclusions (Universal Credit, contributory ESA, contribution-based JSA), and the UC predicate is that variable less SI 2014/1230 reg. 28. The predicate reads a report of Income Support, income-based JSA or a tax credit only in a year the model pays that benefit and no reform neutralises it. Scotland's working-age Council Tax Reduction reads the reg. 5 variable, since SSI 2021/249 reg. 6(1) imports reg. 5 and reg. 28 applies only for the UC Regulations and WRA 2012 Part 1. Tests: closed-benefit cases, the two couple-rule cases, an exact reg. 5 less reg. 28 property, Scottish CTR cases, and the knock-on changes to the reported-awards YAML and the Pension Credit test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JZXfdURcTxpkfm2gf --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 181 ++++++++++++++++-- ...ying_young_person_reported_own_awards.yaml | 44 +++-- .../council_tax_reduction_young_person.yaml | 58 +++++- ...l_tax_reduction_young_person_properties.py | 19 +- ...sion_credit_young_person_income_support.py | 18 +- ...ung_person_existing_benefits_properties.py | 143 ++++++++++---- ...fying_young_person_for_universal_credit.py | 96 ++++------ ...son_under_universal_credit_regulation_5.py | 70 +++++++ ..._young_person_for_council_tax_reduction.py | 29 ++- 10 files changed, 518 insertions(+), 142 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 8f324eac71..f6bb42efa8 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, or a claimant or partner on the couple's tax credit award, from the Universal Credit qualifying young person test (Universal Credit (Transitional Provisions) Regulations 2014 reg. 28). +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA or Housing Benefit, or their own income-based JSA, Income Support, Child Tax Credit or Working Tax Credit while the model pays it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). Scotland's working-age Council Tax Reduction now reads the Universal Credit Regulations reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 569875fddd..2f581d2bc1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -1,14 +1,18 @@ -# The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 -# excludes from the UC qualifying young person test a person entitled to an -# existing benefit (reg. 2(1)): income-based JSA, income-related ESA, Income -# Support, Housing Benefit, Child Tax Credit and Working Tax Credit. The -# predicate reads the person's own reports and, for a claimant or partner, -# the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). The -# first seven cases follow the table in issue 2804: 2026, North East, owned -# outright, no income. +# Under the Universal Credit (Transitional Provisions) Regulations 2014 +# reg. 28, DWP doesn't count a person entitled to an existing benefit (reg. +# 2(1)) as a UC qualifying young person: income-based JSA, income-related +# ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax +# Credit. The predicate reads the person's own reports and, for a claimant or +# partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). +# It reads a report of Income Support, income-based JSA or a tax credit only +# in a year the model pays that benefit: Income Support and income-based JSA +# until 31 March 2026, tax credits until 5 April 2025. The first six cases, +# and the four after the seventh, share one household: a parent aged 45 and +# a 17-year-old on a non-advanced course, North East, owned outright, no +# income. - name: A parent's young person who reports their own Income Support attracts no child element. - period: 2026 + period: 2025 input: people: parent: @@ -32,7 +36,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 - universal_credit: 5_098.80 + universal_credit: 4_801.68 - name: A parent's young person who reports their own Housing Benefit attracts no child element. period: 2026 @@ -89,7 +93,7 @@ universal_credit: 5_098.80 - name: A parent's young person who reports their own income-based JSA attracts no child element. - period: 2026 + period: 2025 input: people: parent: @@ -113,7 +117,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 - universal_credit: 5_098.80 + universal_credit: 4_801.68 - name: A parent's young person who reports no award attracts the child element. period: 2026 @@ -191,6 +195,117 @@ uc_child_element: 0 universal_credit: 4_062.96 +- name: A young person's own Income Support report after Income Support stops paying leaves the child element. + # Income Support stops paying on 1 April 2026 (gov.dwp.income_support.active), so nobody is entitled to it in 2026. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-based JSA report after income-based JSA stops paying leaves the child element. + # Income-based JSA stops paying on 1 April 2026 (gov.dwp.JSA.income.active). + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own Child Tax Credit report while tax credits pay removes the child element. + period: 2024 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_721.40 + +- name: A young person's own Child Tax Credit report after tax credits stop paying leaves the child element. + # Tax credits stop paying on 6 April 2025 (gov.dwp.tax_credits.active). + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + - name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. period: 2024 input: @@ -267,3 +382,45 @@ members: [claimant, partner, young_person] output: is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A partner's own Housing Benefit report leaves an 18-year-old claimant a qualifying young person. + # Housing Benefit is paid to the claimant of that benefit, so the + # partner's report is their own and the couple branch reads only tax + # credits. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + partner: + age: 30 + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [true, false] + +- name: A dependant's own Child Tax Credit award leaves an 18-year-old claimant a qualifying young person. + # Tax Credits Act 2002 s.3(3)(b): the dependant claims as a single person, + # so their award is not the claimant's. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [claimant, young_person] + output: + is_qualifying_young_person_for_universal_credit: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index d33c9c12e0..8de6ae1fc5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -1,11 +1,14 @@ -# UC reg. 5(5) and SPC reg. 4A(5) exclude own awards from both QYP routes. -# The reports identify the recipient whether receives_benefits_in_own_right -# is omitted or explicitly false. SPC reg. 4A(5) also lists income support, -# which UC reg. 5(5) does not: is_on_income_support reads it, from the -# calculated award for a claimant or partner and from their own report for -# anyone else. For Universal Credit, the Transitional Provisions Regulations -# 2014 reg. 28 excludes a person entitled to Income Support, and the UC -# predicate reads the person's own report. +# UC reg. 5(5) excludes a person receiving Universal Credit, contributory ESA +# or contribution-based JSA; UC Regs reg. 2 defines ESA and JSA without their +# income-related and income-based forms. DWP doesn't count a person entitled +# to income-related ESA, income-based JSA or Income Support as a UC +# qualifying young person (SI 2014/1230 reg. 28), and the UC predicate reads +# a report of income-based JSA or Income Support only while the model pays +# it, until 31 March 2026. SPC reg. 4A(5) excludes own awards from the +# Pension Credit routes, including income support: is_on_income_support reads +# it, from the calculated award for a claimant or partner and from their own +# report for anyone else. The reports identify the recipient whether +# receives_benefits_in_own_right is omitted or explicitly false. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -43,7 +46,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 40. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -218,7 +222,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 70. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -358,7 +363,8 @@ child_minimum_guarantee_addition: 0 - name: A reported award also excludes the education route. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: age: [18, 18, 18, 18, 18] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] @@ -430,9 +436,9 @@ is_qualifying_young_person_for_pension_credit: false is_qualifying_young_person_for_child_benefit: true -- name: A 16-year-old reporting their own Income Support is neither a Pension Credit nor a UC young person. - # SPC reg. 4A(5) lists income support; for UC, Transitional Provisions - # reg. 28 excludes it. Income Support is in payment in 2025 +- name: A 16-year-old reporting their own Income Support counts as neither a Pension Credit nor a UC young person. + # SPC reg. 4A(5) lists income support; for UC, TP reg. 28 excludes a + # person entitled to it. Income Support is in payment in 2025 # (gov.dwp.income_support.active). period: 2025 input: @@ -489,10 +495,10 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's Income Support report leaves the Pension Credit route available once Income Support stops paying. +- name: A member's Income Support report leaves the Pension Credit and UC routes available once Income Support stops paying. # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment for Pension Credit. The UC predicate - # reads the raw report under Transitional Provisions reg. 28. + # report then is no award in payment for Pension Credit, and nobody is + # entitled to it for TP reg. 28. period: 2026 input: people: @@ -515,9 +521,9 @@ output: is_on_income_support: [false, false] is_qualifying_young_person_for_pension_credit: [false, true] - is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] -- name: A claimant's calculated Income Support excludes them from the Pension Credit status. +- name: A claimant's calculated Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: age: 17 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index 1abdbeba87..0e378492c4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -1,6 +1,6 @@ # Each Council Tax Reduction scheme's own "young person". Scotland's -# working-age scheme, from 1 April 2022, uses the Universal Credit qualifying -# young person (SSI 2021/249 reg. 6(1); UC Regs 2013 reg. 5). England, Wales +# working-age scheme, from 1 April 2022, uses the qualifying young person of +# UC Regs 2013 reg. 5 (SSI 2021/249 reg. 6(1)). England, Wales # and Scotland's pension-age scheme use the Child Benefit qualifying young # person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as # did Scotland's working-age scheme until 31 March 2022 (SSI 2012/303 reg. 4). @@ -351,3 +351,57 @@ is_council_tax_reduction_applicant_or_partner: [true, false, false] is_child_or_young_person_for_council_tax_reduction: [true, false, false] council_tax_reduction_applicable_income: 10_000 + +- name: Case 11, Scotland working age, a 19-year-old's own Housing Benefit leaves them a young person. + # The Secretary of State excludes a person entitled to an existing benefit + # from the qualifying young person only for the purposes of the UC + # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). SSI 2021/249 + # reg. 6(1) reads UC Regs reg. 5, so the 19-year-old stays in the family. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_qualifying_young_person_under_universal_credit_regulation_5: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + # Lone parent aged 18 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: 4_968.60 + +- name: Case 12, Scotland working age, a 17-year-old's own Income Support, income-based JSA or income-related ESA leaves them a young person, and contribution-based JSA does not. + period: 2025 + input: + age: [17, 17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + is_claimant_or_partner: [false, false, false, false] + income_support_reported: [3_000, 0, 0, 0] + jsa_income_reported: [0, 3_000, 0, 0] + esa_income_reported: [0, 0, 3_000, 0] + jsa_contrib_reported: [0, 0, 0, 3_000] + country: [SCOTLAND, SCOTLAND, SCOTLAND, SCOTLAND] + output: + is_qualifying_young_person_for_universal_credit: [false, false, false, false] + # Contribution-based JSA is a reg. 5(5) benefit, so it alone excludes. + is_qualifying_young_person_under_universal_credit_regulation_5: [true, true, true, false] + is_child_or_young_person_for_council_tax_reduction: [true, true, true, false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py index 1bf2efbf8f..07f0378bf4 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py @@ -1,8 +1,11 @@ """Each Council Tax Reduction scheme's own young person. -Scotland's working-age scheme, from 1 April 2022, defines a young person as a -Universal Credit qualifying young person (Council Tax Reduction (Scotland) -Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). England, Wales and +Scotland's working-age scheme, from 1 April 2022, defines a young person by +the qualifying young person of UC Regs 2013 reg. 5 (Council Tax Reduction +(Scotland) Regulations 2021 reg. 6(1)). The Secretary of State excludes a +person entitled to an existing benefit only for the purposes of the UC +Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28), so that exclusion +stays out. England, Wales and Scotland's pension-age scheme use the Child Benefit qualifying young person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as Scotland's working-age scheme did until 31 March 2022 (SSI 2012/303 reg. 4). Every scheme @@ -158,7 +161,9 @@ def assert_scheme_selection(units, year): universal_credit = ( ( under_16 - | sim.calculate("is_qualifying_young_person_for_universal_credit", year) + | sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) ) & ~looked_after & ~claimant_or_partner @@ -255,7 +260,9 @@ def test_scheme_selection_over_every_combination(): # Not vacuous: some of those lone claimants are qualifying young # persons by status, in Scotland's working-age scheme too. sim = simulate(alone, year) - status = sim.calculate("is_qualifying_young_person_for_universal_credit", year) + status = sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) scottish_alone = np.array( [follows_universal_credit(unit, year) for unit in alone] ) @@ -280,7 +287,7 @@ def test_schemes_differ_only_where_the_two_definitions_differ(units): sim = simulate(variants, year) flag = sim.calculate("is_child_or_young_person_for_council_tax_reduction", year) agree = sim.calculate( - "is_qualifying_young_person_for_universal_credit", year + "is_qualifying_young_person_under_universal_credit_regulation_5", year ) == sim.calculate("is_qualifying_young_person_for_child_benefit", year) offset = 0 for unit in units: diff --git a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py index a4aaa042af..7f31244d41 100644 --- a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -3,10 +3,10 @@ A 16-year-old outside education, in a 70-year-old's benefit unit, reports their own Income Support in 2025, when it is in payment. They are not the claimant's qualifying young person, so the claimant gets no child addition, -until a reform removes Income Support. For Universal Credit, the -Transitional Provisions Regulations 2014 reg. 28 excludes them through their -own Income Support report, which a reform that removes the award leaves in -place. +until a reform removes Income Support. DWP doesn't count them as a UC +qualifying young person while they're entitled to Income Support (SI +2014/1230 reg. 28), so the reform that removes Income Support restores both +statuses. A young carer who claims Income Support in their own benefit unit follows the calculated award. While they receive it they are no qualifying young @@ -190,10 +190,12 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): ).tolist() == [False, True] assert kept.calculate("child_minimum_guarantee_addition", YEAR).tolist() == [0] assert removed.calculate("child_minimum_guarantee_addition", YEAR)[0] > 0 - for sim in (kept, removed): - assert sim.calculate( - "is_qualifying_young_person_for_universal_credit", YEAR - ).tolist() == [False, False] + assert kept.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, False] + assert removed.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, True] @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 21c832c529..d4bdd34426 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -1,23 +1,27 @@ """Properties of the UC qualifying young person test under SI 2014/1230 reg. 28. -The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 -excludes from the UC qualifying young person test a person entitled to an -existing benefit (reg. 2(1)). The predicate reads the person's own reports of -Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, -for a claimant or partner, the couple's joint tax credit award (Tax Credits -Act 2002 s.3(3)). These tests check that: - -- any positive own report of an existing benefit makes the predicate false; +DWP doesn't count a person entitled to an existing benefit (SI 2014/1230 +reg. 2(1)) as a UC qualifying young person (reg. 28). The predicate reads the +person's own reports of income-based JSA, income-related ESA, Income Support, +Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant +or partner, the couple's joint tax credit award (Tax Credits Act 2002 +s.3(3)). It reads a report of Income Support, income-based JSA or a tax +credit only in a year the model pays that benefit. These tests check that: + +- any positive own report of an existing benefit the model pays that year + makes the predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 - reg. 5 test with the reg. 5(5) exclusions alone; + reg. 5 test less the person's reported UC, ESA and JSA awards; - a claimant's or partner's reports never change a dependant's predicate; - a parent's Universal Credit never rises when their young person's own - report turns positive. + report turns positive; +- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. """ from unittest.mock import patch import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -27,23 +31,33 @@ EducationType, ) -_PROPERTY_SYSTEM = system.clone() EDUCATIONS = tuple(item.name for item in EducationType) -YEARS = (2024, 2026) +NON_ADVANCED = ("UPPER_SECONDARY", "POST_SECONDARY") +YEARS = (2024, 2025, 2026) EXISTING_BENEFITS = ( + "jsa_income_reported", + "esa_income_reported", "income_support_reported", "housing_benefit_reported", "child_tax_credit_reported", "working_tax_credit_reported", ) TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") +# UC Regs reg. 2 defines ESA and JSA without their income-related and +# income-based forms, so reg. 5(5) reaches only these. REG_5_5_REPORTS = ( "universal_credit_reported", - "esa_income_reported", "esa_contrib_reported", - "jsa_income_reported", "jsa_contrib_reported", ) +# The existing benefits the model pays in each year: tax credits until 5 April +# 2025 (gov.dwp.tax_credits.active), Income Support and income-based JSA until +# 31 March 2026 (gov.dwp.income_support.active, gov.dwp.JSA.income.active). +PAYING = { + 2024: set(EXISTING_BENEFITS), + 2025: set(EXISTING_BENEFITS) - set(TAX_CREDITS), + 2026: {"esa_income_reported", "housing_benefit_reported"}, +} SETTINGS = settings( max_examples=25, deadline=None, @@ -61,8 +75,8 @@ def sometimes(draw): @st.composite -def member(draw, ages, training): - """A person who usually reports no reg. 5(5) award. +def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)): + """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely leaves people for reg. 28 to exclude. Each existing benefit report is @@ -71,10 +85,10 @@ def member(draw, ages, training): reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) return { "age": draw(ages), - "current_education": draw(st.sampled_from(EDUCATIONS)), + "current_education": draw(st.sampled_from(educations)), "is_in_approved_training": draw(training), "receives_benefits_in_own_right": draw( - st.sampled_from((False, False, False, True)) + st.sampled_from((False,) * 3 + own_right[1:]) ), **{ name: draw(amount) if name == reg_5_5_report else 0.0 @@ -87,6 +101,11 @@ def member(draw, ages, training): young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) +# Someone who meets reg. 5 unless a report excludes them: 16 to 19, on a +# non-advanced course and not flagged as receiving benefits in their own right. +qualifying = member( + st.integers(16, 19), st.just(False), NON_ADVANCED, own_right=(False,) +) INPUTS = ( ( "age", @@ -118,14 +137,49 @@ def families(draw): return [claimant, second] + dependants +@st.composite +def qualifying_families(draw): + """Families where reg. 28 alone decides most members' status. + + The claimant, the partner or dependant, and both dependants would meet + reg. 5 two times in three. A partner is otherwise an older adult whose own + reports reg. 28 must not pass to the claimant, except for the couple's + tax credit award. + """ + couple = draw(st.booleans()) + claimant = draw(st.one_of(qualifying, qualifying, adult)) + claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + second = draw(st.one_of(qualifying, qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict( + draw(st.one_of(qualifying, qualifying, young_member)), + is_claimant_or_partner=False, + is_parent=False, + ) + for _ in range(2) + ] + return [claimant, second] + dependants + + units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) -_SIMULATIONS = {} +qualifying_units_strategy = st.lists( + qualifying_families(), min_size=UNITS, max_size=UNITS +) +_CACHE = {} + + +@pytest.fixture(autouse=True, scope="module") +def _release_simulations(): + """Drop the cloned system and cached simulations when the module ends.""" + yield + _CACHE.clear() def simulate(units, year): """Set the inputs on one cached simulation per shape and year.""" shape = (tuple(len(members) for members in units), year) - if shape not in _SIMULATIONS: + if shape not in _CACHE: situation = {"people": {}, "benunits": {}, "households": {}} for i, members in enumerate(units): names = [f"p{i}_{j}" for j in range(len(members))] @@ -133,12 +187,14 @@ def simulate(units, year): situation["people"][name] = {} situation["benunits"][f"b{i}"] = {"members": names} situation["households"][f"h{i}"] = {"members": names} + if "system" not in _CACHE: + _CACHE["system"] = system.clone() with patch( "policyengine_uk.simulation.CountryTaxBenefitSystem", - side_effect=_PROPERTY_SYSTEM.clone, + side_effect=_CACHE["system"].clone, ): - _SIMULATIONS[shape] = Simulation(situation=situation) - sim = _SIMULATIONS[shape] + _CACHE[shape] = Simulation(situation=situation) + sim = _CACHE[shape] people = flatten(units) for variable in INPUTS: sim.set_input(variable, year, [p[variable] for p in people]) @@ -163,7 +219,7 @@ def predicate(sim, year): def reg_5_test(sim, year): - """UC Regs 2013 reg. 5 with the reg. 5(5) exclusions alone.""" + """UC Regs 2013 reg. 5 less the person's reported UC, ESA and JSA awards.""" def get(name): return sim.calculate(name, year) @@ -187,25 +243,32 @@ def get(name): ) -@SETTINGS -@given(units=units_strategy, year=st.sampled_from(YEARS)) -def test_any_own_existing_benefit_report_excludes_the_person(units, year): - sim = simulate(units, year) - people = flatten(units) - own = np.array([any(p[n] > 0 for n in EXISTING_BENEFITS) for p in people]) +def reg_28_exclusions(units, year): + """Who reg. 28 excludes: own reports of benefits the model pays that year, + and, for a claimant or partner, the couple's tax credit award.""" + paying = PAYING[year] + own = np.array([any(p[n] > 0 for n in paying) for p in flatten(units)], dtype=bool) couple_tax_credits = np.array( [ p["is_claimant_or_partner"] and any( q["is_claimant_or_partner"] and q[n] > 0 for q in members - for n in TAX_CREDITS + for n in paying.intersection(TAX_CREDITS) ) for members in units for p in members - ] + ], + dtype=bool, ) - assert not np.any(predicate(sim, year) & (own | couple_tax_credits)) + return own | couple_tax_credits + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_any_own_existing_benefit_report_excludes_the_person(units, year): + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year)) @SETTINGS @@ -214,6 +277,12 @@ def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): zeroed = with_zero(units, EXISTING_BENEFITS, lambda p: True) sim = simulate(zeroed, year) assert predicate(sim, year).tolist() == reg_5_test(sim, year).tolist() + assert ( + reg_5_test(sim, year).tolist() + == sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ).tolist() + ) # With reports present, reg. 28 only removes people from the test. sim = simulate(units, year) assert not np.any(predicate(sim, year) & ~reg_5_test(sim, year)) @@ -272,3 +341,11 @@ def family(young_report): without = simulate(family(0.0), year).calculate("universal_credit", year)[0] with_report = simulate(family(report), year).calculate("universal_credit", year)[0] assert with_report <= without + 1e-6 + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_the_predicate_is_reg_5_less_reg_28(units, year): + sim = simulate(units, year) + expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year) + assert predicate(sim, year).tolist() == expected.tolist() diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index d731218ad1..f4a73f9416 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -6,58 +6,54 @@ class is_qualifying_young_person_for_universal_credit(Variable): entity = Person label = "Qualifying young person for Universal Credit" documentation = ( - "A person aged 16 to 19 who is either before the 1 September following " - "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " - "non-advanced education or approved training that meets the entry and " - "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " - "receiving Universal Credit, Employment and Support Allowance or " - "Jobseeker's Allowance (reg. 5(5)), or an existing benefit: Income " - "Support, Housing Benefit, Child Tax Credit or Working Tax Credit " - "(Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 " - "and reg. 2(1)). The formula reads the person's own reported awards, " - "and, for a claimant or partner, the couple's joint tax credit award " - "(Tax Credits Act 2002 s.3(3))." + "A qualifying young person under UC Regs 2013 reg. 5 " + "(is_qualifying_young_person_under_universal_credit_regulation_5): a " + "person aged 16 to 19 who meets reg. 5(1)(a) or (b) and receives no " + "Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)) and no existing benefit: income-based JSA, income-related ESA, " + "Income Support, Housing Benefit, Child Tax Credit or Working Tax " + "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " + "29). Reg. 28 turns on entitlement, so the formula reads a person's own " + "report of Income Support, income-based JSA or a tax credit only in a " + "year the model pays that benefit (gov.dwp.income_support.active, " + "gov.dwp.JSA.income.active, gov.dwp.tax_credits.active) and no reform " + "neutralises it. It reads Housing Benefit and income-related ESA " + "reports in every year. For a claimant or partner it also reads the " + "couple's joint tax credit award (Tax Credits Act 2002 s.3(3))." ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", ] def formula(person, period, parameters): - # Reg. 5(1)(a): no education or training condition. - before_first_september_following_16th_birthday = person( - "is_before_first_september_following_16th_birthday", period - ) - # Reg. 5(1)(b), (2) and (3). - in_education_or_training = ( - person( - "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", - period, - ) - ) - # Reg. 5(5) and Transitional Provisions reg. 28 exclude a person - # receiving Universal Credit, any ESA or JSA, or an existing benefit - # (TP reg. 2(1)), on both routes. Reg. 5(5) already covers - # income-related ESA and income-based JSA. Raw person-level reports - # identify own awards without calculating family benefits, which - # depend on QYP status and could recurse. A dependant's own report - # counts; a parent's report does not. - tax_credits_reported = person("child_tax_credit_reported", period) + person( - "working_tax_credit_reported", period + # TP reg. 28 excludes a person entitled to an existing benefit (TP + # reg. 2(1)). The formula reads raw person-level reports, so it + # calculates no family benefit that depends on QYP status. It reads a + # dependant's own reports, never a parent's. + dwp = parameters(period).gov.dwp + system = person.simulation.tax_benefit_system + + def paying(active, name): + # Nobody is entitled to a benefit the model no longer pays, or one + # a reform neutralises. + return bool(active) and not system.get_variable(name).is_neutralized + + income_support_paying = paying(dwp.income_support.active, "income_support") + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") + tax_credits_paying = paying(dwp.tax_credits.active, "tax_credits") + tax_credits_reported = tax_credits_paying * ( + person("child_tax_credit_reported", period) + + person("working_tax_credit_reported", period) ) # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, - # so the couple's award belongs to the claimant and the partner. + # so the couple's award belongs to the claimant and the partner. A + # dependant claims as a single person, so their award stays theirs. claimant_or_partner = person("is_claimant_or_partner", period) couple_tax_credits_reported = person.benunit.sum( where(claimant_or_partner, tax_credits_reported, 0) @@ -65,25 +61,17 @@ def formula(person, period, parameters): receives_tax_credits = (tax_credits_reported > 0) | ( claimant_or_partner & (couple_tax_credits_reported > 0) ) - receives_own_benefits = ( - person("receives_benefits_in_own_right", period) - | (person("universal_credit_reported", period) > 0) - | (person("esa_income_reported", period) > 0) - | (person("esa_contrib_reported", period) > 0) - | (person("jsa_income_reported", period) > 0) - | (person("jsa_contrib_reported", period) > 0) - | (person("income_support_reported", period) > 0) + entitled_to_existing_benefit = ( + (person("esa_income_reported", period) > 0) + | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) + | (income_support_paying & (person("income_support_reported", period) > 0)) | (person("housing_benefit_reported", period) > 0) | receives_tax_credits ) return ( person( - "meets_qualifying_young_person_age_condition_for_universal_credit", + "is_qualifying_young_person_under_universal_credit_regulation_5", period, ) - & ( - before_first_september_following_16th_birthday - | in_education_or_training - ) - & ~receives_own_benefits + & ~entitled_to_existing_benefit ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py new file mode 100644 index 0000000000..3e1e48929b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -0,0 +1,70 @@ +from policyengine_uk.model_api import * + + +class is_qualifying_young_person_under_universal_credit_regulation_5(Variable): + value_type = bool + entity = Person + label = "Qualifying young person under Universal Credit Regulations reg. 5" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " + "non-advanced education or approved training that meets the entry and " + "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who receives " + "no Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)). Universal Credit Regulations 2013 reg. 2 defines employment and " + "support allowance and jobseeker's allowance without their " + "income-related and income-based forms, so this test leaves those to " + "SI 2014/1230 reg. 28, which is_qualifying_young_person_for_universal_credit " + "applies. Northern Ireland's twin is UC Regs (NI) 2016 reg. 6. Scotland's " + "working-age Council Tax Reduction scheme defines its young person by " + "this regulation (SSI 2021/249 reg. 6(1))." + ) + definition_period = YEAR + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + ] + + def formula(person, period, parameters): + # Reg. 5(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(1)(b), (2) and (3). + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", + period, + ) + ) + # Reg. 5(5) excludes a person receiving Universal Credit, contributory + # ESA or contribution-based JSA, on both routes. The formula reads raw + # person-level reports, so it calculates no family benefit that + # depends on qualifying young person status. + receives_reg_5_5_benefit = ( + person("receives_benefits_in_own_right", period) + | (person("universal_credit_reported", period) > 0) + | (person("esa_contrib_reported", period) > 0) + | (person("jsa_contrib_reported", period) > 0) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_universal_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) + & ~receives_reg_5_5_benefit + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py index 476e395c44..dadb1f9777 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py @@ -9,8 +9,13 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): young person: - Scotland's working-age scheme, from 1 April 2022, uses the Universal - Credit qualifying young person (Council Tax Reduction (Scotland) - Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). The applicant is + Credit child and the qualifying young person of UC Regs 2013 reg. 5 + (Council Tax Reduction (Scotland) Regulations 2021 reg. 6(1)). The + Secretary of State excludes a person entitled to an existing benefit + only for the purposes of the UC Regulations and WRA 2012 Part 1 (SI + 2014/1230 reg. 28), so the formula reads reg. 5 alone and keeps a + young person who receives Income Support, Housing Benefit, a tax + credit, income-related ESA or income-based JSA. The applicant is responsible for one who normally lives with them, never for one they live with as a couple, and no one is responsible for one placed with a foster carer (reg. 7). @@ -34,6 +39,8 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ssi/2021/249/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", "https://www.legislation.gov.uk/ssi/2021/249/regulation/7", "https://www.legislation.gov.uk/ssi/2012/319/regulation/4", @@ -50,10 +57,18 @@ def formula(person, period, parameters): & ~person.benunit("council_tax_reduction_pensioner", period) & p.young_person_follows_universal_credit ) - # SSI 2021/249 regs 4, 6 and 7. The Universal Credit flag already - # leaves out a child or young person looked after by a local authority. - universal_credit = person( - "is_child_or_qualifying_young_person_for_universal_credit", period - ) & ~person("is_claimant_or_partner", period) + # SSI 2021/249 regs 4, 6 and 7: a child under UC Regs reg. 4, or a + # qualifying young person under reg. 5 without TP reg. 28. + universal_credit = ( + ( + person("is_child_for_universal_credit", period) + | person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, + ) + ) + & ~person("is_looked_after_by_local_authority", period) + & ~person("is_claimant_or_partner", period) + ) child_benefit = person("is_child_or_young_person_for_legacy_benefits", period) return where(scottish_working_age, universal_credit, child_benefit) From 4b4d30df264e3951def2e597aed0aeca15f943ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:10:00 +0000 Subject: [PATCH 04/20] Supply the reg 5 status in the Scottish CTR family-mask test Scotland's working-age Council Tax Reduction reads the reg. 5 variable, so the input grid sets that status rather than the UC predicate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JZXfdURcTxpkfm2gf --- ...ouncil_tax_reduction_severe_disability_family.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py index 4334138476..f2e0d4279e 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -1,10 +1,11 @@ """CTR family-mask input contracts, separate from derived eligibility tests. SSI 2021/249 regs 6 and 8(2)(a), Sch 1 para 13 retain an applicant's -UC young person in their family. These finite grids deliberately supply the -two young-person statuses to exercise both directions independently of the -upstream eligibility rules. The policy YAML uses school/course inputs and -the annual terminal-date input, plus actual PIP, pension and council tax. +young person under UC Regs 2013 reg. 5 in their family. These finite grids +deliberately supply the two young-person statuses to exercise both directions +independently of the upstream eligibility rules. The policy YAML uses +school/course inputs and the annual terminal-date input, plus actual PIP, +pension and council tax. Looked-after and other-benefit-unit treatment remains the existing model baseline, not a certification of reg 8(2)(b) and its paragraph (3) overrides. @@ -31,7 +32,9 @@ def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): "age": row.get("age", 19), "is_claimant_or_partner": False, "is_child_or_qualifying_young_person_for_child_benefit": row["cb"], - "is_qualifying_young_person_for_universal_credit": row["uc"], + # SSI 2021/249 reg. 6(1) reads UC Regs reg. 5, without SI + # 2014/1230 reg. 28. + "is_qualifying_young_person_under_universal_credit_regulation_5": row["uc"], "is_looked_after_by_local_authority": row.get("looked_after", False), "is_blind": row.get("blind", False), "pip_dl_category": "STANDARD" if row.get("benefit", False) else "NONE", From ab1b6f0eb594d89941020ad5d44bcf248b5a3da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:17:14 +0000 Subject: [PATCH 05/20] Lift reg 28 exclusions a reform neutralises, benefit by benefit The UC qualifying young person predicate now skips a report of income-related ESA or Housing Benefit when a reform neutralises esa_income or housing_benefit, and checks Child Tax Credit and Working Tax Credit each against its own variable or tax_credits, in the couple branch too. New Python tests cover each benefit under a reform that neutralises it. Also: Case 13 pins a Scottish CTR award that the reg 5 reading raises; the qualifying generator draws no reg 5(5) report; review wording rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- ...ifying_young_person_existing_benefits.yaml | 12 +- ...ying_young_person_reported_own_awards.yaml | 24 +-- .../council_tax_reduction_young_person.yaml | 48 ++++- ...nefit_cap_specified_benefits_properties.py | 8 +- .../test_legacy_award_readers_properties.py | 4 +- ...ung_person_existing_benefits_properties.py | 53 ++++-- ...fying_young_person_neutralised_benefits.py | 170 ++++++++++++++++++ ...fying_young_person_for_universal_credit.py | 54 ++++-- ...son_under_universal_credit_regulation_5.py | 18 +- 9 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 2f581d2bc1..0d608f2111 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,9 +4,10 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report of Income Support, income-based JSA or a tax credit only -# in a year the model pays that benefit: Income Support and income-based JSA -# until 31 March 2026, tax credits until 5 April 2025. The first six cases, +# It reads a report only in a year the model pays that benefit, and only if +# no reform neutralises it. The model pays Income Support and income-based +# JSA until 31 March 2026 and tax credits until 5 April 2025, and has no +# closure date for Housing Benefit or income-related ESA. The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. @@ -384,9 +385,8 @@ is_qualifying_young_person_for_universal_credit: [false, false, true] - name: A partner's own Housing Benefit report leaves an 18-year-old claimant a qualifying young person. - # Housing Benefit is paid to the claimant of that benefit, so the - # partner's report is their own and the couple branch reads only tax - # credits. + # The partner reports their own Housing Benefit, and the formula reads + # only tax credits across the couple. period: 2024 input: people: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 8de6ae1fc5..859fab063e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -1,14 +1,16 @@ -# UC reg. 5(5) excludes a person receiving Universal Credit, contributory ESA -# or contribution-based JSA; UC Regs reg. 2 defines ESA and JSA without their -# income-related and income-based forms. DWP doesn't count a person entitled -# to income-related ESA, income-based JSA or Income Support as a UC -# qualifying young person (SI 2014/1230 reg. 28), and the UC predicate reads -# a report of income-based JSA or Income Support only while the model pays -# it, until 31 March 2026. SPC reg. 4A(5) excludes own awards from the -# Pension Credit routes, including income support: is_on_income_support reads -# it, from the calculated award for a claimant or partner and from their own -# report for anyone else. The reports identify the recipient whether -# receives_benefits_in_own_right is omitted or explicitly false. +# The Secretary of State excludes a person receiving Universal Credit, +# contributory ESA or contribution-based JSA (UC reg. 5(5)), and defines ESA +# and JSA without their income-related and income-based forms (UC Regs reg. +# 2). DWP doesn't count a person entitled to income-related ESA, income-based +# JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. +# 28). The UC predicate reads a report only in a year the model pays that +# benefit (income-based JSA and Income Support until 31 March 2026), and only +# if no reform neutralises it. The Secretary of State excludes own awards +# from the Pension Credit routes, including income support (SPC reg. 4A(5)): +# is_on_income_support reads it, from the calculated award for a claimant or +# partner and from their own report for anyone else. The reports identify the +# recipient whether receives_benefits_in_own_right is omitted or explicitly +# false. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index 0e378492c4..a1672030f8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -355,8 +355,9 @@ - name: Case 11, Scotland working age, a 19-year-old's own Housing Benefit leaves them a young person. # The Secretary of State excludes a person entitled to an existing benefit # from the qualifying young person only for the purposes of the UC - # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). SSI 2021/249 - # reg. 6(1) reads UC Regs reg. 5, so the 19-year-old stays in the family. + # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). The Scottish + # Ministers define the young person by UC Regs reg. 5 (SSI 2021/249 reg. + # 6(1)), so the 19-year-old stays in the family. period: 2026 absolute_error_margin: 0.01 input: @@ -402,6 +403,47 @@ country: [SCOTLAND, SCOTLAND, SCOTLAND, SCOTLAND] output: is_qualifying_young_person_for_universal_credit: [false, false, false, false] - # Contribution-based JSA is a reg. 5(5) benefit, so it alone excludes. + # The Secretary of State excludes a person receiving contribution-based + # JSA (reg. 5(5)), so only that report removes the young person. is_qualifying_young_person_under_universal_credit_regulation_5: [true, true, true, false] is_child_or_young_person_for_council_tax_reduction: [true, true, true, false] + +- name: Case 13, Scotland working age, a 19-year-old's own income-related ESA leaves the earning applicant a lone parent and raises the award. + # The Scottish Ministers define the young person by UC Regs reg. 5 (SSI + # 2021/249 reg. 6(1)), so the 19-year-old stays in the family and the + # 22-year-old applicant is a lone parent: 95.55 * 52 = 4,968.60. Applicable + # income is the applicant's earnings, 5,000, and the family's Child Benefit, + # 27.05 * 52 = 1,406.60: 6,406.60. The formula withdraws 20% of the excess, + # 0.2 * (6,406.60 - 4,968.60) = 287.60, from the 1,800 liability. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + employment_income: 5_000 + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + would_claim_council_tax_reduction: true + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + council_tax_reduction_applicable_amount: 4_968.60 + council_tax_reduction_applicable_income: 6_406.60 + council_tax_reduction: 1_512.40 diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 076d8ffa2c..759a86cd38 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -31,7 +31,8 @@ Each scheme applies its own exceptions. UC reg 83(1)(f) names a "child or qualifying young person"; HB reg 75F(1)(e) names a "child or young person". UC includes a 16-year-old school leaver (reg 5(1)(a)) who does not receive -benefits in their own right (reg 5(5)) and is not looked after (reg 4(6)). +benefits in their own right (reg 5(5); SI 2014/1230 reg. 28) and is not +looked after (reg 4(6)). HB young persons follow Child Benefit (reg 19), so only the education-based young-person role counts for Housing Benefit below. UC reg 83(1)(c) names "a claimant" for attendance allowance, which includes armed forces @@ -278,7 +279,10 @@ def own_esa(m): def own_benefits(m): - # UC reg 5(5): a personal report identifies an award in the person's own right. + # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI + # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it + # pays, as in 2025): a personal report identifies an award in the + # person's own right. return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 9fa4db56d9..f884cec685 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -687,7 +687,9 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): @pytest.mark.parametrize("report", ["esa_income_reported", "jsa_income_reported"]) def test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged(report): # A natural route-(a) case: no route or own-right Boolean is supplied. - # UC reg 5(5) and SPC reg 4A(5) still exclude the member's own award. + # SI 2014/1230 reg. 28 excludes the member's own income-related ESA or + # income-based JSA (paying in 2025) from the UC test, and SPC reg. 4A(5) + # from the Pension Credit test. family = ( [ { diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index d4bdd34426..2428d63449 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,8 +5,10 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report of Income Support, income-based JSA or a tax -credit only in a year the model pays that benefit. These tests check that: +s.3(3)). It reads a report only in a year the model pays that benefit, and +only if no reform neutralises it; these tests run without a reform, and +test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. +These tests check that: - any positive own report of an existing benefit the model pays that year makes the predicate false; @@ -43,8 +45,8 @@ "working_tax_credit_reported", ) TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") -# UC Regs reg. 2 defines ESA and JSA without their income-related and -# income-based forms, so reg. 5(5) reaches only these. +# The Secretary of State defines ESA and JSA without their income-related and +# income-based forms (UC Regs reg. 2), so reg. 5(5) reaches only these. REG_5_5_REPORTS = ( "universal_credit_reported", "esa_contrib_reported", @@ -75,14 +77,26 @@ def sometimes(draw): @st.composite -def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)): +def member( + draw, + ages, + training, + educations=EDUCATIONS, + own_right=(False, True), + reg_5_5_reports=True, +): """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely - leaves people for reg. 28 to exclude. Each existing benefit report is - positive one time in three. + leaves people for reg. 28 to exclude; with reg_5_5_reports false, the + person reports none. Each existing benefit report is positive one time in + three. """ - reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + reg_5_5_report = ( + draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + if reg_5_5_reports + else None + ) return { "age": draw(ages), "current_education": draw(st.sampled_from(educations)), @@ -101,10 +115,17 @@ def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)) young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) -# Someone who meets reg. 5 unless a report excludes them: 16 to 19, on a -# non-advanced course and not flagged as receiving benefits in their own right. +# Someone who meets reg. 5 unless reg. 28 excludes them: 16 to 18, on a +# non-advanced course, not flagged as receiving benefits in their own right +# and reporting no UC, ESA or JSA award under reg. 5(5). A 19-year-old needs +# the entry and terminal-date inputs (reg. 5(1)(b) and (2)), which these +# draws don't set. qualifying = member( - st.integers(16, 19), st.just(False), NON_ADVANCED, own_right=(False,) + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, ) INPUTS = ( ( @@ -141,10 +162,12 @@ def families(draw): def qualifying_families(draw): """Families where reg. 28 alone decides most members' status. - The claimant, the partner or dependant, and both dependants would meet - reg. 5 two times in three. A partner is otherwise an older adult whose own - reports reg. 28 must not pass to the claimant, except for the couple's - tax credit award. + Each slot is drawn from qualifying, which meets reg. 5, or from the + general strategies. Over 10,000 derandomised draws for 2025, the + claimant met reg. 5 in 61% of draws, the partner or dependant in 61%, + and each dependant in 65%. A partner is otherwise an older adult whose + own reports reg. 28 must not pass to the claimant, except for the + couple's tax credit award. """ couple = draw(st.booleans()) claimant = draw(st.one_of(qualifying, qualifying, adult)) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py new file mode 100644 index 0000000000..c1d64e1807 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py @@ -0,0 +1,170 @@ +"""The UC qualifying young person test under reforms that neutralise a benefit. + +The Secretary of State excludes a person entitled to an existing benefit +from the UC qualifying young person test (SI 2014/1230 regs. 2(1) and 28). +A reform that neutralises a benefit's variable abolishes that benefit, so +nobody can be entitled to it and reg. 28 can't exclude on it. The predicate +checks each report against its own variable: + +- Income Support: ``income_support``; +- income-based JSA: ``jsa_income``; +- income-related ESA: ``esa_income``; +- Housing Benefit: ``housing_benefit``; +- Child Tax Credit: ``child_tax_credit`` or ``tax_credits``; +- Working Tax Credit: ``working_tax_credit`` or ``tax_credits``. + +The model pays all six in 2024, so the tests read 2024 unless they name +another year. +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation + +PREDICATE = "is_qualifying_young_person_for_universal_credit" +# Each report, and the variables whose neutralisation lifts its exclusion. +ROWS = { + "income_support_reported": {"income_support"}, + "jsa_income_reported": {"jsa_income"}, + "esa_income_reported": {"esa_income"}, + "housing_benefit_reported": {"housing_benefit"}, + "child_tax_credit_reported": {"child_tax_credit", "tax_credits"}, + "working_tax_credit_reported": {"working_tax_credit", "tax_credits"}, +} +NEUTRALISED = sorted(set().union(*ROWS.values())) + + +def neutralising(*names): + class neutralise(Reform): + def apply(self): + for name in names: + self.neutralize_variable(name) + + return neutralise + + +def young_people(reports, year, reform=None, amount=3_000): + """One household per report: a parent aged 45 and a 17-year-old on a + non-advanced course who reports that benefit (£3,000 a year unless the + test says otherwise). North East, owned outright, no income.""" + people, benunits, households = {}, {}, {} + for index, report in enumerate(reports): + parent, young = f"parent_{index}", f"young_{index}" + people[parent] = { + "age": {year: 45}, + "is_parent": {year: True}, + "is_claimant_or_partner": {year: True}, + } + people[young] = { + "age": {year: 17}, + "current_education": {year: "POST_SECONDARY"}, + "is_claimant_or_partner": {year: False}, + report: {year: amount}, + } + benunits[f"benunit_{index}"] = {"members": [parent, young]} + households[f"household_{index}"] = { + "members": [parent, young], + "region": {year: "NORTH_EAST"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=reform, + ) + + +def young_flags(sim, year): + return sim.calculate(PREDICATE, year)[1::2].tolist() + + +def parent_awards(sim, year): + return [round(float(x), 2) for x in sim.calculate("universal_credit", year)] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_only_the_exclusions_on_the_benefit_it_neutralises( + neutralised, +): + year = 2024 + reform = neutralising(neutralised) if neutralised else None + sim = young_people(list(ROWS), year, reform) + expected = [neutralised in variables for variables in ROWS.values()] + assert young_flags(sim, year) == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( + neutralised, +): + """Two 18-year-olds on non-advanced courses form a couple, and the + partner reports the couple's Child Tax Credit or Working Tax Credit + award (Tax Credits Act 2002 s.3(3)(a)).""" + year = 2024 + people, benunits, households = {}, {}, {} + credits = ("child_tax_credit_reported", "working_tax_credit_reported") + for index, report in enumerate(credits): + claimant, partner = f"claimant_{index}", f"partner_{index}" + for name in (claimant, partner): + people[name] = { + "age": {year: 18}, + "current_education": {year: "UPPER_SECONDARY"}, + "is_claimant_or_partner": {year: True}, + } + people[partner][report] = {year: 1_000} + benunits[f"benunit_{index}"] = {"members": [claimant, partner]} + households[f"household_{index}"] = {"members": [claimant, partner]} + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=neutralising(neutralised) if neutralised else None, + ) + lifted = [neutralised in ROWS[report] for report in credits] + expected = [flag for flag in lifted for _ in range(2)] + assert sim.calculate(PREDICATE, year).tolist() == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_alone_leaves_a_working_tax_credit_exclusion(): + year = 2024 + credits = ["child_tax_credit_reported", "working_tax_credit_reported"] + alone = young_people(credits, year, neutralising("child_tax_credit")) + both = young_people(credits, year, neutralising("tax_credits")) + assert young_flags(alone, year) == [True, False] + assert young_flags(both, year) == [True, True] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_housing_benefit_restores_an_own_housing_benefit_young_person(): + """Household G: the 17-year-old reports their own Housing Benefit in 2026.""" + year = 2026 + kept = young_people(["housing_benefit_reported"], year) + removed = young_people( + ["housing_benefit_reported"], year, neutralising("housing_benefit") + ) + assert young_flags(kept, year) == [False] + assert young_flags(removed, year) == [True] + assert parent_awards(kept, year)[0] == pytest.approx(5_098.80) + assert parent_awards(removed, year)[0] == pytest.approx(9_321.36) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_restores_an_own_child_tax_credit_young_person(): + """Household D: the 17-year-old reports their own Child Tax Credit in + 2024, £1,000.""" + year = 2024 + sims = {} + for name, reform in ( + ("kept", None), + ("child_tax_credit", neutralising("child_tax_credit")), + ("tax_credits", neutralising("tax_credits")), + ): + sims[name] = young_people( + ["child_tax_credit_reported"], year, reform, amount=1_000 + ) + assert young_flags(sims["kept"], year) == [False] + assert parent_awards(sims["kept"], year)[0] == pytest.approx(4_721.40) + for name in ("child_tax_credit", "tax_credits"): + assert young_flags(sims[name], year) == [True] + assert parent_awards(sims[name], year)[0] == pytest.approx(8_721.36) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index f4a73f9416..94ddd31351 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -13,13 +13,18 @@ class is_qualifying_young_person_for_universal_credit(Variable): "5(5)) and no existing benefit: income-based JSA, income-related ESA, " "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " - "29). Reg. 28 turns on entitlement, so the formula reads a person's own " - "report of Income Support, income-based JSA or a tax credit only in a " - "year the model pays that benefit (gov.dwp.income_support.active, " - "gov.dwp.JSA.income.active, gov.dwp.tax_credits.active) and no reform " - "neutralises it. It reads Housing Benefit and income-related ESA " - "reports in every year. For a claimant or partner it also reads the " - "couple's joint tax credit award (Tax Credits Act 2002 s.3(3))." + "29). Reg. 28 turns on entitlement, so the formula reads a report only " + "in a year the model pays that benefit, and only if no reform " + "neutralises it. The model pays Income Support, income-based JSA and " + "tax credits only while gov.dwp.income_support.active, " + "gov.dwp.JSA.income.active and gov.dwp.tax_credits.active hold, and " + "has no closure date for Housing Benefit or income-related ESA. The " + "formula checks each report against its own variable: income_support, " + "jsa_income, esa_income, housing_benefit, child_tax_credit or " + "tax_credits for Child Tax Credit, and working_tax_credit or " + "tax_credits for Working Tax Credit. For a claimant or partner it also " + "reads the couple's joint tax credit award (Tax Credits Act 2002 " + "s.3(3)), each credit by its own check." ) definition_period = YEAR reference = [ @@ -32,25 +37,33 @@ class is_qualifying_young_person_for_universal_credit(Variable): ] def formula(person, period, parameters): - # TP reg. 28 excludes a person entitled to an existing benefit (TP - # reg. 2(1)). The formula reads raw person-level reports, so it - # calculates no family benefit that depends on QYP status. It reads a - # dependant's own reports, never a parent's. + # The Secretary of State excludes a person entitled to an existing + # benefit (TP regs. 2(1) and 28). The formula reads raw person-level + # reports, so it calculates no family benefit that depends on QYP + # status. It reads a dependant's own reports, never a parent's. dwp = parameters(period).gov.dwp system = person.simulation.tax_benefit_system - def paying(active, name): + def paying(active, *names): # Nobody is entitled to a benefit the model no longer pays, or one # a reform neutralises. - return bool(active) and not system.get_variable(name).is_neutralized + return bool(active) and not any( + system.get_variable(name).is_neutralized for name in names + ) income_support_paying = paying(dwp.income_support.active, "income_support") jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") - tax_credits_paying = paying(dwp.tax_credits.active, "tax_credits") - tax_credits_reported = tax_credits_paying * ( - person("child_tax_credit_reported", period) - + person("working_tax_credit_reported", period) + esa_income_paying = paying(True, "esa_income") + housing_benefit_paying = paying(True, "housing_benefit") + child_tax_credit_paying = paying( + dwp.tax_credits.active, "child_tax_credit", "tax_credits" + ) + working_tax_credit_paying = paying( + dwp.tax_credits.active, "working_tax_credit", "tax_credits" ) + tax_credits_reported = child_tax_credit_paying * person( + "child_tax_credit_reported", period + ) + working_tax_credit_paying * person("working_tax_credit_reported", period) # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, # so the couple's award belongs to the claimant and the partner. A # dependant claims as a single person, so their award stays theirs. @@ -62,10 +75,13 @@ def paying(active, name): claimant_or_partner & (couple_tax_credits_reported > 0) ) entitled_to_existing_benefit = ( - (person("esa_income_reported", period) > 0) + (esa_income_paying & (person("esa_income_reported", period) > 0)) | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) | (income_support_paying & (person("income_support_reported", period) > 0)) - | (person("housing_benefit_reported", period) > 0) + | ( + housing_benefit_paying + & (person("housing_benefit_reported", period) > 0) + ) | receives_tax_credits ) return ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py index 3e1e48929b..034e6772fe 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -11,13 +11,14 @@ class is_qualifying_young_person_under_universal_credit_regulation_5(Variable): "non-advanced education or approved training that meets the entry and " "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who receives " "no Universal Credit, contributory ESA or contribution-based JSA (reg. " - "5(5)). Universal Credit Regulations 2013 reg. 2 defines employment and " - "support allowance and jobseeker's allowance without their " - "income-related and income-based forms, so this test leaves those to " + "5(5)). The Secretary of State defines employment and support " + "allowance and jobseeker's allowance without their income-related and " + "income-based forms (UC Regs 2013 reg. 2), so this test leaves those to " "SI 2014/1230 reg. 28, which is_qualifying_young_person_for_universal_credit " - "applies. Northern Ireland's twin is UC Regs (NI) 2016 reg. 6. Scotland's " - "working-age Council Tax Reduction scheme defines its young person by " - "this regulation (SSI 2021/249 reg. 6(1))." + "applies. Northern Ireland's twin is reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 (SR 2016/216). The Scottish " + "Ministers define the young person for Scotland's working-age Council " + "Tax Reduction by this regulation (SSI 2021/249 reg. 6(1))." ) definition_period = YEAR reference = [ @@ -47,8 +48,9 @@ def formula(person, period, parameters): period, ) ) - # Reg. 5(5) excludes a person receiving Universal Credit, contributory - # ESA or contribution-based JSA, on both routes. The formula reads raw + # The Secretary of State excludes a person receiving Universal Credit, + # contributory ESA or contribution-based JSA (reg. 5(5)), on both + # routes. The formula reads raw # person-level reports, so it calculates no family benefit that # depends on qualifying young person status. receives_reg_5_5_benefit = ( From 9ad6f2f553b35711397a48637ebe46303e583c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:34:56 +0000 Subject: [PATCH 06/20] Read income-based JSA in the benefit cap oracle only while it pays The specified-benefit oracle now counts a young person's income-based JSA report as an own award only in a year the model pays income-based JSA, as the UC predicate does under SI 2014/1230 reg 28. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- ...st_benefit_cap_specified_benefits_properties.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 759a86cd38..1c328e968b 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -87,6 +87,7 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable from policyengine_uk.scenarios.uc_reform import REBALANCING_YEARS +from policyengine_uk.tax_benefit_system import system as baseline_system from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input YEAR = 2025 @@ -278,12 +279,19 @@ def own_esa(m): return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 -def own_benefits(m): +def own_benefits(m, year=YEAR): # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it # pays, as in 2025): a personal report identifies an award in the - # person's own right. - return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) + # person's own right. Reg. 28 turns on entitlement, so an income-based + # JSA report counts only in a year the model pays it. + dwp = baseline_system.parameters(f"{year}-01-01").gov.dwp + reports = [ + report + for report in OWN_RIGHT_REPORTS + if report != "jsa_income_reported" or dwp.JSA.income.active + ] + return any(m.get(report, 0) > 0 for report in reports) def reference_specified_benefit(members, benunit_inputs, scheme): From d5d32074d569331db3cda217701cdee5ed152d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:42:59 +0000 Subject: [PATCH 07/20] Draw at most one existing benefit for a qualifying property member The exact reg 28 property now gives each qualifying member at most one existing benefit report, so reg 28 leaves more of them in the test and the property catches a couple-sum mutant again. The changelog fragment says the predicate reads a report only while the model pays the benefit and no reform neutralises it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- changelog.d/2804.fixed.md | 2 +- ...ung_person_existing_benefits_properties.py | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index f6bb42efa8..1585e60f48 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA or Housing Benefit, or their own income-based JSA, Income Support, Child Tax Credit or Working Tax Credit while the model pays it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). Scotland's working-age Council Tax Reduction now reads the Universal Credit Regulations reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays it and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 2428d63449..452f61f63b 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -84,13 +84,15 @@ def member( educations=EDUCATIONS, own_right=(False, True), reg_5_5_reports=True, + one_existing_benefit=False, ): """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely leaves people for reg. 28 to exclude; with reg_5_5_reports false, the person reports none. Each existing benefit report is positive one time in - three. + three; with one_existing_benefit true, the person reports at most one, + and none one time in three. """ reg_5_5_report = ( draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) @@ -108,10 +110,17 @@ def member( name: draw(amount) if name == reg_5_5_report else 0.0 for name in REG_5_5_REPORTS }, - **{name: draw(sometimes()) for name in EXISTING_BENEFITS}, + **existing_benefit_reports(draw, one_existing_benefit), } +def existing_benefit_reports(draw, one_existing_benefit): + if not one_existing_benefit: + return {name: draw(sometimes()) for name in EXISTING_BENEFITS} + report = draw(st.sampled_from((None,) * 3 + EXISTING_BENEFITS)) + return {name: draw(amount) if name == report else 0.0 for name in EXISTING_BENEFITS} + + young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) @@ -119,13 +128,15 @@ def member( # non-advanced course, not flagged as receiving benefits in their own right # and reporting no UC, ESA or JSA award under reg. 5(5). A 19-year-old needs # the entry and terminal-date inputs (reg. 5(1)(b) and (2)), which these -# draws don't set. +# draws don't set. They report at most one existing benefit, so reg. 28 +# often leaves them in the test. qualifying = member( st.integers(16, 18), st.just(False), NON_ADVANCED, own_right=(False,), reg_5_5_reports=False, + one_existing_benefit=True, ) INPUTS = ( ( @@ -164,8 +175,8 @@ def qualifying_families(draw): Each slot is drawn from qualifying, which meets reg. 5, or from the general strategies. Over 10,000 derandomised draws for 2025, the - claimant met reg. 5 in 61% of draws, the partner or dependant in 61%, - and each dependant in 65%. A partner is otherwise an older adult whose + claimant met reg. 5 in 59% of draws, the partner or dependant in 62%, + and the dependants in 65% and 69%. A partner is otherwise an older adult whose own reports reg. 28 must not pass to the claimant, except for the couple's tax credit award. """ From e7fa06ebdc4914385554546d73b8c78d4a6566e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:14:00 +0000 Subject: [PATCH 08/20] Follow the model's legacy closure in the UC qualifying young person test The model ends a person's own Income Support, income-based JSA and income-related ESA when person_legacy_benefits_closed holds, and the family's Housing Benefit and tax credits when legacy_benefits_closed holds outside the Pension Credit age conditions. Neither closure reads a Universal Credit variable, so the predicate follows both: nobody is entitled under SI 2014/1230 reg. 28 to an award the model has ended. Under disable_simulated_benefits the model pays each benefit as reported after its year gate closes, so the year gates also read that switch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KY1QwmCKh9DpfvfPk --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 8 +- ...ualifying_young_person_legacy_closure.yaml | 292 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 8 +- ...nefit_cap_specified_benefits_properties.py | 5 +- .../tests/test_disable_simulated_benefits.py | 64 ++++ .../test_legacy_award_readers_properties.py | 6 +- ...ung_person_existing_benefits_properties.py | 165 ++++++++-- ..._qualifying_young_person_legacy_closure.py | 99 ++++++ ...fying_young_person_for_universal_credit.py | 97 ++++-- ...son_under_universal_credit_regulation_5.py | 5 +- 11 files changed, 684 insertions(+), 67 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 1585e60f48..fd64aee48a 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays it and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit while the model pays it on that report (its year gate and the model's legacy closure) and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 0d608f2111..1b10314ae3 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,10 +4,14 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report only in a year the model pays that benefit, and only if +# It reads a report only while the model pays that benefit on it, and only if # no reform neutralises it. The model pays Income Support and income-based # JSA until 31 March 2026 and tax credits until 5 April 2025, and has no -# closure date for Housing Benefit or income-related ESA. The first six cases, +# closure date for Housing Benefit or income-related ESA. It also ends a +# person's legacy awards when its annual migration proxy closes them, and +# the predicate follows that closure +# (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases +# no report closes the young person's legacy awards. The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml new file mode 100644 index 0000000000..c7c2809447 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -0,0 +1,292 @@ +# The model ends a person's legacy awards once its annual migration proxy +# closes them, and the UC qualifying young person test (SI 2014/1230 reg. 28) +# follows that closure: nobody is entitled to an award the model has ended. +# The model ends a person's own Income Support, income-based JSA and +# income-related ESA when person_legacy_benefits_closed holds +# (income_support, jsa_income, esa_income_report_closed), and the family's +# Housing Benefit and tax credits when legacy_benefits_closed holds +# (housing_benefit_eligible, is_CTC_eligible, is_WTC_eligible). A +# dependant's own Income Support or income-based JSA report from 2026, or own +# tax credit report from 2025, closes the dependant's legacy awards +# (gov.dwp.income_support.active, gov.dwp.JSA.income.active and +# gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and +# 6 April 2025). The closure reports exclude nobody, as the benefit has closed. +# +# Every case uses one household: a parent aged 45 and a 17-year-old on a +# non-advanced course in the parent's benefit unit, North East, owned +# outright, no income. The parent claims Universal Credit as a single +# claimant over 25 (universal_credit/standard_allowance/amount.yaml +# SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the +# first child higher amount for a qualifying young person +# (elements/child/first/higher_amount.yaml: £339.00 in 2025-26, £351.88 in +# 2026-27). So: +# 2025 with the child element: 12 x (400.14 + 339.00) = 8,869.68 +# 2026 with the child element: 12 x (424.90 + 351.88) = 9,321.36 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report alone removes the child element in 2026. + # No closure applies: the young person reports no closed benefit, so the + # model pays the £3,000 of income-related ESA (esa_income) and the young + # person is entitled to it. UC: 12 x 424.90 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, false] + esa_income: 3_000 + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report with their own Income Support report in 2026 leaves the child element. + # Income Support closed on 1 April 2026, so the young person's own report + # closes their legacy awards (person_legacy_benefits_closed) and the model + # pays no income-related ESA on their report (esa_income 0). Neither report + # excludes them. UC: 12 x (424.90 + 351.88) = 9,321.36; child element + # 12 x 351.88 = 4,222.56. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own income-based JSA report in 2026 leaves the child element. + # Income-based JSA closed on 1 April 2026, so the young person's own report + # closes their legacy awards and the model pays no income-related ESA on + # their report. UC: 12 x (424.90 + 351.88) = 9,321.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own Child Tax Credit report in 2025 leaves the child element. + # Tax credits closed on 6 April 2025, so the young person's own Child Tax + # Credit report closes their legacy awards and the model pays no + # income-related ESA on their report. UC: 12 x (400.14 + 339.00) = 8,869.68; + # child element 12 x 339.00 = 4,068.00. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Income Support report with their own Child Tax Credit report in 2025 leaves the child element. + # Income Support still pays in 2025, but the young person's own Child Tax + # Credit report closes their legacy awards, which ends their Income Support + # too. UC: 12 x (400.14 + 339.00) = 8,869.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own income-based JSA report with their own Child Tax Credit report in 2025 leaves the child element. + # Income-based JSA still pays in 2025, but the young person's own Child Tax + # Credit report closes their legacy awards, so the model pays no + # income-based JSA on their report (jsa_income 0). UC: 8,869.68 as above. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + jsa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Housing Benefit report after the parent's legacy awards close leaves the child element. + # The parent's own Income Support report from 2026 closes the family's + # legacy awards (legacy_benefits_closed), and the family doesn't meet the + # Pension Credit age conditions, so the model pays no Housing Benefit on + # the young person's report (housing_benefit_eligible). UC: 12 x (424.90 + + # 351.88) = 9,321.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + housing_benefit_eligible: false + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own Housing Benefit report after the parent's tax credits close leaves the child element. + # The parent's own Child Tax Credit report from 2025 closes the family's + # legacy awards, which ends the family's Housing Benefit. UC: 12 x (400.14 + # + 339.00) = 8,869.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + housing_benefit_eligible: false + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 859fab063e..38d7067893 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,9 +3,11 @@ # and JSA without their income-related and income-based forms (UC Regs reg. # 2). DWP doesn't count a person entitled to income-related ESA, income-based # JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. -# 28). The UC predicate reads a report only in a year the model pays that -# benefit (income-based JSA and Income Support until 31 March 2026), and only -# if no reform neutralises it. The Secretary of State excludes own awards +# 28). The UC predicate reads a report only while the model pays that +# benefit on it (income-based JSA and Income Support until 31 March 2026, and +# income-related ESA, income-based JSA and Income Support only until the +# person's legacy awards close, person_legacy_benefits_closed), and only if +# no reform neutralises it. The Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 1c328e968b..058700ec0c 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -283,8 +283,9 @@ def own_benefits(m, year=YEAR): # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it # pays, as in 2025): a personal report identifies an award in the - # person's own right. Reg. 28 turns on entitlement, so an income-based - # JSA report counts only in a year the model pays it. + # person's own right. The Secretary of State ties reg. 28's exclusion to + # entitlement, so the oracle counts an income-based JSA report only in a + # year the model pays it. dwp = baseline_system.parameters(f"{year}-01-01").gov.dwp reports = [ report diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 46ae03d233..a53be29791 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -818,3 +818,67 @@ def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): # reported ESA in household income while incorrectly ending their status. closed = by_person(reformed, "person_legacy_benefits_closed", year) assert closed == dict.fromkeys(closed, False) + + +# A parent aged 45 and a 17-year-old on a non-advanced course in each family; +# the 17-year-old reports £3,000 of one existing benefit (SI 2014/1230 reg. +# 2(1)) in the dataset year. +YOUNG_PERSON_REPORTS = { + "income_support_reported": "income_support", + "jsa_income_reported": "jsa_income", + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", + "working_tax_credit_reported": "working_tax_credit", +} + + +def young_person_situation(): + people, benunits, households = {}, {}, {} + for report in YOUNG_PERSON_REPORTS: + parent, young = f"{report}_parent", f"{report}_young" + people[parent] = dict(adult(45, True), is_parent=every_year(True)) + people[young] = { + "age": every_year(17), + "is_claimant_or_partner": every_year(False), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + benunits[report] = {"members": [parent, young]} + households[report] = {"members": [parent, young]} + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_person( + year, +): + # The reform pays each benefit at its dataset-year report after the + # model's closure dates (income-based JSA from 2026, tax credits from + # 2025), so the young person stays entitled to it and reg. 28 excludes + # them from the UC qualifying young person test in every year. Income + # Support is the claimant's family's award, so the model pays none on a + # dependant's own report, with the reform or without it; the predicate + # reads that report in the years the model pays Income Support to anyone. + reformed = Simulation(situation=young_person_situation(), scenario=REFORM) + unreformed = Simulation(situation=young_person_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + paid = [ + float(reformed.calculate(benefit, year)[i]) + for i, benefit in enumerate(YOUNG_PERSON_REPORTS.values()) + ] + assert paid == [0, 3_000, 3_000, 3_000, 3_000, 3_000] + assert reformed.calculate(predicate, year)[1::2].tolist() == [False] * 6 + # Without the reform, the model stops paying tax credits from 2025 and + # Income Support and income-based JSA from 2026, and those reports + # exclude nobody. + tax_credits_closed = year >= 2025 + legacy_closed = year >= 2026 + assert unreformed.calculate(predicate, year)[1::2].tolist() == [ + legacy_closed, + legacy_closed, + False, + False, + tax_credits_closed, + tax_credits_closed, + ] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index f884cec685..734ed3c133 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -687,9 +687,9 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): @pytest.mark.parametrize("report", ["esa_income_reported", "jsa_income_reported"]) def test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged(report): # A natural route-(a) case: no route or own-right Boolean is supplied. - # SI 2014/1230 reg. 28 excludes the member's own income-related ESA or - # income-based JSA (paying in 2025) from the UC test, and SPC reg. 4A(5) - # from the Pension Credit test. + # The Secretary of State excludes the member's own income-related ESA or + # income-based JSA (paying in 2025) from the UC test (SI 2014/1230 reg. + # 28) and from the Pension Credit test (SPC reg. 4A(5)). family = ( [ { diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 452f61f63b..534a714213 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,16 +5,26 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report only in a year the model pays that benefit, and -only if no reform neutralises it; these tests run without a reform, and -test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. -These tests check that: - -- any positive own report of an existing benefit the model pays that year - makes the predicate false; +s.3(3)). It reads a report only while the model pays that benefit on it, +and only if no reform neutralises it; these tests run without a reform, and +test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. The +model stops paying a benefit when its year gate closes, and ends a person's +legacy awards when its annual migration proxy closes them: their own Income +Support, income-based JSA and income-related ESA when +person_legacy_benefits_closed holds, and the family's Housing Benefit and +tax credits when legacy_benefits_closed holds and the family doesn't meet +the Pension Credit age conditions. The oracle reads those three closure +variables from the model. These tests check that: + +- any positive own report of an existing benefit the model pays on it that + year makes the predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 reg. 5 test less the person's reported UC, ESA and JSA awards; -- a claimant's or partner's reports never change a dependant's predicate; +- a claimant's or partner's reports change a dependant's predicate only by + closing the family's legacy awards; +- a person whose legacy awards have closed is never excluded through their + own income-related ESA report, nor through a Housing Benefit report once + the family's awards close outside the Pension Credit age conditions; - a parent's Universal Credit never rises when their young person's own report turns positive; - the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. @@ -174,9 +184,8 @@ def qualifying_families(draw): """Families where reg. 28 alone decides most members' status. Each slot is drawn from qualifying, which meets reg. 5, or from the - general strategies. Over 10,000 derandomised draws for 2025, the - claimant met reg. 5 in 59% of draws, the partner or dependant in 62%, - and the dependants in 65% and 69%. A partner is otherwise an older adult whose + general strategies. Each slot meets reg. 5 in about three draws in + five. A partner is otherwise an older adult whose own reports reg. 28 must not pass to the claimant, except for the couple's tax credit award. """ @@ -196,7 +205,36 @@ def qualifying_families(draw): return [claimant, second] + dependants +# Someone who meets reg. 5 unless reg. 28 excludes them and reports each +# existing benefit one time in three, so a report that closes their legacy +# awards often sits beside an income-related ESA or Housing Benefit report. +reporting_qualifying = member( + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, +) + + +@st.composite +def closure_families(draw): + """A claimant and a second member who meet reg. 5 one time in two, and + two dependants who always do, each reporting any existing benefit.""" + couple = draw(st.booleans()) + claimant = draw(st.one_of(reporting_qualifying, adult)) + claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + second = draw(st.one_of(reporting_qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(reporting_qualifying), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) +closure_units_strategy = st.lists(closure_families(), min_size=UNITS, max_size=UNITS) qualifying_units_strategy = st.lists( qualifying_families(), min_size=UNITS, max_size=UNITS ) @@ -277,24 +315,52 @@ def get(name): ) -def reg_28_exclusions(units, year): - """Who reg. 28 excludes: own reports of benefits the model pays that year, - and, for a claimant or partner, the couple's tax credit award.""" +def open_awards(sim, year): + """For each report, whether the model's legacy closure leaves the award on + it open, person by person.""" + own_open = ~sim.calculate("person_legacy_benefits_closed", year) + family_closed = sim.calculate("legacy_benefits_closed", year, map_to="person") + pension_age = sim.calculate( + "meets_pension_credit_age_conditions", year, map_to="person" + ) + return { + "income_support_reported": own_open, + "jsa_income_reported": own_open, + "esa_income_reported": own_open, + "housing_benefit_reported": ~(family_closed & ~pension_age), + "child_tax_credit_reported": ~family_closed, + "working_tax_credit_reported": ~family_closed, + } + + +def reg_28_exclusions(units, year, sim): + """Who reg. 28 excludes: own reports of benefits the model pays on them + that year, and, for a claimant or partner, the couple's tax credit + award.""" paying = PAYING[year] - own = np.array([any(p[n] > 0 for n in paying) for p in flatten(units)], dtype=bool) - couple_tax_credits = np.array( + award_open = open_awards(sim, year) + people = flatten(units) + own = np.array( [ - p["is_claimant_or_partner"] - and any( - q["is_claimant_or_partner"] and q[n] > 0 - for q in members - for n in paying.intersection(TAX_CREDITS) - ) - for members in units - for p in members + any(p[n] > 0 and award_open[n][i] for n in paying) + for i, p in enumerate(people) ], dtype=bool, ) + couple_tax_credits = np.zeros(len(people), dtype=bool) + start = 0 + for members in units: + indices = range(start, start + len(members)) + award = any( + people[j]["is_claimant_or_partner"] + and people[j][n] > 0 + and award_open[n][j] + for j in indices + for n in paying.intersection(TAX_CREDITS) + ) + for j in indices: + couple_tax_credits[j] = people[j]["is_claimant_or_partner"] and award + start += len(members) return own | couple_tax_credits @@ -302,7 +368,7 @@ def reg_28_exclusions(units, year): @given(units=units_strategy, year=st.sampled_from(YEARS)) def test_any_own_existing_benefit_report_excludes_the_person(units, year): sim = simulate(units, year) - assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year)) + assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year, sim)) @SETTINGS @@ -324,8 +390,12 @@ def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): @SETTINGS @given(units=units_strategy, year=st.sampled_from(YEARS)) -def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): - reported = predicate(simulate(units, year), year) +def test_a_claimants_reports_change_a_dependant_only_by_closing_the_family(units, year): + sim = simulate(units, year) + reported = predicate(sim, year) + family_closed = sim.calculate( + "legacy_benefits_closed", year, map_to="person" + ).copy() cleared = predicate( simulate( with_zero(units, EXISTING_BENEFITS, lambda p: p["is_claimant_or_partner"]), @@ -334,7 +404,44 @@ def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): year, ) dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) - assert reported[dependant].tolist() == cleared[dependant].tolist() + # Where the couple's reports leave the family's legacy awards open, they + # never change a dependant's predicate. + kept = dependant & ~family_closed + assert reported[kept].tolist() == cleared[kept].tolist() + # Where they close them, they can only lift a dependant's exclusion. + closed = dependant & family_closed + assert not np.any(~reported[closed] & cleared[closed]) + + +@SETTINGS +@given(units=closure_units_strategy, year=st.sampled_from((2025, 2026))) +def test_a_closed_legacy_award_never_excludes(units, year): + # The model ends a person's own income-related ESA once their legacy + # awards close (person_legacy_benefits_closed, read by + # esa_income_report_closed), and the family's Housing Benefit once the + # claimant's and partner's close outside the Pension Credit age + # conditions (housing_benefit_eligible). Clearing those reports leaves + # every predicate as it was. Neither report closes a legacy award, so + # clearing it leaves the closures as they were. Tax credits close from + # 2025 and Income Support and income-based JSA from 2026. + sim = simulate(units, year) + reported = predicate(sim, year) + award_open = open_awards(sim, year) + closed = { + name: ~award_open[name] + for name in ("esa_income_reported", "housing_benefit_reported") + } + cleared_units, index = [], 0 + for members in units: + cleared_members = [] + for p in members: + cleared_members.append( + dict(p, **{name: 0.0 for name in closed if closed[name][index]}) + ) + index += 1 + cleared_units.append(cleared_members) + cleared = predicate(simulate(cleared_units, year), year) + assert reported.tolist() == cleared.tolist() @SETTINGS @@ -381,5 +488,5 @@ def family(young_report): @given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) def test_the_predicate_is_reg_5_less_reg_28(units, year): sim = simulate(units, year) - expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year) + expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year, sim) assert predicate(sim, year).tolist() == expected.tolist() diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py new file mode 100644 index 0000000000..2806880775 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py @@ -0,0 +1,99 @@ +"""The UC qualifying young person test follows the family's tax credit closure. + +The model pays tax credits to the family, on every member's reports, and +ends them when the claimant's and partner's legacy awards close +(legacy_benefits_closed, read by is_CTC_eligible and is_WTC_eligible). +Nobody is entitled to an award the model has ended, so the predicate reads a +young person's own tax credit report only while the family's legacy awards +stay open (SI 2014/1230 reg. 28). + +In the baseline the family's legacy awards close only once a benefit has +closed, and tax credits close first (gov.dwp.tax_credits.active turns false +on 6 April 2025, Income Support on 1 April 2026), so the year gate already +covers every closed tax credit report. These tests keep tax credits paying +in 2026 by a parameter reform, so the family's closure decides. + +The household: a parent aged 45 and a 17-year-old on a non-advanced course, +North East, owned outright, no income, in 2026. The parent claims Universal +Credit as a single claimant over 25: £424.90 a month (standard_allowance +SINGLE_OLD) and £351.88 a month for a qualifying young person (child element +first higher amount), so 12 x (424.90 + 351.88) = £9,321.36 with the child +element and 12 x 424.90 = £5,098.80 without it. +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +TAX_CREDITS_PAYING = {"gov.dwp.tax_credits.active": {"2020-01-01.2100-12-31": True}} + + +def household(young_report, parent_report=None): + parent = { + "age": {YEAR: 45}, + "is_parent": {YEAR: True}, + "is_claimant_or_partner": {YEAR: True}, + } + if parent_report: + parent[parent_report] = {YEAR: 3_000} + return { + "people": { + "parent": parent, + "young_person": { + "age": {YEAR: 17}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: False}, + young_report: {YEAR: 1_000}, + }, + }, + "benunits": {"benunit": {"members": ["parent", "young_person"]}}, + "households": { + "household": { + "members": ["parent", "young_person"], + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + + +def outcome(situation, reform): + sim = Simulation(situation=situation, reform=reform) + return ( + sim.calculate("legacy_benefits_closed", YEAR).tolist(), + sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR).tolist(), + round(float(sim.calculate("universal_credit", YEAR)[0]), 2), + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_a_young_persons_tax_credit_report_excludes_while_the_family_stays_open( + report, +): + # Tax credits pay in 2026 under the reform, and nothing closes the + # family's legacy awards, so the young person's own report excludes them. + assert outcome(household(report), TAX_CREDITS_PAYING) == ( + [False], + [False, False], + 5_098.80, + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_the_parents_closed_legacy_award_ends_the_young_persons_tax_credits( + report, +): + # The parent's own Income Support report in 2026, after Income Support + # closed, closes the family's legacy awards, which ends the family's tax + # credits, the young person's report included. + situation = household(report, "income_support_reported") + assert outcome(situation, TAX_CREDITS_PAYING) == ( + [True], + [False, True], + 9_321.36, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 94ddd31351..e0426e32ea 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -13,18 +13,32 @@ class is_qualifying_young_person_for_universal_credit(Variable): "5(5)) and no existing benefit: income-based JSA, income-related ESA, " "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " - "29). Reg. 28 turns on entitlement, so the formula reads a report only " - "in a year the model pays that benefit, and only if no reform " - "neutralises it. The model pays Income Support, income-based JSA and " - "tax credits only while gov.dwp.income_support.active, " - "gov.dwp.JSA.income.active and gov.dwp.tax_credits.active hold, and " - "has no closure date for Housing Benefit or income-related ESA. The " - "formula checks each report against its own variable: income_support, " - "jsa_income, esa_income, housing_benefit, child_tax_credit or " - "tax_credits for Child Tax Credit, and working_tax_credit or " - "tax_credits for Working Tax Credit. For a claimant or partner it also " - "reads the couple's joint tax credit award (Tax Credits Act 2002 " - "s.3(3)), each credit by its own check." + "29). The Secretary of State ties the exclusion to entitlement (reg. " + "28), so the formula reads a report only while the model pays that " + "benefit on it, and only if no reform neutralises it. The model pays " + "Income Support, income-based JSA and tax credits only while " + "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " + "gov.dwp.tax_credits.active hold, or while " + "gov.contrib.policyengine.disable_simulated_benefits pays them as " + "reported, and has no closure date for Housing Benefit or " + "income-related ESA. The model also ends a person's legacy awards " + "once its annual migration proxy closes them: it ends their Income " + "Support, income-based JSA and income-related ESA when " + "person_legacy_benefits_closed holds, and the family's Housing " + "Benefit and tax credits when legacy_benefits_closed holds and the " + "family doesn't meet the Pension Credit age conditions. The formula " + "follows both closures. It doesn't follow the closures that read " + "claims_universal_credit (esa_income_report_closed for a claimant or " + "partner, housing_benefit_eligible and is_CTC_eligible), because " + "claims_universal_credit reads this variable. The formula checks each " + "report against its own variable: income_support, jsa_income, " + "esa_income, housing_benefit, child_tax_credit or tax_credits for " + "Child Tax Credit, and working_tax_credit or tax_credits for Working " + "Tax Credit. A reform that changes or zeroes a benefit's formula " + "without neutralising its variable leaves the exclusion on that " + "benefit's reports in place. For a claimant or partner it also reads " + "the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)), " + "each credit by its own check." ) definition_period = YEAR reference = [ @@ -39,27 +53,62 @@ class is_qualifying_young_person_for_universal_credit(Variable): def formula(person, period, parameters): # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports, so it calculates no family benefit that depends on QYP - # status. It reads a dependant's own reports, never a parent's. - dwp = parameters(period).gov.dwp + # reports and the model's legacy closure proxy, so it calculates no + # family benefit that depends on QYP status. + p = parameters(period).gov + dwp = p.dwp + # The reform pays each benefit at its reported amount after the + # model's closure dates, and holds both legacy closures false. + paid_as_reported = p.contrib.policyengine.disable_simulated_benefits system = person.simulation.tax_benefit_system def paying(active, *names): # Nobody is entitled to a benefit the model no longer pays, or one # a reform neutralises. - return bool(active) and not any( + return bool(active or paid_as_reported) and not any( system.get_variable(name).is_neutralized for name in names ) - income_support_paying = paying(dwp.income_support.active, "income_support") - jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") - esa_income_paying = paying(True, "esa_income") - housing_benefit_paying = paying(True, "housing_benefit") - child_tax_credit_paying = paying( - dwp.tax_credits.active, "child_tax_credit", "tax_credits" + # The model ends a person's Income Support, income-based JSA and + # income-related ESA when their legacy awards close: the claimant's + # and partner's together (legacy_benefits_closed, read through + # income_support), and anyone else's on their own reports + # (jsa_income, esa_income). esa_income_report_closed also ends a + # claimant's or partner's income-related ESA when the family claims + # Universal Credit, but the formula can't read it: its + # claimant-or-partner limb reads claims_universal_credit, which reads + # this variable. person_legacy_benefits_closed and + # legacy_benefits_closed read reports, is_claimant_or_partner, the + # Pension Credit age conditions and parameters, and no Universal + # Credit variable. + own_award_open = ~person("person_legacy_benefits_closed", period) + # The model pays Housing Benefit and tax credits to the family, on + # every member's reports, and ends them when the claimant's and + # partner's legacy awards close (housing_benefit_eligible, + # is_CTC_eligible, is_WTC_eligible). A family that meets the Pension + # Credit age conditions keeps Housing Benefit unless it leaves that + # route by claiming Universal Credit, which the formula can't read, + # so for such a family the formula keeps the Housing Benefit report. + family_closed = person.benunit("legacy_benefits_closed", period) + family_award_open = ~family_closed + housing_benefit_open = ~( + family_closed + & ~person.benunit("meets_pension_credit_age_conditions", period) ) - working_tax_credit_paying = paying( - dwp.tax_credits.active, "working_tax_credit", "tax_credits" + + income_support_paying = ( + paying(dwp.income_support.active, "income_support") & own_award_open + ) + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & own_award_open + housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open + child_tax_credit_paying = ( + paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") + & family_award_open + ) + working_tax_credit_paying = ( + paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") + & family_award_open ) tax_credits_reported = child_tax_credit_paying * person( "child_tax_credit_reported", period diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py index 034e6772fe..882f7cf52a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -50,9 +50,8 @@ def formula(person, period, parameters): ) # The Secretary of State excludes a person receiving Universal Credit, # contributory ESA or contribution-based JSA (reg. 5(5)), on both - # routes. The formula reads raw - # person-level reports, so it calculates no family benefit that - # depends on qualifying young person status. + # routes. The formula reads raw person-level reports, so it calculates + # no family benefit that depends on qualifying young person status. receives_reg_5_5_benefit = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) From f379377f91b39251ace94b1a541555fe64065473 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:09:10 +0000 Subject: [PATCH 09/20] Pin the Pension Credit age exception to the Housing Benefit closure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KY1QwmCKh9DpfvfPk --- ...ualifying_young_person_legacy_closure.yaml | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index c7c2809447..442d3d0683 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -12,7 +12,7 @@ # gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and # 6 April 2025). The closure reports exclude nobody, as the benefit has closed. # -# Every case uses one household: a parent aged 45 and a 17-year-old on a +# Every case but the last uses one household: a parent aged 45 and a 17-year-old on a # non-advanced course in the parent's benefit unit, North East, owned # outright, no income. The parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml @@ -290,3 +290,40 @@ is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_068 universal_credit: 8_869.68 + +- name: A young person's own Housing Benefit report still excludes them where the family meets the Pension Credit age conditions. + # A parent aged 70 reports their own Working Tax Credit in 2025, after tax + # credits closed, which closes the family's legacy awards. The family meets + # the Pension Credit age conditions, so the model keeps paying Housing + # Benefit unless the family leaves that route by claiming Universal Credit + # (left_pension_route_at_legacy_closure). The predicate can't read that + # claim, because claims_universal_credit reads the predicate, so it keeps + # the Housing Benefit report. UC: 12 x 400.14 = 4,801.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_parent: true + is_claimant_or_partner: true + working_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + meets_pension_credit_age_conditions: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_801.68 From ad19029f6bb216058927d9813c313702fe75c59b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:45:08 +0000 Subject: [PATCH 10/20] Follow the Universal Credit claim limbs for a claimant or partner in the UC qualifying young person test A claim for Universal Credit ends the claimant's and partner's own awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), not a dependant's. The predicate now reads esa_income_report_closed for income-related ESA, and for a claimant or partner applies housing_benefit_eligible's and is_CTC_eligible's claims_universal_credit terms to Housing Benefit and Child Tax Credit reports. Under disable_simulated_benefits it leaves the claim limbs out, as that reform pays each report claim or none. Moves closure cases 7 and 8 to a renting family that claims no Universal Credit, adds the claim-limb cases, the couple's joint tax credit closure test, the dependant Housing Benefit closure case, two claim invariants and the reform tests, and corrects the predicate's stated scope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 24 +- ...ualifying_young_person_legacy_closure.yaml | 107 ++++-- ...g_young_person_universal_credit_claim.yaml | 339 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 21 +- .../tests/test_disable_simulated_benefits.py | 65 +++- ...ung_person_existing_benefits_properties.py | 136 +++++-- ..._qualifying_young_person_legacy_closure.py | 45 ++- ...fying_young_person_neutralised_benefits.py | 8 +- ...fying_young_person_for_universal_credit.py | 133 ++++--- 10 files changed, 767 insertions(+), 113 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index fd64aee48a..25484768fe 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit while the model pays it on that report (its year gate and the model's legacy closure) and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 1b10314ae3..03665bd930 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,14 +4,21 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report only while the model pays that benefit on it, and only if -# no reform neutralises it. The model pays Income Support and income-based +# It reads a report only in a year the model pays that benefit, only until +# the model's legacy closure, or for a claimant or partner the family's +# Universal Credit claim, ends the award on it, and only if no reform +# neutralises it. The model pays Income Support and income-based # JSA until 31 March 2026 and tax credits until 5 April 2025, and has no # closure date for Housing Benefit or income-related ESA. It also ends a # person's legacy awards when its annual migration proxy closes them, and # the predicate follows that closure # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases -# no report closes the young person's legacy awards. The first six cases, +# a young person's own report closes their legacy awards only where that +# benefit's year gate has already closed, so the closure decides no case. +# The claim limbs end only a claimant's or partner's own award +# (uc_qualifying_young_person_universal_credit_claim.yaml covers them), so +# the cases that test a claimant's own Child Tax Credit or Housing Benefit +# report claim no Universal Credit (would_claim_uc). The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. @@ -178,6 +185,9 @@ universal_credit: 5_098.80 - name: An 18-year-old claimant on a non-advanced course who reports Housing Benefit is not a qualifying young person. + # The claimant doesn't claim Universal Credit, so no claim ends their own + # Housing Benefit (uc_qualifying_young_person_universal_credit_claim.yaml + # covers a claim). They get no Universal Credit. period: 2026 input: people: @@ -189,6 +199,7 @@ benunits: benunit: members: [claimant] + would_claim_uc: false households: household: members: [claimant] @@ -198,7 +209,7 @@ is_qualifying_young_person_for_universal_credit: [false] is_child_or_qualifying_young_person_for_universal_credit: [false] uc_child_element: 0 - universal_credit: 4_062.96 + universal_credit: 0 - name: A young person's own Income Support report after Income Support stops paying leaves the child element. # Income Support stops paying on 1 April 2026 (gov.dwp.income_support.active), so nobody is entitled to it in 2026. @@ -312,12 +323,15 @@ universal_credit: 8_869.68 - name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. + # Each person forms their own benefit unit as its claimant, and none + # claims Universal Credit, so no claim ends their Child Tax Credit. period: 2024 input: age: [17, 17, 17] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] child_tax_credit_reported: [0.01, 0, 0] working_tax_credit_reported: [0, 0.01, 0] + would_claim_uc: [false, false, false] output: is_qualifying_young_person_for_universal_credit: [false, false, true] @@ -366,6 +380,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] - name: A couple's Child Tax Credit award does not exclude a dependant who reports none. + # The couple doesn't claim Universal Credit, so no claim ends its award. period: 2024 input: people: @@ -385,6 +400,7 @@ benunits: benunit: members: [claimant, partner, young_person] + would_claim_uc: false output: is_qualifying_young_person_for_universal_credit: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 442d3d0683..2762b03988 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -10,11 +10,14 @@ # tax credit report from 2025, closes the dependant's legacy awards # (gov.dwp.income_support.active, gov.dwp.JSA.income.active and # gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and -# 6 April 2025). The closure reports exclude nobody, as the benefit has closed. +# 6 April 2025). The predicate excludes nobody on those reports, as each +# benefit's year gate has closed. # -# Every case but the last uses one household: a parent aged 45 and a 17-year-old on a -# non-advanced course in the parent's benefit unit, North East, owned -# outright, no income. The parent claims Universal Credit as a single +# Cases 1 to 6 and the last use one household: a parent aged 45 and a +# 17-year-old on a non-advanced course in the parent's benefit unit, North +# East, owned outright, no income. Cases 7 and 8 use the same family renting +# from the council at £6,000 a year and claiming no Universal Credit, so only +# the closure can end its Housing Benefit. Case 9 has a parent aged 70. The parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person @@ -159,9 +162,11 @@ universal_credit: 8_869.68 - name: A young person's own Income Support report with their own Child Tax Credit report in 2025 leaves the child element. - # Income Support still pays in 2025, but the young person's own Child Tax - # Credit report closes their legacy awards, which ends their Income Support - # too. UC: 12 x (400.14 + 339.00) = 8,869.68. + # The model pays Income Support in 2025, though never on a dependant's own + # report. The young person's own Child Tax Credit report closes their + # legacy awards (person_legacy_benefits_closed), and the predicate then + # drops their Income Support report too. UC: 12 x (400.14 + 339.00) = + # 8,869.68. period: 2025 absolute_error_margin: 0.01 input: @@ -224,11 +229,15 @@ universal_credit: 8_869.68 - name: A young person's own Housing Benefit report after the parent's legacy awards close leaves the child element. - # The parent's own Income Support report from 2026 closes the family's - # legacy awards (legacy_benefits_closed), and the family doesn't meet the - # Pension Credit age conditions, so the model pays no Housing Benefit on - # the young person's report (housing_benefit_eligible). UC: 12 x (424.90 + - # 351.88) = 9,321.36. + # The family rents from the council at £6,000 a year and claims no + # Universal Credit, so without the parent's report the model continues its + # Housing Benefit award at the full rent (6,000, no income). The parent's + # own Income Support report from 2026 closes the family's legacy awards + # (legacy_benefits_closed), and the family doesn't meet the Pension Credit + # age conditions, so the model ends that award (housing_benefit_eligible), + # the young person's report included, and the predicate drops the report. + # The young person meets reg. 5, so the family's UC child element comes to + # 12 x 351.88 = 4,222.56, though the family claims no Universal Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -246,22 +255,29 @@ benunits: benunit: members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] region: NORTH_EAST - tenure_type: OWNED_OUTRIGHT + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 output: legacy_benefits_closed: true + claims_universal_credit: false housing_benefit_eligible: false + housing_benefit: 0 is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_222.56 - universal_credit: 9_321.36 + universal_credit: 0 - name: A young person's own Housing Benefit report after the parent's tax credits close leaves the child element. - # The parent's own Child Tax Credit report from 2025 closes the family's - # legacy awards, which ends the family's Housing Benefit. UC: 12 x (400.14 - # + 339.00) = 8,869.68. + # The same renting family in 2025, which claims no Universal Credit. The + # parent's own Child Tax Credit report from 2025, after tax credits closed, + # closes the family's legacy awards, so the model ends its Housing Benefit + # award (6,000 without the report) and the predicate drops the young + # person's report. UC child element: 12 x 339.00 = 4,068. period: 2025 absolute_error_margin: 0.01 input: @@ -279,26 +295,34 @@ benunits: benunit: members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] region: NORTH_EAST - tenure_type: OWNED_OUTRIGHT + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 output: legacy_benefits_closed: true + claims_universal_credit: false housing_benefit_eligible: false + housing_benefit: 0 is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_068 - universal_credit: 8_869.68 + universal_credit: 0 - name: A young person's own Housing Benefit report still excludes them where the family meets the Pension Credit age conditions. # A parent aged 70 reports their own Working Tax Credit in 2025, after tax # credits closed, which closes the family's legacy awards. The family meets - # the Pension Credit age conditions, so the model keeps paying Housing - # Benefit unless the family leaves that route by claiming Universal Credit - # (left_pension_route_at_legacy_closure). The predicate can't read that - # claim, because claims_universal_credit reads the predicate, so it keeps - # the Housing Benefit report. UC: 12 x 400.14 = 4,801.68. + # the Pension Credit age conditions, so the model keeps its Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to it + # (left_pension_route_at_legacy_closure). This family does, so the model + # pays it no Housing Benefit. The predicate can't read + # left_pension_route_at_legacy_closure, whose is_uc_entitled reads the + # predicate through the child element, so it keeps the Housing Benefit + # report. The young person is a dependant, so the family's claim doesn't + # end their own award either. UC: 12 x 400.14 = 4,801.68. period: 2025 absolute_error_margin: 0.01 input: @@ -327,3 +351,38 @@ is_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 universal_credit: 4_801.68 + +- name: A young person's own Income Support report from 2026 doesn't lift their Housing Benefit exclusion. + # The young person's own Income Support report from 2026, after Income + # Support closed, closes their own legacy awards (person_legacy_benefits_closed), + # but not the family's: legacy_benefits_closed reads only the claimant's + # and partner's reports. The model ends Housing Benefit on the family's + # closure, so the young person's Housing Benefit report still excludes + # them. UC: 12 x 424.90 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + legacy_benefits_closed: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml new file mode 100644 index 0000000000..54057dbb77 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -0,0 +1,339 @@ +# A claim for Universal Credit ends the claimant's own tax credits, Income +# Support and Housing Benefit, and either joint claimant's tax credits (SI +# 2014/1230 reg. 8(2) and (2A)), and the claimant's and partner's +# income-related ESA (SI 2013/983 art. 4). It ends nobody else's award. The +# model ends the family's continuing Housing Benefit award +# (housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible), and +# the claimant's and partner's income-related ESA (esa_income_report_closed), +# when the family claims (claims_universal_credit). Nobody is entitled to an +# award the claim has ended, so for a claimant or partner the UC qualifying +# young person test (SI 2014/1230 reg. 28) follows those three limbs. For a +# dependant it doesn't: a parent's claim leaves the dependant's own award. +# +# would_claim_uc decides the claim here: no case closes the family's legacy +# awards, so claims_uc_at_legacy_closure stays false. The reg. 5 variable +# reads universal_credit_reported and receives_benefits_in_own_right for reg. +# 5(5), not a calculated Universal Credit award, and these cases leave both +# at their defaults, so a claimant who claims Universal Credit still meets +# reg. 5. +# +# Rates (universal_credit/standard_allowance/amount.yaml, elements/child/): +# SINGLE_YOUNG £311.68 a month in 2024-25 and £338.58 in 2026-27; +# COUPLE_OLD £617.60 in 2024-25; the child amount for a child born after +# 5 April 2017 £287.92 in 2024-25 (elements/child/amount.yaml). +# Child Tax Credit child element £3,455 a year in 2024-25 +# (tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: An 18-year-old claimant's own income-related ESA report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their own income-related + # ESA (SI 2013/983 art. 4; esa_income_report_closed), so the model pays no + # income-related ESA on their report and they aren't entitled to it. UC: 12 + # x 338.58 = 4,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [true] + esa_income: 0 + is_qualifying_young_person_under_universal_credit_regulation_5: [true] + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 4_062.96 + +- name: An 18-year-old claimant's own income-related ESA report excludes them while they don't claim Universal Credit. + # Nothing ends the award: the claimant doesn't claim Universal Credit and + # reports no closed benefit, so the model pays the £3,000 of income-related + # ESA on their report and they're entitled to it. They get no Universal + # Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [false] + esa_income: 3_000 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old claimant's own Housing Benefit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant rents from the council at £6,000 a year and claims Universal + # Credit, which ends their own Housing Benefit (SI 2014/1230 reg. 8(2A); + # housing_benefit_eligible), so the model pays no Housing Benefit on their + # report. UC: 12 x 338.58 + 6,000 housing element = 10,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: false + housing_benefit: 0 + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 10_062.96 + +- name: An 18-year-old claimant's own Housing Benefit report excludes them while they don't claim Universal Credit. + # The claimant rents from the council at £6,000 a year, has no income and + # doesn't claim Universal Credit, so the model continues their Housing + # Benefit award at the full rent (6,000) and they're entitled to it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old lone parent's own Child Tax Credit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their tax credits (SI + # 2014/1230 reg. 8(2); is_CTC_eligible), so the model pays no Child Tax + # Credit. UC: 12 x (311.68 + 287.92) = 7,195.20. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false] + universal_credit: 7_195.20 + +- name: An 18-year-old lone parent's own Child Tax Credit report excludes them while they don't claim Universal Credit. + # Tax credits pay in 2024 and the claimant doesn't claim Universal Credit, + # so the model pays Child Tax Credit for the child born after 5 April 2017: + # the child element of 3,455, with no family element and no income to taper + # it. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: false + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit stays a qualifying young person once the couple claims Universal Credit. + # The members of a couple claim tax credits jointly (Tax Credits Act 2002 + # s.3(3)), so the partner's report gives the claimant the couple's award. + # The couple's claim for Universal Credit ends a joint claimant's tax + # credits (SI 2014/1230 reg. 8(2); is_CTC_eligible), so nobody is entitled + # to the award. UC: 12 x (617.60 + 287.92) = 10,866.24. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: true + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false, false] + universal_credit: 10_866.24 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit isn't a qualifying young person while the couple doesn't claim Universal Credit. + # Tax credits pay in 2024 and the couple doesn't claim Universal Credit, so + # the model pays the couple's Child Tax Credit (child element 3,455) and the + # claimant shares the joint award. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: false + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false, false] + universal_credit: 0 + +- name: A parent's claim for Universal Credit leaves their young person's own Housing Benefit report excluding them. + # A parent aged 45 and a 17-year-old on a non-advanced course, owned + # outright. The parent's claim ends the parent's own awards, not the young + # person's (SI 2014/1230 reg. 8(2A) names the claimant's award), so the + # young person's own report still excludes them. UC: 12 x 424.90 = 5,098.80 + # (SINGLE_OLD, 2026-27). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report excludes them while their parent doesn't claim Universal Credit. + # The same household without a claim: the young person's own report excludes + # them as with the claim, and the parent gets no Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 38d7067893..78cb68fd0c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,11 +3,14 @@ # and JSA without their income-related and income-based forms (UC Regs reg. # 2). DWP doesn't count a person entitled to income-related ESA, income-based # JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. -# 28). The UC predicate reads a report only while the model pays that -# benefit on it (income-based JSA and Income Support until 31 March 2026, and -# income-related ESA, income-based JSA and Income Support only until the -# person's legacy awards close, person_legacy_benefits_closed), and only if -# no reform neutralises it. The Secretary of State excludes own awards +# 28). The UC predicate reads a report only in a year the model pays that +# benefit (income-based JSA and Income Support until 31 March 2026), reads an +# income-related ESA, income-based JSA or Income Support report only until +# the person's legacy awards close (person_legacy_benefits_closed), and +# reads none that a reform neutralises. A claim for Universal Credit also +# ends a claimant's or partner's own income-related ESA +# (esa_income_report_closed), so the cases that test a claimant's own +# income-related ESA report claim no Universal Credit (would_claim_uc). The Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the @@ -368,8 +371,11 @@ - name: A reported award also excludes the education route. # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [18, 18, 18, 18, 18] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] universal_credit_reported: [5_000, 0, 0, 0, 0] @@ -382,8 +388,10 @@ is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] - name: An explicit false own-right flag does not erase a reported own ESA award. + # The person claims no Universal Credit, so no claim ends their award. period: 2026 input: + would_claim_uc: false age: 16 current_education: NOT_IN_EDUCATION receives_benefits_in_own_right: false @@ -583,8 +591,11 @@ is_qualifying_young_person_for_pension_credit: [false, true] - name: A report of one penny of a listed award other than Income Support excludes the young person. + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [16, 16, 16, 16, 16] current_education: [NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION] universal_credit_reported: [0.01, 0, 0, 0, 0] diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index a53be29791..3cc555c204 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -855,8 +855,9 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers ): # The reform pays each benefit at its dataset-year report after the # model's closure dates (income-based JSA from 2026, tax credits from - # 2025), so the young person stays entitled to it and reg. 28 excludes - # them from the UC qualifying young person test in every year. Income + # 2025), so the young person stays entitled to it, and the Secretary of + # State excludes them from the UC qualifying young person test (reg. 28), + # in each of the reform's years. Income # Support is the claimant's family's award, so the model pays none on a # dependant's own report, with the reform or without it; the predicate # reads that report in the years the model pays Income Support to anyone. @@ -882,3 +883,63 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers tax_credits_closed, tax_credits_closed, ] + + +# An 18-year-old claimant on a non-advanced course, a lone parent of a child +# aged 1, renting from the council at £6,000 a year, who reports £3,000 of +# their own award in the dataset year. The family claims Universal Credit +# (would_claim_uc defaults to true). +CLAIMANT_REPORTS = { + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", +} + + +def claimant_situation(): + people, benunits, households = {}, {}, {} + for report in CLAIMANT_REPORTS: + claimant, child = f"{report}_claimant", f"{report}_child" + people[claimant] = { + "age": every_year(18), + "is_claimant_or_partner": every_year(True), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + people[child] = { + "age": every_year(1), + "is_claimant_or_partner": every_year(False), + } + benunits[report] = {"members": [claimant, child]} + households[report] = { + "members": [claimant, child], + "region": every_year("NORTH_EAST"), + "tenure_type": every_year("RENT_FROM_COUNCIL"), + "rent": every_year(6_000), + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("index, report", list(enumerate(CLAIMANT_REPORTS))) +def test_a_claimant_on_a_benefit_the_reform_pays_beside_universal_credit_is_no_qualifying_young_person( + year, index, report +): + # A claim for Universal Credit ends the claimant's own income-related + # ESA, Housing Benefit and Child Tax Credit (SI 2014/1230 reg. 8(2) and + # (2A); SI 2013/983 art. 4), and without the reform the model ends each + # award on the claim, so the predicate doesn't read the report. The + # reform pays each report as reported, claim or none, so the claimant + # stays entitled to it and the predicate excludes them. + reformed = Simulation(situation=claimant_situation(), scenario=REFORM) + unreformed = Simulation(situation=claimant_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + benefit = CLAIMANT_REPORTS[report] + person = 2 * index + paid = reformed.calculate(benefit, year) + paid = paid[person] if benefit == "esa_income" else paid[index] + assert reformed.calculate("claims_universal_credit", year)[index] + assert float(paid) == 3_000 + assert not reformed.calculate(predicate, year)[person] + assert unreformed.calculate("claims_universal_credit", year)[index] + assert unreformed.calculate(predicate, year)[person] diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 534a714213..1561c2270d 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,29 +5,45 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report only while the model pays that benefit on it, -and only if no reform neutralises it; these tests run without a reform, and +s.3(3)). It reads a report only in a year the model pays that benefit and +until the model's legacy closure, or for a claimant or partner the family's +Universal Credit claim, ends the award on it, and only if no reform +neutralises it; these tests run without a reform, and test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. The model stops paying a benefit when its year gate closes, and ends a person's legacy awards when its annual migration proxy closes them: their own Income Support, income-based JSA and income-related ESA when -person_legacy_benefits_closed holds, and the family's Housing Benefit and -tax credits when legacy_benefits_closed holds and the family doesn't meet -the Pension Credit age conditions. The oracle reads those three closure -variables from the model. These tests check that: - -- any positive own report of an existing benefit the model pays on it that - year makes the predicate false; +person_legacy_benefits_closed holds, the family's tax credits when +legacy_benefits_closed holds, and its Housing Benefit when +legacy_benefits_closed holds and the family doesn't meet the Pension Credit +age conditions. A claim for Universal Credit ends the claimant's and +partner's own awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), +and the model ends the family's continuing Housing Benefit award and its +Child Tax Credit, and the claimant's and partner's income-related ESA, when +the family claims (claims_universal_credit). The oracle reads those closure +variables from the model, and each family draws would_claim_uc. These tests +check that: + +- any positive own report of an existing benefit whose year gate, legacy + closure and, for a claimant or partner, claim limb leave it open makes the + predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 reg. 5 test less the person's reported UC, ESA and JSA awards; -- a claimant's or partner's reports change a dependant's predicate only by - closing the family's legacy awards; +- compared with the same family without the claimant's and partner's + reports, those reports change a dependant's predicate only where the + proxy closes the family's legacy awards on them, and then only to lift an + exclusion; - a person whose legacy awards have closed is never excluded through their own income-related ESA report, nor through a Housing Benefit report once the family's awards close outside the Pension Credit age conditions; -- a parent's Universal Credit never rises when their young person's own - report turns positive; -- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. +- a parent's Universal Credit never rises when one existing benefit report + of their young person turns positive and the young person reports no + other; +- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly; +- turning would_claim_uc on never changes the predicate of anyone who isn't + the claimant or partner; +- turning would_claim_uc on never turns a claimant's or partner's predicate + from true to false. """ from unittest.mock import patch @@ -169,7 +185,12 @@ def existing_benefit_reports(draw, one_existing_benefit): def families(draw): """A claimant, then a partner or a dependant, then two dependants.""" couple = draw(st.booleans()) - claimant = dict(draw(adult), is_claimant_or_partner=True, is_parent=True) + claimant = dict( + draw(adult), + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(adult if couple else young_member) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -191,7 +212,12 @@ def qualifying_families(draw): """ couple = draw(st.booleans()) claimant = draw(st.one_of(qualifying, qualifying, adult)) - claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(st.one_of(qualifying, qualifying, adult if couple else young_member)) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -223,7 +249,12 @@ def closure_families(draw): two dependants who always do, each reporting any existing benefit.""" couple = draw(st.booleans()) claimant = draw(st.one_of(reporting_qualifying, adult)) - claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(st.one_of(reporting_qualifying, adult if couple else young_member)) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -249,7 +280,9 @@ def _release_simulations(): def simulate(units, year): - """Set the inputs on one cached simulation per shape and year.""" + """Set the inputs on one cached simulation per shape and year. Each + family claims Universal Credit (would_claim_uc) as its first member's + draw says, or by default.""" shape = (tuple(len(members) for members in units), year) if shape not in _CACHE: situation = {"people": {}, "benunits": {}, "households": {}} @@ -270,7 +303,12 @@ def simulate(units, year): people = flatten(units) for variable in INPUTS: sim.set_input(variable, year, [p[variable] for p in people]) - sim.input_variables = list(INPUTS) + sim.set_input( + "would_claim_uc", + year, + [members[0].get("would_claim_uc", True) for members in units], + ) + sim.input_variables = list(INPUTS) + ["would_claim_uc"] sim.reset_calculations() return sim @@ -279,6 +317,13 @@ def flatten(units): return [p for members in units for p in members] +def claiming(units, claims): + """The same families, each with would_claim_uc set to claims.""" + return [ + [dict(members[0], would_claim_uc=claims)] + members[1:] for members in units + ] + + def with_zero(units, names, who): return [ [dict(p, **{n: 0.0 for n in names}) if who(p) else p for p in members] @@ -315,20 +360,28 @@ def get(name): ) -def open_awards(sim, year): - """For each report, whether the model's legacy closure leaves the award on - it open, person by person.""" +def open_awards(sim, year, claim_limbs=True): + """For each report, whether the model's legacy closure and, with + claim_limbs, the family's Universal Credit claim leave the award on it + open, person by person. The claim ends the claimant's and partner's own + income-related ESA, Housing Benefit outside the Pension Credit age + conditions and Child Tax Credit, and nobody else's.""" own_open = ~sim.calculate("person_legacy_benefits_closed", year) family_closed = sim.calculate("legacy_benefits_closed", year, map_to="person") pension_age = sim.calculate( "meets_pension_credit_age_conditions", year, map_to="person" ) + claim_ends = np.zeros_like(own_open) + if claim_limbs: + claim_ends = sim.calculate( + "claims_universal_credit", year, map_to="person" + ) & sim.calculate("is_claimant_or_partner", year) return { "income_support_reported": own_open, "jsa_income_reported": own_open, - "esa_income_reported": own_open, - "housing_benefit_reported": ~(family_closed & ~pension_age), - "child_tax_credit_reported": ~family_closed, + "esa_income_reported": own_open & ~claim_ends, + "housing_benefit_reported": pension_age | (~family_closed & ~claim_ends), + "child_tax_credit_reported": ~family_closed & ~claim_ends, "working_tax_credit_reported": ~family_closed, } @@ -421,12 +474,15 @@ def test_a_closed_legacy_award_never_excludes(units, year): # esa_income_report_closed), and the family's Housing Benefit once the # claimant's and partner's close outside the Pension Credit age # conditions (housing_benefit_eligible). Clearing those reports leaves - # every predicate as it was. Neither report closes a legacy award, so - # clearing it leaves the closures as they were. Tax credits close from - # 2025 and Income Support and income-based JSA from 2026. + # every predicate as it was. Neither report enters legacy_award_is_closed. + # They reach the closures only through a mixed-age couple's Pension + # Credit saving, which clearing both reports of a closed family can't + # create. Tax credits close from 2025 and Income Support and income-based + # JSA from 2026. The claim limbs stay out: the claim can end an award in + # a family whose legacy awards stay open. sim = simulate(units, year) reported = predicate(sim, year) - award_open = open_awards(sim, year) + award_open = open_awards(sim, year, claim_limbs=False) closed = { name: ~award_open[name] for name in ("esa_income_reported", "housing_benefit_reported") @@ -490,3 +546,25 @@ def test_the_predicate_is_reg_5_less_reg_28(units, year): sim = simulate(units, year) expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year, sim) assert predicate(sim, year).tolist() == expected.tolist() + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_changes_no_dependants_predicate(units, year): + # A claim for Universal Credit ends the claimant's and partner's own + # awards, not a dependant's (SI 2014/1230 reg. 8(2) and (2A)). + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + assert with_claim[dependant].tolist() == without_claim[dependant].tolist() + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_never_excludes_a_claimant_or_partner(units, year): + # A claim only ends awards, so it can only lift a claimant's or partner's + # exclusion. + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + member_of_couple = np.array([p["is_claimant_or_partner"] for p in flatten(units)]) + assert not np.any(without_claim[member_of_couple] & ~with_claim[member_of_couple]) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py index 2806880775..5a8c425f47 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py @@ -10,8 +10,9 @@ In the baseline the family's legacy awards close only once a benefit has closed, and tax credits close first (gov.dwp.tax_credits.active turns false on 6 April 2025, Income Support on 1 April 2026), so the year gate already -covers every closed tax credit report. These tests keep tax credits paying -in 2026 by a parameter reform, so the family's closure decides. +covers every closed tax credit report. These tests keep the tax credit +scheme open in 2026 by a parameter reform (gov.dwp.tax_credits.active), so +the family's closure decides. The household: a parent aged 45 and a 17-year-old on a non-advanced course, North East, owned outright, no income, in 2026. The parent claims Universal @@ -97,3 +98,43 @@ def test_the_parents_closed_legacy_award_ends_the_young_persons_tax_credits( [False, True], 9_321.36, ) + + +def test_the_familys_closure_ends_the_couples_joint_tax_credit_award(): + # An 18-year-old claimant on a non-advanced course and a partner aged 45 + # with a child aged 1. The partner reports the couple's Working Tax Credit + # (Tax Credits Act 2002 s.3(3)) and their own Income Support in 2026, + # after Income Support closed, which closes the family's legacy awards + # and ends the couple's tax credits. So the couple's award doesn't + # exclude the claimant. Universal Credit can't show this: the claimant's + # status doesn't enter the award, so the test reads the predicate. + members = ["claimant", "partner", "child"] + situation = { + "people": { + "claimant": { + "age": {YEAR: 18}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: True}, + }, + "partner": { + "age": {YEAR: 45}, + "is_claimant_or_partner": {YEAR: True}, + "income_support_reported": {YEAR: 3_000}, + "working_tax_credit_reported": {YEAR: 1_000}, + }, + "child": {"age": {YEAR: 1}, "is_claimant_or_partner": {YEAR: False}}, + }, + "benunits": {"benunit": {"members": members}}, + "households": { + "household": { + "members": members, + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + sim = Simulation(situation=situation, reform=TAX_CREDITS_PAYING) + assert sim.calculate("legacy_benefits_closed", YEAR).tolist() == [True] + assert sim.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [True, False, False] diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py index c1d64e1807..041d6d3af3 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py @@ -101,7 +101,8 @@ def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( ): """Two 18-year-olds on non-advanced courses form a couple, and the partner reports the couple's Child Tax Credit or Working Tax Credit - award (Tax Credits Act 2002 s.3(3)(a)).""" + award (Tax Credits Act 2002 s.3(3)(a)). The couple doesn't claim + Universal Credit, so no claim ends its Child Tax Credit.""" year = 2024 people, benunits, households = {}, {}, {} credits = ("child_tax_credit_reported", "working_tax_credit_reported") @@ -114,7 +115,10 @@ def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( "is_claimant_or_partner": {year: True}, } people[partner][report] = {year: 1_000} - benunits[f"benunit_{index}"] = {"members": [claimant, partner]} + benunits[f"benunit_{index}"] = { + "members": [claimant, partner], + "would_claim_uc": {year: False}, + } households[f"household_{index}"] = {"members": [claimant, partner]} sim = Simulation( situation={"people": people, "benunits": benunits, "households": households}, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index e0426e32ea..e2a319ccfa 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -14,31 +14,53 @@ class is_qualifying_young_person_for_universal_credit(Variable): "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " "29). The Secretary of State ties the exclusion to entitlement (reg. " - "28), so the formula reads a report only while the model pays that " - "benefit on it, and only if no reform neutralises it. The model pays " - "Income Support, income-based JSA and tax credits only while " + "28), so the formula reads a report only in a year the model pays that " + "benefit, only until the model's legacy closure, or for a claimant or " + "partner the family's Universal Credit claim, ends the award on it, and" + " only if no reform neutralises it. It doesn't apply the model's other " + "conditions of payment, so a report can exclude a person even where the" + " model pays nothing on that report. For example, the model pays Income" + " Support only on the claimant's and partner's reports, Housing Benefit" + " only to a family that rents, and income-based JSA and income-related " + "ESA only after their capital and work screens. The model pays Income " + "Support, income-based JSA and tax credits only while " "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " - "gov.dwp.tax_credits.active hold, or while " - "gov.contrib.policyengine.disable_simulated_benefits pays them as " - "reported, and has no closure date for Housing Benefit or " - "income-related ESA. The model also ends a person's legacy awards " - "once its annual migration proxy closes them: it ends their Income " - "Support, income-based JSA and income-related ESA when " - "person_legacy_benefits_closed holds, and the family's Housing " - "Benefit and tax credits when legacy_benefits_closed holds and the " - "family doesn't meet the Pension Credit age conditions. The formula " - "follows both closures. It doesn't follow the closures that read " - "claims_universal_credit (esa_income_report_closed for a claimant or " - "partner, housing_benefit_eligible and is_CTC_eligible), because " - "claims_universal_credit reads this variable. The formula checks each " - "report against its own variable: income_support, jsa_income, " - "esa_income, housing_benefit, child_tax_credit or tax_credits for " - "Child Tax Credit, and working_tax_credit or tax_credits for Working " - "Tax Credit. A reform that changes or zeroes a benefit's formula " - "without neutralising its variable leaves the exclusion on that " - "benefit's reports in place. For a claimant or partner it also reads " - "the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)), " - "each credit by its own check." + "gov.dwp.tax_credits.active hold, and has no closure date for Housing " + "Benefit or income-related ESA. The formula also reads those three " + "benefits' reports while " + "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " + "that switch turns on pays them at their reported amounts in the " + "dataset year and the nine years after it, and the formula still reads " + "them in later years. That reform pays each report whether or not the " + "family claims Universal Credit, so while the switch is on the formula " + "leaves out the Universal Credit claim limbs below. The model also ends" + " a person's legacy awards once its annual migration proxy closes them:" + " it ends their Income Support, income-based JSA and income-related ESA" + " when person_legacy_benefits_closed holds, the family's tax credits " + "when legacy_benefits_closed holds, and its Housing Benefit when " + "legacy_benefits_closed holds and the family doesn't meet the Pension " + "Credit age conditions. The formula follows both closures. A claim for " + "Universal Credit ends the claimant's and partner's own awards (SI " + "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and the model ends " + "the family's continuing Housing Benefit award " + "(housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible)," + " and the claimant's and partner's income-related ESA " + "(esa_income_report_closed), when the family claims " + "(claims_universal_credit). For a claimant or partner, the formula " + "follows those limbs too. The family's claim doesn't end a dependant's " + "own award, so for a dependant the formula doesn't. It can't read " + "housing_benefit_eligible: that variable's Pension Credit route reads " + "left_pension_route_at_legacy_closure, which reads this variable " + "through is_uc_entitled and the child element. So the formula keeps a " + "Housing Benefit report while the family meets the Pension Credit age " + "conditions. The formula checks each report against its own variable: " + "income_support, jsa_income, esa_income, housing_benefit, " + "child_tax_credit or tax_credits for Child Tax Credit, and " + "working_tax_credit or tax_credits for Working Tax Credit. A reform " + "that changes or zeroes a benefit's formula without neutralising its " + "variable leaves the exclusion on that benefit's reports in place. For " + "a claimant or partner it also reads the couple's joint tax credit " + "award (Tax Credits Act 2002 s.3(3)), each credit by its own check." ) definition_period = YEAR reference = [ @@ -53,13 +75,17 @@ class is_qualifying_young_person_for_universal_credit(Variable): def formula(person, period, parameters): # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports and the model's legacy closure proxy, so it calculates no - # family benefit that depends on QYP status. + # reports, the model's legacy closure proxy and the family's Universal + # Credit claim, so it calculates no family benefit that depends on QYP + # status. p = parameters(period).gov dwp = p.dwp - # The reform pays each benefit at its reported amount after the - # model's closure dates, and holds both legacy closures false. - paid_as_reported = p.contrib.policyengine.disable_simulated_benefits + # The reform pays each listed benefit at its dataset-year report in + # that year and the nine after it, closure dates included, and holds + # both legacy closures false in those years. The formula reads the + # switch, not those years, so in a later year it still reads a tax + # credit report that the model no longer pays. + paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) system = person.simulation.tax_benefit_system def paying(active, *names): @@ -73,38 +99,58 @@ def paying(active, *names): # income-related ESA when their legacy awards close: the claimant's # and partner's together (legacy_benefits_closed, read through # income_support), and anyone else's on their own reports - # (jsa_income, esa_income). esa_income_report_closed also ends a - # claimant's or partner's income-related ESA when the family claims - # Universal Credit, but the formula can't read it: its - # claimant-or-partner limb reads claims_universal_credit, which reads - # this variable. person_legacy_benefits_closed and + # (jsa_income, esa_income). person_legacy_benefits_closed and # legacy_benefits_closed read reports, is_claimant_or_partner, the # Pension Credit age conditions and parameters, and no Universal # Credit variable. own_award_open = ~person("person_legacy_benefits_closed", period) + # A claim for Universal Credit ends the claimant's and partner's own + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and + # nobody else's. The model ends the claimant's and partner's + # income-related ESA when the family claims (esa_income_report_closed), + # and the formula follows it. claims_universal_credit reads + # would_claim_uc, would_claim_uc_at_legacy_closure and + # legacy_benefits_closed, not this variable. The reform behind + # disable_simulated_benefits pays each report as reported whether or + # not the family claims, and holds the legacy closures false in its + # years, so under it the formula keeps the person's own closure alone. + claimant_or_partner = person("is_claimant_or_partner", period) + esa_income_open = ~person("esa_income_report_closed", period) | ( + own_award_open & paid_as_reported + ) + claim_ends_own_award = ( + claimant_or_partner + & person.benunit("claims_universal_credit", period) + & (not paid_as_reported) + ) # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, - # is_CTC_eligible, is_WTC_eligible). A family that meets the Pension - # Credit age conditions keeps Housing Benefit unless it leaves that - # route by claiming Universal Credit, which the formula can't read, - # so for such a family the formula keeps the Housing Benefit report. + # is_CTC_eligible, is_WTC_eligible). It also ends the family's + # continuing Housing Benefit award and its Child Tax Credit when the + # family claims Universal Credit, and the formula follows that limb + # for a claimant or partner, whose own award the claim ends. A family + # that meets the Pension Credit age conditions keeps Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to + # it (left_pension_route_at_legacy_closure). That variable reads this + # one through is_uc_entitled and the child element, so the formula + # keeps the Housing Benefit report for such a family. family_closed = person.benunit("legacy_benefits_closed", period) family_award_open = ~family_closed - housing_benefit_open = ~( - family_closed - & ~person.benunit("meets_pension_credit_age_conditions", period) - ) + housing_benefit_open = person.benunit( + "meets_pension_credit_age_conditions", period + ) | (~family_closed & ~claim_ends_own_award) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open ) jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open - esa_income_paying = paying(True, "esa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & esa_income_open housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open child_tax_credit_paying = ( paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") & family_award_open + & ~claim_ends_own_award ) working_tax_credit_paying = ( paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") @@ -116,7 +162,6 @@ def paying(active, *names): # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, # so the couple's award belongs to the claimant and the partner. A # dependant claims as a single person, so their award stays theirs. - claimant_or_partner = person("is_claimant_or_partner", period) couple_tax_credits_reported = person.benunit.sum( where(claimant_or_partner, tax_credits_reported, 0) ) From cdd497084d0ea9891bce62f7d7fadc464a3a2315 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:48:36 +0000 Subject: [PATCH 11/20] Keep the no-claim cases free of a claim at the legacy closure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- ...qualifying_young_person_universal_credit_claim.yaml | 10 ++++++++-- .../qualifying_young_person_reported_own_awards.yaml | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 54057dbb77..e31d332463 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -10,8 +10,9 @@ # young person test (SI 2014/1230 reg. 28) follows those three limbs. For a # dependant it doesn't: a parent's claim leaves the dependant's own award. # -# would_claim_uc decides the claim here: no case closes the family's legacy -# awards, so claims_uc_at_legacy_closure stays false. The reg. 5 variable +# would_claim_uc decides the claim here. A case without a claim also sets +# would_claim_uc_at_legacy_closure false, so the family claims nothing when +# DWP moves it off its legacy benefits either. The reg. 5 variable # reads universal_credit_reported and receives_benefits_in_own_right for reg. # 5(5), not a calculated Universal Credit award, and these cases leave both # at their defaults, so a claimant who claims Universal Credit still meets @@ -72,6 +73,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -130,6 +132,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -194,6 +197,7 @@ benunit: members: [claimant, child] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant, child] @@ -264,6 +268,7 @@ benunit: members: [claimant, partner, child] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant, partner, child] @@ -328,6 +333,7 @@ benunit: members: [parent, young_person] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 78cb68fd0c..39f05dbc38 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -388,10 +388,12 @@ is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] - name: An explicit false own-right flag does not erase a reported own ESA award. - # The person claims no Universal Credit, so no claim ends their award. + # The person claims no Universal Credit, now or when DWP moves them off + # their legacy benefits, so no claim ends their award. period: 2026 input: would_claim_uc: false + would_claim_uc_at_legacy_closure: false age: 16 current_education: NOT_IN_EDUCATION receives_benefits_in_own_right: false From 7b9e556954bf6762707e6cb5def410d6ba2138e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:29:06 +0000 Subject: [PATCH 12/20] Pin the Pension Credit route's Housing Benefit for a claimant or partner who claims Universal Credit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- ...g_young_person_universal_credit_claim.yaml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index e31d332463..546774f357 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -280,6 +280,49 @@ is_qualifying_young_person_for_universal_credit: [false, false, false] universal_credit: 0 +- name: An 18-year-old partner's own Housing Benefit report excludes them while their mixed-age couple keeps the Pension Credit route. + # A claimant aged 75 who reports Pension Credit and an 18-year-old partner + # on a non-advanced course rent from the council at £6,000 a year and claim + # Universal Credit. The couple keeps the mixed-age Pension Credit saving + # (has_mixed_age_couple_pension_credit_saving), so it meets the Pension + # Credit age conditions. The model keeps such a family's Housing Benefit + # while it claims, unless it claims at a legacy closure and is entitled to + # Universal Credit (left_pension_route_at_legacy_closure), and nothing + # closes this family's legacy awards. So the model pays the full rent + # (6,000, no income) as Housing Benefit, the partner stays entitled to it, + # and their own report excludes them. The predicate can't read + # left_pension_route_at_legacy_closure, so it keeps any Housing Benefit + # report while the family meets those conditions. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_claimant_or_partner: true + pension_credit_reported: 1_000 + partner: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: true + households: + household: + members: [claimant, partner] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + meets_pension_credit_age_conditions: true + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false, false] + - name: A parent's claim for Universal Credit leaves their young person's own Housing Benefit report excluding them. # A parent aged 45 and a 17-year-old on a non-advanced course, owned # outright. The parent's claim ends the parent's own awards, not the young From a25cc40d08633077448e6d9dd07ad7de42d4b027 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:52:23 +0000 Subject: [PATCH 13/20] Say what the reform pays on a claim, and pin the Working Tax Credit report The reform behind disable_simulated_benefits pays a claimant's or partner's own income-related ESA, Housing Benefit and Child Tax Credit report as reported only in its ten years. From then on the model ends those awards on the family's Universal Credit claim again, while the formula, which reads the switch, still leaves the claim limbs out. The docstring and comments now say so, and cite SI 2014/1230 reg. 8 and SI 2013/983 art. 4. A new case pins that the predicate follows no claim limb for Working Tax Credit: the model keeps a lone parent's award beside a Universal Credit claim in 2024, and their own report still excludes them. The reform test now also checks that the model pays nothing on each claimant's report without the reform. Three YAML headers name the model, not the claim, as the actor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- ...ifying_young_person_existing_benefits.yaml | 5 ++- ...ualifying_young_person_legacy_closure.yaml | 3 +- ...g_young_person_universal_credit_claim.yaml | 38 +++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 7 ++-- .../tests/test_disable_simulated_benefits.py | 18 ++++++--- ...fying_young_person_for_universal_credit.py | 27 ++++++++----- 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 03665bd930..cc0a03a6f2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -15,8 +15,9 @@ # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases # a young person's own report closes their legacy awards only where that # benefit's year gate has already closed, so the closure decides no case. -# The claim limbs end only a claimant's or partner's own award -# (uc_qualifying_young_person_universal_credit_claim.yaml covers them), so +# The predicate follows the family's Universal Credit claim only for a +# claimant's or partner's own award +# (uc_qualifying_young_person_universal_credit_claim.yaml covers it), so # the cases that test a claimant's own Child Tax Credit or Housing Benefit # report claim no Universal Credit (would_claim_uc). The first six cases, # and the four after the seventh, share one household: a parent aged 45 and diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 2762b03988..e5c5d5cd07 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -17,7 +17,8 @@ # 17-year-old on a non-advanced course in the parent's benefit unit, North # East, owned outright, no income. Cases 7 and 8 use the same family renting # from the council at £6,000 a year and claiming no Universal Credit, so only -# the closure can end its Housing Benefit. Case 9 has a parent aged 70. The parent claims Universal Credit as a single +# the closure can end its Housing Benefit. Case 9 has a parent aged 70. In +# every case but 7 and 8 the parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 546774f357..545524e659 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -9,6 +9,8 @@ # award the claim has ended, so for a claimant or partner the UC qualifying # young person test (SI 2014/1230 reg. 28) follows those three limbs. For a # dependant it doesn't: a parent's claim leaves the dependant's own award. +# The model ends no Working Tax Credit, Income Support or income-based JSA +# award on the claim, so the predicate follows no claim limb for them. # # would_claim_uc decides the claim here. A case without a claim also sets # would_claim_uc_at_legacy_closure false, so the family claims nothing when @@ -280,6 +282,42 @@ is_qualifying_young_person_for_universal_credit: [false, false, false] universal_credit: 0 +- name: An 18-year-old lone parent's own Working Tax Credit report excludes them though they claim Universal Credit. + # Tax credits pay in 2024. The model ends Working Tax Credit when the + # family's legacy awards close, but not on its Universal Credit claim + # (is_WTC_eligible reads legacy_benefits_closed, not + # claims_universal_credit). The claimant works 16 hours a week and looks + # after a child, so they meet the model's lone parent hours test (16 hours + # from age 16), and the model keeps their award beside the claim. The + # predicate follows no claim limb for Working Tax Credit, so the + # claimant's own report still excludes them. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + weekly_hours: 16 + working_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_WTC_eligible: true + is_qualifying_young_person_under_universal_credit_regulation_5: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + - name: An 18-year-old partner's own Housing Benefit report excludes them while their mixed-age couple keeps the Pension Credit route. # A claimant aged 75 who reports Pension Credit and an 18-year-old partner # on a non-advanced course rent from the council at £6,000 a year and claim diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 39f05dbc38..2ca48da78e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -7,10 +7,11 @@ # benefit (income-based JSA and Income Support until 31 March 2026), reads an # income-related ESA, income-based JSA or Income Support report only until # the person's legacy awards close (person_legacy_benefits_closed), and -# reads none that a reform neutralises. A claim for Universal Credit also -# ends a claimant's or partner's own income-related ESA +# reads none that a reform neutralises. The model also ends a claimant's or +# partner's own income-related ESA when the family claims Universal Credit # (esa_income_report_closed), so the cases that test a claimant's own -# income-related ESA report claim no Universal Credit (would_claim_uc). The Secretary of State excludes own awards +# income-related ESA report claim no Universal Credit (would_claim_uc). The +# Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 3cc555c204..316eaa8596 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -928,18 +928,24 @@ def test_a_claimant_on_a_benefit_the_reform_pays_beside_universal_credit_is_no_q # A claim for Universal Credit ends the claimant's own income-related # ESA, Housing Benefit and Child Tax Credit (SI 2014/1230 reg. 8(2) and # (2A); SI 2013/983 art. 4), and without the reform the model ends each - # award on the claim, so the predicate doesn't read the report. The - # reform pays each report as reported, claim or none, so the claimant - # stays entitled to it and the predicate excludes them. + # award on the claim, so the predicate doesn't read the report (the + # model's year gate closes the Child Tax Credit report from 2025 too). + # In its years, the dataset year and the nine after it, the reform pays + # each report as reported, claim or none, so the claimant stays entitled + # to it and the predicate excludes them. reformed = Simulation(situation=claimant_situation(), scenario=REFORM) unreformed = Simulation(situation=claimant_situation()) predicate = "is_qualifying_young_person_for_universal_credit" benefit = CLAIMANT_REPORTS[report] person = 2 * index - paid = reformed.calculate(benefit, year) - paid = paid[person] if benefit == "esa_income" else paid[index] + + def paid(simulation): + amounts = simulation.calculate(benefit, year) + return float(amounts[person] if benefit == "esa_income" else amounts[index]) + assert reformed.calculate("claims_universal_credit", year)[index] - assert float(paid) == 3_000 + assert paid(reformed) == 3_000 assert not reformed.calculate(predicate, year)[person] assert unreformed.calculate("claims_universal_credit", year)[index] + assert paid(unreformed) == 0 assert unreformed.calculate(predicate, year)[person] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index e2a319ccfa..b4b3d3474b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -31,9 +31,12 @@ class is_qualifying_young_person_for_universal_credit(Variable): "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " "that switch turns on pays them at their reported amounts in the " "dataset year and the nine years after it, and the formula still reads " - "them in later years. That reform pays each report whether or not the " - "family claims Universal Credit, so while the switch is on the formula " - "leaves out the Universal Credit claim limbs below. The model also ends" + "them in later years. In those years the reform also pays a claimant's " + "or partner's own income-related ESA, Housing Benefit and Child Tax " + "Credit report whether or not the family claims Universal Credit, so " + "while the switch is on the formula leaves out the Universal Credit " + "claim limbs below. It leaves them out in later years too, when the " + "model again ends those awards on the family's claim. The model also ends" " a person's legacy awards once its annual migration proxy closes them:" " it ends their Income Support, income-based JSA and income-related ESA" " when person_legacy_benefits_closed holds, the family's tax credits " @@ -48,7 +51,9 @@ class is_qualifying_young_person_for_universal_credit(Variable): "(esa_income_report_closed), when the family claims " "(claims_universal_credit). For a claimant or partner, the formula " "follows those limbs too. The family's claim doesn't end a dependant's " - "own award, so for a dependant the formula doesn't. It can't read " + "own award, so for a dependant the formula doesn't. The model ends no " + "Working Tax Credit, Income Support or income-based JSA award on the " + "claim, so the formula follows no claim limb for them. It can't read " "housing_benefit_eligible: that variable's Pension Credit route reads " "left_pension_route_at_legacy_closure, which reads this variable " "through is_uc_entitled and the child element. So the formula keeps a " @@ -67,6 +72,8 @@ class is_qualifying_young_person_for_universal_credit(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", @@ -84,7 +91,8 @@ def formula(person, period, parameters): # that year and the nine after it, closure dates included, and holds # both legacy closures false in those years. The formula reads the # switch, not those years, so in a later year it still reads a tax - # credit report that the model no longer pays. + # credit report that the model no longer pays, and still leaves out + # the claim limbs below. paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) system = person.simulation.tax_benefit_system @@ -110,10 +118,11 @@ def paying(active, *names): # income-related ESA when the family claims (esa_income_report_closed), # and the formula follows it. claims_universal_credit reads # would_claim_uc, would_claim_uc_at_legacy_closure and - # legacy_benefits_closed, not this variable. The reform behind - # disable_simulated_benefits pays each report as reported whether or - # not the family claims, and holds the legacy closures false in its - # years, so under it the formula keeps the person's own closure alone. + # legacy_benefits_closed, not this variable. In its years the reform + # behind disable_simulated_benefits pays each report as reported + # whether or not the family claims, and holds the legacy closures + # false, so while the switch is on the formula keeps the person's own + # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) esa_income_open = ~person("esa_income_report_closed", period) | ( own_award_open & paid_as_reported From c8e31269af11a02a111cdd9fb049c81aff2594ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:01:40 +0000 Subject: [PATCH 14/20] Carry the composition with #2806: read reg. 28 on the migration day for reg. 60(1) #2806 merged first, so this PR carries the change the composition note gives (comment 6605782269 on #2806), applied unchanged. is_qualifying_young_person_for_uc_education returns is_qualifying_young_person_for_universal_credit, and a new is_qualifying_young_person_on_uc_migration_day applies reg. 28 to every reported existing benefit, the award a closure claim ended, for the reg. 60(1) protection in uc_is_excepted_from_education_condition. The note's YAML and property-test changes come with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- .../uc_receiving_education.yaml | 9 ++- .../test_uc_receiving_education_properties.py | 74 ++++++++++++++++--- ...ualifying_young_person_for_uc_education.py | 64 +++++----------- ...ifying_young_person_on_uc_migration_day.py | 68 +++++++++++++++++ ...uc_is_excepted_from_education_condition.py | 16 ++-- 5 files changed, 162 insertions(+), 69 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index bc1ed54e69..dda84a456c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -831,7 +831,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_universal_credit: [false] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true @@ -886,7 +886,7 @@ members: [person1, person2] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true, true] + is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_uc_education: [false, false] uc_receiving_education: [false, false] @@ -915,7 +915,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_universal_credit: [false] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] @@ -1256,7 +1256,8 @@ output: legacy_benefits_closed: true claims_uc_at_legacy_closure: true - is_qualifying_young_person_for_uc_education: [false] + is_qualifying_young_person_for_uc_education: [true] + is_qualifying_young_person_on_uc_migration_day: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] meets_uc_education_condition: [true] diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 44c304d9d7..1d010d4de2 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -101,16 +101,34 @@ YEARS = [2019, 2021, 2024, 2026] REGIONS = ["NORTH_EAST", "LONDON", "SCOTLAND", "NORTHERN_IRELAND"] EDUCATION = ["TERTIARY", "POST_SECONDARY", "NOT_IN_EDUCATION"] -# SI 2014/1230 reg. 2(1), as reported receipt. +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# SI 2014/1230 reg. 2(1), as reported receipt, for the migration day: the +# report stands for the award the closure claim ended. EXISTING_BENEFITS = [ "jsa_income_reported", "esa_income_reported", "income_support_reported", "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", + *TAX_CREDITS, ] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# Reg. 28 turns on entitlement, +# so a report counts only in a year the model pays that benefit (the +# parameter, or None where the model has no closure date) and only if no +# reform neutralises one of its variables. +REPORT_BENEFITS = { + "jsa_income_reported": ("JSA.income.active", ["jsa_income"]), + "esa_income_reported": (None, ["esa_income"]), + "income_support_reported": ("income_support.active", ["income_support"]), + "housing_benefit_reported": (None, ["housing_benefit"]), + "child_tax_credit_reported": ( + "tax_credits.active", + ["child_tax_credit", "tax_credits"], + ), + "working_tax_credit_reported": ( + "tax_credits.active", + ["working_tax_credit", "tax_credits"], + ), +} # Compare eligibility, maximum entitlement, income reduction and Universal # Credit awards. OUTPUTS = [ @@ -302,6 +320,23 @@ def all_flags(case): BEFORE = (None, True) +def paying_reports(simulation, year): + """The existing benefit reports reg. 28 reads in the year: those of + benefits the model pays then and no reform neutralises.""" + system = simulation.tax_benefit_system + dwp = system.parameters(f"{year}-01-01").gov.dwp + paying = [] + for report, (active, variables) in REPORT_BENEFITS.items(): + node = dwp + for key in active.split(".") if active else []: + node = getattr(node, key) + if (active is None or bool(node)) and not any( + system.get_variable(v).is_neutralized for v in variables + ): + paying.append(report) + return paying + + def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) @@ -314,6 +349,7 @@ def run(case, variants=(DRAWN,)): np.array([uses_main for _, uses_main in variants for _ in case["families"]]), ) values = {name: simulation.calculate(name, year) for name in VARIABLES} + paying = paying_reports(simulation, year) limit = simulation.tax_benefit_system.parameters( f"{year}-01-01" ).gov.dwp.universal_credit.means_test.capital.limit @@ -324,7 +360,7 @@ def run(case, variants=(DRAWN,)): } results = [] for v in range(len(variants)): - result = {"capital_limit": limit} + result = {"capital_limit": limit, "paying": paying} for name, array in values.items(): entity = simulation.tax_benefit_system.variables[name].entity.key size = sizes[entity] @@ -353,18 +389,31 @@ def reference(case, result): members = range(start, start + len(fam["people"])) person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + tax_credits = [name for name in TAX_CREDITS if name in result["paying"]] couple_tax_credits = any( + person_of[j].get(name, 0) > 0 for j in couple for name in tax_credits + ) + couple_tax_credits_on_migration_day = any( person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS ) - qyp, recv = {}, {} + qyp, qyp_on_migration_day, recv = {}, {}, {} for i, person in person_of.items(): existing_benefit = any( - person.get(name, 0) > 0 for name in EXISTING_BENEFITS + person.get(name, 0) > 0 for name in result["paying"] ) or (i in couple and couple_tax_credits) qyp[i] = ( bool(result["is_qualifying_young_person_for_universal_credit"][i]) and not existing_benefit ) + # Reg. 28 on the migration day, when the award the closure claim + # ended still existed. + existing_benefit_on_migration_day = any( + person.get(name, 0) > 0 for name in EXISTING_BENEFITS + ) or (i in couple and couple_tax_credits_on_migration_day) + qyp_on_migration_day[i] = ( + bool(result["is_qualifying_young_person_for_universal_credit"][i]) + and not existing_benefit_on_migration_day + ) advanced = person["current_education"] == "TERTIARY" or person.get( "in_HE", False ) @@ -410,7 +459,7 @@ def reference(case, result): reg_60 = ( in_couple_role and fam["benunit"]["claims_uc_at_legacy_closure"] - and not qyp[i] + and not qyp_on_migration_day[i] ) exc[i] = limb_b or limb_c or limb_d or limb_e or limb_f or reg_60 claimants = [i for i in members if result["is_uc_single_or_joint_claimant"][i]] @@ -663,11 +712,12 @@ def no_loans_or_grants(i, values): atol=0.005, err_msg=f"{name}: {context}", ) - # 8. Reg. 28: no one who reports an existing benefit counts as a - # qualifying young person for the condition. + # 8. Reg. 28: no one who reports an existing benefit the model pays that + # year, and no reform neutralises, counts as a qualifying young person + # for the condition. reports = np.array( [ - any(p.get(name, 0) > 0 for name in EXISTING_BENEFITS) + any(p.get(name, 0) > 0 for name in drawn["paying"]) for fam in case["families"] for p in fam["people"] ] @@ -711,7 +761,7 @@ def test_examples_reach_the_cases(): # People 9 and 10: the young Housing Benefit claimants. young = slice(9, 11) np.testing.assert_array_equal( - drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] + drawn["is_qualifying_young_person_for_universal_credit"][young], [False, False] ) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_uc_education"][young], [False, False] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 39307c22a0..88d8dc9931 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -1,16 +1,5 @@ from policyengine_uk.model_api import * -# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). -EXISTING_BENEFITS = [ - "jsa_income_reported", - "esa_income_reported", - "income_support_reported", - "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", -] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] - class is_qualifying_young_person_for_uc_education(Variable): value_type = bool @@ -19,25 +8,23 @@ class is_qualifying_young_person_for_uc_education(Variable): documentation = ( "Whether this person counts as a qualifying young person for the " "Universal Credit condition that a claimant is not receiving " - "education: they meet is_qualifying_young_person_for_universal_credit " - "and have no entitlement to an existing benefit. Under the Universal " - "Credit (Transitional Provisions) Regulations 2014 reg. 28, a person " - "entitled to an existing benefit doesn't count as a qualifying young " - "person, and the Secretary of State lists the existing benefits in " - "reg. 2(1): income-based Jobseeker's Allowance, income-related " - "Employment and Support Allowance, Income Support, Housing Benefit, " - "Child Tax Credit and Working Tax Credit. The model reads entitlement " - "from the year's reported receipt: the person's own report of any of " - "them, or, for a claimant or partner, either member's report of tax " - "credits, which a couple claims jointly. A person outside the " - "definition under reg. 28 takes the ordinary route under the Universal " - "Credit Regulations 2013 reg. 12(2). The model's other rules read " - "is_qualifying_young_person_for_universal_credit, which leaves reg. 28 " - "out. In Northern Ireland the decision maker applies the same rule " - "under reg. 29 of the Universal Credit (Transitional Provisions) " - "Regulations (Northern Ireland) 2016, with reg. 6 of the Universal " - "Credit Regulations (Northern Ireland) 2016 in place of the Universal " - "Credit Regulations 2013 reg. 5." + "education. Under the Universal Credit (Transitional Provisions) " + "Regulations 2014 reg. 28, a person entitled to an existing benefit " + "doesn't count as a qualifying young person, and the Secretary of " + "State lists the existing benefits in reg. 2(1): income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance, Income Support, Housing Benefit, Child Tax Credit and " + "Working Tax Credit. It equals " + "is_qualifying_young_person_for_universal_credit, which applies reg. " + "28. A person outside the definition under reg. 28 takes the ordinary " + "route under the Universal Credit Regulations 2013 reg. 12(2). For " + "the reg. 60(1) protection the model reads the status on the " + "migration day instead (is_qualifying_young_person_on_uc_migration_day). " + "In Northern Ireland the decision maker applies the same rule under " + "reg. 29 of the Universal Credit (Transitional Provisions) Regulations " + "(Northern Ireland) 2016, with reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 in place of the Universal Credit " + "Regulations 2013 reg. 5." ) definition_period = YEAR reference = ( @@ -50,19 +37,4 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - qualifying_young_person = person( - "is_qualifying_young_person_for_universal_credit", period - ) - # SI 2014/1230 reg. 28: where a person who would be a qualifying - # young person "is entitled to an existing benefit", "that person is - # not a qualifying young person for the purposes of the Universal - # Credit Regulations". - own_award = add(person, period, EXISTING_BENEFITS) > 0 - # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a - # tax credit jointly, so the award belongs to both. - claimant_or_partner = person("is_claimant_or_partner", period) - joint_tax_credits = claimant_or_partner & ( - person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) - > 0 - ) - return qualifying_young_person & ~(own_award | joint_tax_credits) + return person("is_qualifying_young_person_for_universal_credit", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py new file mode 100644 index 0000000000..2ef8cb2e31 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py @@ -0,0 +1,68 @@ +from policyengine_uk.model_api import * + +# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). +EXISTING_BENEFITS = [ + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +] +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] + + +class is_qualifying_young_person_on_uc_migration_day(Variable): + value_type = bool + entity = Person + label = "Qualifying young person on the Universal Credit migration day" + documentation = ( + "Whether this person counted as a qualifying young person for the " + "Universal Credit condition that a claimant is not receiving " + "education on the migration day of a closure claim, the day before " + "the Universal Credit award starts (Universal Credit (Transitional " + "Provisions) Regulations 2014 reg. 49). Under reg. 28, a person " + "entitled to an existing benefit doesn't count as a qualifying young " + "person, and the Secretary of State lists the existing benefits in " + "reg. 2(1). Under reg. 8(2)(a), a claim for Universal Credit ends a " + 'tax credit award "on the day before the first date on which the ' + 'claimant is entitled to universal credit", so on the migration day ' + "reg. 28 still applied to that award. The model reads the year's " + "reported receipt of an existing benefit as that award, whether or " + "not it still pays the benefit that year: the person's own report, " + "or, for a claimant or partner, either member's report of tax " + "credits, which a couple claims jointly. " + "uc_is_excepted_from_education_condition reads this status for the " + "reg. 60(1) protection. In Northern Ireland the decision maker applies " + "the same rules under regs. 29 and 50 of the Universal Credit " + "(Transitional Provisions) Regulations (Northern Ireland) 2016." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/50", + ) + + def formula(person, period, parameters): + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + # SI 2014/1230 reg. 28: where a person who would be a qualifying + # young person "is entitled to an existing benefit", "that person is + # not a qualifying young person for the purposes of the Universal + # Credit Regulations". The report stands for the award the closure + # claim ended, so the formula reads it in every year. + own_award = add(person, period, EXISTING_BENEFITS) > 0 + # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a + # tax credit jointly, so the award belongs to both. + claimant_or_partner = person("is_claimant_or_partner", period) + joint_tax_credits = claimant_or_partner & ( + person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) + > 0 + ) + return qualifying_young_person & ~(own_award | joint_tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 209eddad27..5b42c4658f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -22,10 +22,10 @@ class uc_is_excepted_from_education_condition(Variable): "a family that claims Universal Credit when DWP moves it off its " "legacy benefits (claims_uc_at_legacy_closure), which the model takes " "as the qualifying claim under reg. 48 of the 2014 Regulations, and who " - "doesn't count " - "as a qualifying young person for the condition " - "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " - "of the 2014 Regulations). The model needs no rule of its own for limb " + "didn't count as a qualifying young person for the condition on the " + "migration day (is_qualifying_young_person_on_uc_migration_day, which " + "applies reg. 28 of the 2014 Regulations to the award the claim " + "ended). The model needs no rule of its own for limb " "(e)(i): it places responsibility for a child on the benefit unit, so " "both members of the couple meet (c). Under (b), the decision maker " "must determine limited capability for work before the course starts. " @@ -63,6 +63,7 @@ class uc_is_excepted_from_education_condition(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/48", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", @@ -127,12 +128,13 @@ def formula(person, period, parameters): # that condition is not to apply ... while they are continuing to # undertake that course". A qualifying young person receives # education under reg. 12(1A), not reg. 12(2); reg. 28 applies - # first, so a person entitled to an existing benefit takes reg. - # 12(2) and keeps the protection. + # first, so a person entitled to an existing benefit on the + # migration day took reg. 12(2) and keeps the protection while they + # continue the course, after that award has ended. migrated_student = ( claimant_or_partner & benunit("claims_uc_at_legacy_closure", period) - & ~person("is_qualifying_young_person_for_uc_education", period) + & ~person("is_qualifying_young_person_on_uc_migration_day", period) ) return ( disabled_with_limited_capability_for_work From 45450629f2a7552f1d3421448408bd14bb8e11d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:08:19 +0000 Subject: [PATCH 15/20] Read reg. 28 at the claim for the education condition The composition makes is_qualifying_young_person_for_uc_education return the UC predicate. With the claim limbs, that predicate gives true for an 18-year-old who claims Universal Credit while reporting their own Housing Benefit, so the model would treat them as a qualifying young person receiving education (UC Regs 2013 reg. 12(1A)) and deny them Universal Credit (#2806's case 31). The Secretary of State excludes a person entitled to an existing benefit (SI 2014/1230 reg. 28) and a person receiving Universal Credit (UC Regs 2013 reg. 5(5)), and the model's reg. 5 test reads only reported Universal Credit. So the education status now reads reg. 28 against the awards a claimant or partner held when they claimed: the predicate's formula moves into qualifying_young_person, whose claim_limbs switch the education status turns off. #2806's own assertions for cases 31 and 34 and for people 9 and 10 of test_examples_reach_the_cases come back: those claimants claim Universal Credit, which ends their own Housing Benefit, so the predicate gives true as #2806 asserted. The changelog names the education condition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 8 +- .../test_uc_receiving_education_properties.py | 7 +- ...fying_young_person_for_universal_credit.py | 225 +++++++++--------- ...ualifying_young_person_for_uc_education.py | 24 +- 5 files changed, 148 insertions(+), 118 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 25484768fe..f0b3fdc0a6 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test against the awards a claimant held when they claimed, and against every reported existing benefit on the migration day for the reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index dda84a456c..6f5067b868 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -815,6 +815,10 @@ # Housing Benefit (reg. 2(1)), doesn't count as a qualifying young person # for the Universal Credit Regulations. With no maintenance funding, reg. # 12(2) doesn't apply either, so the claimant doesn't receive education. + # The claimant claims Universal Credit, which ends their own Housing + # Benefit (reg. 8(2A)), so is_qualifying_young_person_for_universal_credit + # follows that claim limb. The condition reads reg. 28 against the award + # they held when they claimed (is_qualifying_young_person_for_uc_education). period: 2026 input: people: @@ -831,7 +835,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_universal_credit: [true] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true @@ -915,7 +919,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_universal_credit: [true] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 1d010d4de2..ce0ac3a773 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -758,10 +758,13 @@ def test_examples_reach_the_cases(): drawn["uc_is_excepted_from_education_condition"][1:5], [True, False, True, False], ) - # People 9 and 10: the young Housing Benefit claimants. + # People 9 and 10: the young Housing Benefit claimants. Each claims + # Universal Credit, which ends their own Housing Benefit (SI 2014/1230 + # reg. 8(2A)), so the UC predicate follows that claim limb; the condition + # reads reg. 28 against the award they held when they claimed. young = slice(9, 11) np.testing.assert_array_equal( - drawn["is_qualifying_young_person_for_universal_credit"][young], [False, False] + drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] ) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_uc_education"][young], [False, False] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index b4b3d3474b..cfc5952581 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -80,117 +80,124 @@ class is_qualifying_young_person_for_universal_credit(Variable): ] def formula(person, period, parameters): - # The Secretary of State excludes a person entitled to an existing - # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports, the model's legacy closure proxy and the family's Universal - # Credit claim, so it calculates no family benefit that depends on QYP - # status. - p = parameters(period).gov - dwp = p.dwp - # The reform pays each listed benefit at its dataset-year report in - # that year and the nine after it, closure dates included, and holds - # both legacy closures false in those years. The formula reads the - # switch, not those years, so in a later year it still reads a tax - # credit report that the model no longer pays, and still leaves out - # the claim limbs below. - paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) - system = person.simulation.tax_benefit_system + return qualifying_young_person(person, period, parameters) - def paying(active, *names): - # Nobody is entitled to a benefit the model no longer pays, or one - # a reform neutralises. - return bool(active or paid_as_reported) and not any( - system.get_variable(name).is_neutralized for name in names - ) - # The model ends a person's Income Support, income-based JSA and - # income-related ESA when their legacy awards close: the claimant's - # and partner's together (legacy_benefits_closed, read through - # income_support), and anyone else's on their own reports - # (jsa_income, esa_income). person_legacy_benefits_closed and - # legacy_benefits_closed read reports, is_claimant_or_partner, the - # Pension Credit age conditions and parameters, and no Universal - # Credit variable. - own_award_open = ~person("person_legacy_benefits_closed", period) - # A claim for Universal Credit ends the claimant's and partner's own - # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and - # nobody else's. The model ends the claimant's and partner's - # income-related ESA when the family claims (esa_income_report_closed), - # and the formula follows it. claims_universal_credit reads - # would_claim_uc, would_claim_uc_at_legacy_closure and - # legacy_benefits_closed, not this variable. In its years the reform - # behind disable_simulated_benefits pays each report as reported - # whether or not the family claims, and holds the legacy closures - # false, so while the switch is on the formula keeps the person's own - # closure alone. - claimant_or_partner = person("is_claimant_or_partner", period) - esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & paid_as_reported - ) - claim_ends_own_award = ( - claimant_or_partner - & person.benunit("claims_universal_credit", period) - & (not paid_as_reported) - ) - # The model pays Housing Benefit and tax credits to the family, on - # every member's reports, and ends them when the claimant's and - # partner's legacy awards close (housing_benefit_eligible, - # is_CTC_eligible, is_WTC_eligible). It also ends the family's - # continuing Housing Benefit award and its Child Tax Credit when the - # family claims Universal Credit, and the formula follows that limb - # for a claimant or partner, whose own award the claim ends. A family - # that meets the Pension Credit age conditions keeps Housing Benefit - # unless it claims Universal Credit at the closure and is entitled to - # it (left_pension_route_at_legacy_closure). That variable reads this - # one through is_uc_entitled and the child element, so the formula - # keeps the Housing Benefit report for such a family. - family_closed = person.benunit("legacy_benefits_closed", period) - family_award_open = ~family_closed - housing_benefit_open = person.benunit( - "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_ends_own_award) +def qualifying_young_person(person, period, parameters, claim_limbs=True): + """The UC qualifying young person test with SI 2014/1230 reg. 28 + applied, as is_qualifying_young_person_for_universal_credit documents + it. With claim_limbs false it leaves out the limbs that end a + claimant's or partner's own award on the family's Universal Credit + claim, so it reads reg. 28 against the awards they held when they + claimed (is_qualifying_young_person_for_uc_education reads that).""" + # The Secretary of State excludes a person entitled to an existing + # benefit (TP regs. 2(1) and 28). The formula reads raw person-level + # reports, the model's legacy closure proxy and the family's Universal + # Credit claim, so it calculates no family benefit that depends on QYP + # status. + p = parameters(period).gov + dwp = p.dwp + # The reform pays each listed benefit at its dataset-year report in + # that year and the nine after it, closure dates included, and holds + # both legacy closures false in those years. The formula reads the + # switch, not those years, so in a later year it still reads a tax + # credit report that the model no longer pays, and still leaves out + # the claim limbs below. + paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) + system = person.simulation.tax_benefit_system - income_support_paying = ( - paying(dwp.income_support.active, "income_support") & own_award_open - ) - jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open - esa_income_paying = paying(True, "esa_income") & esa_income_open - housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open - child_tax_credit_paying = ( - paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") - & family_award_open - & ~claim_ends_own_award - ) - working_tax_credit_paying = ( - paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") - & family_award_open - ) - tax_credits_reported = child_tax_credit_paying * person( - "child_tax_credit_reported", period - ) + working_tax_credit_paying * person("working_tax_credit_reported", period) - # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, - # so the couple's award belongs to the claimant and the partner. A - # dependant claims as a single person, so their award stays theirs. - couple_tax_credits_reported = person.benunit.sum( - where(claimant_or_partner, tax_credits_reported, 0) + def paying(active, *names): + # Nobody is entitled to a benefit the model no longer pays, or one + # a reform neutralises. + return bool(active or paid_as_reported) and not any( + system.get_variable(name).is_neutralized for name in names ) - receives_tax_credits = (tax_credits_reported > 0) | ( - claimant_or_partner & (couple_tax_credits_reported > 0) - ) - entitled_to_existing_benefit = ( - (esa_income_paying & (person("esa_income_reported", period) > 0)) - | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) - | (income_support_paying & (person("income_support_reported", period) > 0)) - | ( - housing_benefit_paying - & (person("housing_benefit_reported", period) > 0) - ) - | receives_tax_credits - ) - return ( - person( - "is_qualifying_young_person_under_universal_credit_regulation_5", - period, - ) - & ~entitled_to_existing_benefit + + # The model ends a person's Income Support, income-based JSA and + # income-related ESA when their legacy awards close: the claimant's + # and partner's together (legacy_benefits_closed, read through + # income_support), and anyone else's on their own reports + # (jsa_income, esa_income). person_legacy_benefits_closed and + # legacy_benefits_closed read reports, is_claimant_or_partner, the + # Pension Credit age conditions and parameters, and no Universal + # Credit variable. + own_award_open = ~person("person_legacy_benefits_closed", period) + # A claim for Universal Credit ends the claimant's and partner's own + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and + # nobody else's. The model ends the claimant's and partner's + # income-related ESA when the family claims (esa_income_report_closed), + # and the formula follows it. claims_universal_credit reads + # would_claim_uc, would_claim_uc_at_legacy_closure and + # legacy_benefits_closed, not this variable. In its years the reform + # behind disable_simulated_benefits pays each report as reported + # whether or not the family claims, and holds the legacy closures + # false, so while the switch is on the formula keeps the person's own + # closure alone. + claimant_or_partner = person("is_claimant_or_partner", period) + esa_income_open = ~person("esa_income_report_closed", period) | ( + own_award_open & (paid_as_reported or not claim_limbs) + ) + claim_ends_own_award = ( + claimant_or_partner + & person.benunit("claims_universal_credit", period) + & (claim_limbs and not paid_as_reported) + ) + # The model pays Housing Benefit and tax credits to the family, on + # every member's reports, and ends them when the claimant's and + # partner's legacy awards close (housing_benefit_eligible, + # is_CTC_eligible, is_WTC_eligible). It also ends the family's + # continuing Housing Benefit award and its Child Tax Credit when the + # family claims Universal Credit, and the formula follows that limb + # for a claimant or partner, whose own award the claim ends. A family + # that meets the Pension Credit age conditions keeps Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to + # it (left_pension_route_at_legacy_closure). That variable reads this + # one through is_uc_entitled and the child element, so the formula + # keeps the Housing Benefit report for such a family. + family_closed = person.benunit("legacy_benefits_closed", period) + family_award_open = ~family_closed + housing_benefit_open = person.benunit( + "meets_pension_credit_age_conditions", period + ) | (~family_closed & ~claim_ends_own_award) + + income_support_paying = ( + paying(dwp.income_support.active, "income_support") & own_award_open + ) + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & esa_income_open + housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open + child_tax_credit_paying = ( + paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") + & family_award_open + & ~claim_ends_own_award + ) + working_tax_credit_paying = ( + paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") + & family_award_open + ) + tax_credits_reported = child_tax_credit_paying * person( + "child_tax_credit_reported", period + ) + working_tax_credit_paying * person("working_tax_credit_reported", period) + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, + # so the couple's award belongs to the claimant and the partner. A + # dependant claims as a single person, so their award stays theirs. + couple_tax_credits_reported = person.benunit.sum( + where(claimant_or_partner, tax_credits_reported, 0) + ) + receives_tax_credits = (tax_credits_reported > 0) | ( + claimant_or_partner & (couple_tax_credits_reported > 0) + ) + entitled_to_existing_benefit = ( + (esa_income_paying & (person("esa_income_reported", period) > 0)) + | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) + | (income_support_paying & (person("income_support_reported", period) > 0)) + | (housing_benefit_paying & (person("housing_benefit_reported", period) > 0)) + | receives_tax_credits + ) + return ( + person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, ) + & ~entitled_to_existing_benefit + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 88d8dc9931..3e30ee590a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.child_element.is_qualifying_young_person_for_universal_credit import ( + qualifying_young_person, +) class is_qualifying_young_person_for_uc_education(Variable): @@ -14,9 +17,18 @@ class is_qualifying_young_person_for_uc_education(Variable): "State lists the existing benefits in reg. 2(1): income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance, Income Support, Housing Benefit, Child Tax Credit and " - "Working Tax Credit. It equals " - "is_qualifying_young_person_for_universal_credit, which applies reg. " - "28. A person outside the definition under reg. 28 takes the ordinary " + "Working Tax Credit. It applies reg. 28 as " + "is_qualifying_young_person_for_universal_credit does, except for a " + "claimant or partner whose own income-related ESA, Housing Benefit or " + "Child Tax Credit award the family's Universal Credit claim ends. " + "The Secretary of State excludes a claimant while they hold an " + "existing award (reg. 28), and one who receives Universal Credit " + "(Universal Credit Regulations 2013 reg. 5(5)), which the model's " + "reg. 5 test reads only from reported Universal Credit. So for such a " + "claimant the formula reads reg. 28 against the awards they held when " + "they claimed, and leaves out the claim limbs that " + "is_qualifying_young_person_for_universal_credit follows. A person " + "outside the definition under reg. 28 takes the ordinary " "route under the Universal Credit Regulations 2013 reg. 12(2). For " "the reg. 60(1) protection the model reads the status on the " "migration day instead (is_qualifying_young_person_on_uc_migration_day). " @@ -30,6 +42,7 @@ class is_qualifying_young_person_for_uc_education(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", @@ -37,4 +50,7 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - return person("is_qualifying_young_person_for_universal_credit", period) + # The Secretary of State keeps such a claimant outside the + # definition: through reg. 28 while the award their claim ends + # runs, and through reg. 5(5) once they receive Universal Credit. + return qualifying_young_person(person, period, parameters, claim_limbs=False) From cc191bb524ad29e260729817b6c37b7e8ca65628 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:59:41 +0000 Subject: [PATCH 16/20] Read the education status on the first day of the award, and test it against the closures An independent review of 45450629 found three things, which this commit acts on. - A claim ends a tax credit award the day before the Universal Credit award starts (SI 2014/1230 reg. 8(2)(a)), but Housing Benefit only two weeks after (reg. 8(2A)), and esa_income_report_closed describes the same run-on for income-related ESA. So qualifying_young_person's switch is now at_claim_start: it leaves out the Housing Benefit and income-related ESA claim limbs and keeps the Child Tax Credit one, as #2806's case 13 reads reg. 5(5) for a claimant with no existing award. - is_qualifying_young_person_for_uc_education reads the predicate variable again and removes only those the two run-on limbs readmit, so an input or reform that sets the predicate reaches the education condition too. - The composition's property oracle read every paying report, without the model's legacy closures, and no Child Tax Credit claim limb. It now reads the reg. 5 variable, the closures and the family's claim (excluded_on_first_day), with two more examples: a claimant whose family's legacy awards closed before the claim, and a Child Tax Credit claimant with no child. The comments on cases 31 and 34 and on people 9 and 10 say that the predicate reads reg. 5(5) only from reported Universal Credit. The documentation names the nil-award limit, and the changelog the first day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 17 +- .../test_uc_receiving_education_properties.py | 145 +++++++++++++++--- ...fying_young_person_for_universal_credit.py | 23 ++- ...ualifying_young_person_for_uc_education.py | 47 ++++-- 5 files changed, 180 insertions(+), 54 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index f0b3fdc0a6..b95c3b9b00 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test against the awards a claimant held when they claimed, and against every reported existing benefit on the migration day for the reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit or income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 6f5067b868..31b6796266 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -816,9 +816,12 @@ # for the Universal Credit Regulations. With no maintenance funding, reg. # 12(2) doesn't apply either, so the claimant doesn't receive education. # The claimant claims Universal Credit, which ends their own Housing - # Benefit (reg. 8(2A)), so is_qualifying_young_person_for_universal_credit - # follows that claim limb. The condition reads reg. 28 against the award - # they held when they claimed (is_qualifying_young_person_for_uc_education). + # Benefit two weeks after the award starts (reg. 8(2A)). + # is_qualifying_young_person_for_universal_credit follows that claim limb + # and reads reg. 5(5) only from reported Universal Credit, so it gives true + # for this claimant. The condition reads the status on the first day of the + # award, when the Housing Benefit still runs + # (is_qualifying_young_person_for_uc_education). period: 2026 input: people: @@ -896,11 +899,15 @@ - name: Case 34, the 18-year-old of case 32 claims when DWP moves them off legacy benefits and keeps the award. # The claimant reports Housing Benefit, so under SI 2014/1230 reg. 28 they - # don't count as a qualifying young person, and they receive education + # don't count as a qualifying young person for the condition + # (is_qualifying_young_person_for_uc_education), and they receive education # under reg. 12(2)(b), not reg. 12(1A). They fail the condition on the # migration day "because the person is undertaking a full-time course (see # regulation 12(2) and 13 ...)", so under reg. 60(1) it doesn't apply to - # them while they continue the course. + # them while they continue the course. Their claim ends their own Housing + # Benefit, so is_qualifying_young_person_for_universal_credit, which + # follows that claim limb and reads reg. 5(5) only from reported + # Universal Credit, gives true. period: 2026 input: people: diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index ce0ac3a773..be3bf5aa66 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -302,7 +302,12 @@ def all_flags(case): "meets_pension_credit_age_conditions", "uc_assessable_capital", "is_qualifying_young_person_for_universal_credit", + "is_qualifying_young_person_under_universal_credit_regulation_5", "is_qualifying_young_person_for_uc_education", + "is_claimant_or_partner", + "person_legacy_benefits_closed", + "legacy_benefits_closed", + "claims_universal_credit", "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", "has_attained_state_pension_credit_qualifying_age", "is_mixed_age_couple", @@ -337,6 +342,60 @@ def paying_reports(simulation, year): return paying +# Reports whose award the model ends on the person's own legacy closure +# (person_legacy_benefits_closed); it ends the family's Housing Benefit, +# outside the Pension Credit age conditions, and tax credits on the family's +# (legacy_benefits_closed). +OWN_CLOSURE = ["jsa_income_reported", "esa_income_reported", "income_support_reported"] + + +def excluded_on_first_day(case, result): + """Per person, whether reg. 28 keeps them out of the definition for the + condition: an own report, or for a claimant or partner the couple's tax + credit report, that the model pays that year, no reform neutralises and + the model's legacy closure leaves open, on the first day of the + Universal Credit award. That day a Housing Benefit or income-related ESA + award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim + has already ended a claimant's or partner's Child Tax Credit (reg. + 8(2)(a)).""" + flags = [] + index = 0 + for f, fam in enumerate(case["families"]): + members = range(index, index + len(fam["people"])) + family_closed = bool(result["legacy_benefits_closed"][f]) + pension_age = bool(result["meets_pension_credit_age_conditions"][f]) + claims = bool(result["claims_universal_credit"][f]) + + def is_open(i, name): + if fam["people"][i - index].get(name, 0) <= 0: + return False + if name not in result["paying"]: + return False + if name in OWN_CLOSURE: + return not result["person_legacy_benefits_closed"][i] + if name == "housing_benefit_reported": + return pension_age or not family_closed + if family_closed: + return False + return not ( + name == "child_tax_credit_reported" + and claims + and result["is_claimant_or_partner"][i] + ) + + couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + couple_tax_credits = any( + is_open(j, name) for j in couple for name in TAX_CREDITS + ) + for i in members: + flags.append( + any(is_open(i, name) for name in EXISTING_BENEFITS) + or (i in couple and couple_tax_credits) + ) + index += len(fam["people"]) + return np.array(flags, dtype=bool) + + def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) @@ -377,10 +436,12 @@ def reference(case, result): person for the condition, and per family, eligibility with the education condition and without it. Reads from the model only the roles (who is a claimant), the age conditions, qualifying young person status before reg. - 28, the Pension Credit route, capital, responsibility for a child, limited + 28 (the reg. 5 variable), the legacy closures, the family's claim, the + Pension Credit route, capital, responsibility for a child, limited capability for work and the disability benefit amounts, which other rules settle. """ + excluded = excluded_on_first_day(case, result) receiving, excepted, qyp_for_education = [], [], [] eligible, eligible_before = [], [] index = 0 @@ -389,21 +450,18 @@ def reference(case, result): members = range(start, start + len(fam["people"])) person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] - tax_credits = [name for name in TAX_CREDITS if name in result["paying"]] - couple_tax_credits = any( - person_of[j].get(name, 0) > 0 for j in couple for name in tax_credits - ) couple_tax_credits_on_migration_day = any( person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS ) qyp, qyp_on_migration_day, recv = {}, {}, {} for i, person in person_of.items(): - existing_benefit = any( - person.get(name, 0) > 0 for name in result["paying"] - ) or (i in couple and couple_tax_credits) qyp[i] = ( - bool(result["is_qualifying_young_person_for_universal_credit"][i]) - and not existing_benefit + bool( + result[ + "is_qualifying_young_person_under_universal_credit_regulation_5" + ][i] + ) + and not excluded[i] ) # Reg. 28 on the migration day, when the award the closure claim # ended still existed. @@ -712,17 +770,52 @@ def no_loans_or_grants(i, values): atol=0.005, err_msg=f"{name}: {context}", ) - # 8. Reg. 28: no one who reports an existing benefit the model pays that - # year, and no reform neutralises, counts as a qualifying young person - # for the condition. - reports = np.array( - [ - any(p.get(name, 0) > 0 for name in drawn["paying"]) - for fam in case["families"] - for p in fam["people"] - ] - ) - assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][reports]) + # 8. Reg. 28: no one whose existing benefit report the model pays that + # year, no reform neutralises and the legacy closure leaves open, on the + # first day of the Universal Credit award, counts as a qualifying young + # person for the condition. + excluded = excluded_on_first_day(case, drawn) + assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][excluded]) + + +# An 18-year-old on a non-advanced full-time course who claims Universal +# Credit: in 2026, after the family's legacy awards closed on their tax +# credit report, which ended their Housing Benefit; and in 2024, reporting +# their own Child Tax Credit, which the claim ends the day before the award, +# with no child to except them. +CLAIM_EXAMPLES = [ + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + working_tax_credit_reported=1_000, + ) + ] + ) + ], + ), + dict( + year=2024, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + child_tax_credit_reported=2_000, + ) + ] + ) + ], + ), +] @PROPERTY_SETTINGS @@ -730,6 +823,8 @@ def no_loans_or_grants(i, values): @example(EXAMPLES[1]) @example(EXAMPLES[2]) @example(EXAMPLES[3]) +@example(CLAIM_EXAMPLES[0]) +@example(CLAIM_EXAMPLES[1]) @given(cases()) def test_education_condition_invariants(case): check(case) @@ -759,9 +854,11 @@ def test_examples_reach_the_cases(): [True, False, True, False], ) # People 9 and 10: the young Housing Benefit claimants. Each claims - # Universal Credit, which ends their own Housing Benefit (SI 2014/1230 - # reg. 8(2A)), so the UC predicate follows that claim limb; the condition - # reads reg. 28 against the award they held when they claimed. + # Universal Credit, which ends their own Housing Benefit two weeks after + # the award starts (SI 2014/1230 reg. 8(2A)). The UC predicate follows + # that claim limb and reads reg. 5(5) only from reported Universal + # Credit, so it gives true; the condition reads the status on the first + # day of the award, when the Housing Benefit still runs. young = slice(9, 11) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index cfc5952581..458ec0a4a6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -83,13 +83,17 @@ def formula(person, period, parameters): return qualifying_young_person(person, period, parameters) -def qualifying_young_person(person, period, parameters, claim_limbs=True): +def qualifying_young_person(person, period, parameters, at_claim_start=False): """The UC qualifying young person test with SI 2014/1230 reg. 28 applied, as is_qualifying_young_person_for_universal_credit documents - it. With claim_limbs false it leaves out the limbs that end a - claimant's or partner's own award on the family's Universal Credit - claim, so it reads reg. 28 against the awards they held when they - claimed (is_qualifying_young_person_for_uc_education reads that).""" + it. With at_claim_start it gives a claimant's or partner's status on the + first day of their Universal Credit award + (is_qualifying_young_person_for_uc_education reads it). A Housing + Benefit or income-related ESA award the claim ends still runs that day + (reg. 8(2A); esa_income_report_closed's run-on), so the formula leaves + out those two claim limbs. The claim has already ended a tax credit + award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. + The year gates and the legacy closures apply either way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -135,13 +139,16 @@ def paying(active, *names): # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & (paid_as_reported or not claim_limbs) + own_award_open & (paid_as_reported or at_claim_start) ) claim_ends_own_award = ( claimant_or_partner & person.benunit("claims_universal_credit", period) - & (claim_limbs and not paid_as_reported) + & (not paid_as_reported) ) + # On the first day of the award a Housing Benefit award the claim ends + # still runs (reg. 8(2A)). + claim_has_ended_housing_benefit = claim_ends_own_award & (not at_claim_start) # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, @@ -158,7 +165,7 @@ def paying(active, *names): family_award_open = ~family_closed housing_benefit_open = person.benunit( "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_ends_own_award) + ) | (~family_closed & ~claim_has_ended_housing_benefit) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 3e30ee590a..88f8ff849e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -17,18 +17,25 @@ class is_qualifying_young_person_for_uc_education(Variable): "State lists the existing benefits in reg. 2(1): income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance, Income Support, Housing Benefit, Child Tax Credit and " - "Working Tax Credit. It applies reg. 28 as " - "is_qualifying_young_person_for_universal_credit does, except for a " - "claimant or partner whose own income-related ESA, Housing Benefit or " - "Child Tax Credit award the family's Universal Credit claim ends. " - "The Secretary of State excludes a claimant while they hold an " - "existing award (reg. 28), and one who receives Universal Credit " - "(Universal Credit Regulations 2013 reg. 5(5)), which the model's " - "reg. 5 test reads only from reported Universal Credit. So for such a " - "claimant the formula reads reg. 28 against the awards they held when " - "they claimed, and leaves out the claim limbs that " - "is_qualifying_young_person_for_universal_credit follows. A person " - "outside the definition under reg. 28 takes the ordinary " + "Working Tax Credit. It reads " + "is_qualifying_young_person_for_universal_credit, which applies reg. " + "28, but for a claimant or partner it reads the status on the first " + "day of their Universal Credit award. A claim ends a tax credit award " + "the day before the award starts (reg. 8(2)(a)), but a Housing " + "Benefit award only two weeks after (reg. 8(2A)), and the model's " + "esa_income_report_closed describes the same run-on for " + "income-related ESA. So the Secretary of State still excludes a " + "claimant on such an award that day (reg. 28), and once they receive " + "Universal Credit, as a person receiving it (Universal Credit " + "Regulations 2013 reg. 5(5)), which the model's reg. 5 test reads " + "only from reported Universal Credit. The formula therefore leaves " + "out the Housing Benefit and income-related ESA claim limbs that " + "is_qualifying_young_person_for_universal_credit follows, and keeps " + "the Child Tax Credit one, the year gates and the legacy closures. A " + "claimant whose Universal Credit comes to nil receives none, so after " + "the run-on the law counts them as a qualifying young person again; " + "the annual model keeps them outside the definition all year. A " + "person outside the definition under reg. 28 takes the ordinary " "route under the Universal Credit Regulations 2013 reg. 12(2). For " "the reg. 60(1) protection the model reads the status on the " "migration day instead (is_qualifying_young_person_on_uc_migration_day). " @@ -50,7 +57,15 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - # The Secretary of State keeps such a claimant outside the - # definition: through reg. 28 while the award their claim ends - # runs, and through reg. 5(5) once they receive Universal Credit. - return qualifying_young_person(person, period, parameters, claim_limbs=False) + # Those whom the Housing Benefit and income-related ESA claim limbs + # readmit: on the first day of their award the Secretary of State + # still excludes them through reg. 28, and once they receive + # Universal Credit through reg. 5(5). Removing only them keeps any + # input or reform that sets the predicate. + readmitted = qualifying_young_person( + person, period, parameters + ) & ~qualifying_young_person(person, period, parameters, at_claim_start=True) + return ( + person("is_qualifying_young_person_for_universal_credit", period) + & ~readmitted + ) From 426c72fceb77cc9b1b58a6e0d69ae643f1c26740 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:54:34 +0000 Subject: [PATCH 17/20] Say that the annual model applies both claim run-ons in earlier years SI 2018/65 inserted SI 2014/1230 reg. 8(2A) from 11 April 2018, and SI 2019/1152 reg. 5 reaches claims made from 22 July 2020. The education status reads the first day of the award in every year, so its documentation now says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HmavpLcsnXLwhKe5k --- .../is_qualifying_young_person_for_uc_education.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 88f8ff849e..aa3d74752f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -24,7 +24,11 @@ class is_qualifying_young_person_for_uc_education(Variable): "the day before the award starts (reg. 8(2)(a)), but a Housing " "Benefit award only two weeks after (reg. 8(2A)), and the model's " "esa_income_report_closed describes the same run-on for " - "income-related ESA. So the Secretary of State still excludes a " + "income-related ESA. The Secretary of State inserted reg. 8(2A) from " + "11 April 2018 (SI 2018/65), and SI 2019/1152 reg. 5 gives the " + "income-related ESA run-on only to claims made from 22 July 2020; " + "the annual model applies both run-ons in earlier years too. So the " + "Secretary of State still excludes a " "claimant on such an award that day (reg. 28), and once they receive " "Universal Credit, as a person receiving it (Universal Credit " "Regulations 2013 reg. 5(5)), which the model's reg. 5 test reads " From 8402aee706e41e0f2c807ea3a23aa07748b57733 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 06:54:11 -0500 Subject: [PATCH 18/20] Read an income-related ESA report only while the claim stays payable The predicate's income-related ESA gate now requires esa_income_payable_share above 0 unless disable_simulated_benefits pays the report as reported. The term covers the education status on the first day of a claim and the reform, which bypass esa_income_report_closed. Two 2026 cases now expect 706.85 of esa_income (86 of 365 days of 3,000). Co-Authored-By: Claude Opus 5.5 --- ...ing_young_person_esa_income_abolition.yaml | 175 ++++++++++++++++++ ...ifying_young_person_existing_benefits.yaml | 5 +- ...ualifying_young_person_legacy_closure.yaml | 10 +- ...g_young_person_universal_credit_claim.yaml | 9 +- .../tests/test_disable_simulated_benefits.py | 2 +- ...fying_young_person_for_universal_credit.py | 19 +- 6 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml new file mode 100644 index 0000000000..8ede79769f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml @@ -0,0 +1,175 @@ +# The Secretary of State appointed 1 July 2026 for the abolition of the +# remaining old style income-related ESA awards in Great Britain (SI +# 2025/1148 art. 3A(1)), apart from awards saved for an appointee (art. +# 3A(2)). The model pays each claim for the share of the fiscal year before +# that date (esa_income_payable_share, from gov.dwp.ESA.income.awards_payable): +# 1 up to 2025-26, 86/365 = 0.2356 in 2026-27 and 0 from 2027-28, or 1 for a +# saved claim. Nobody is entitled to a claim that pays nothing, so a young +# person's own income-related ESA report excludes them from the UC qualifying +# young person test (SI 2014/1230 reg. 28) only while the share exceeds 0. +# +# The first four cases use one household: a parent aged 45 and a 17-year-old on a +# non-advanced course in the parent's benefit unit, North East, owned +# outright, no income. The parent claims Universal Credit as a single +# claimant over 25 (standard_allowance SINGLE_OLD: £424.90 a month in +# 2026-27, £443.2099 projected for 2027-28) with the first child higher +# amount for a qualifying young person (£364.1952 projected for 2027-28): +# 2027 with the child element: 12 x (443.2099 + 364.1952) = 9,688.86 +# 2027 without the child element: 12 x 443.2099 = 5,318.52 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element. + # The claim pays nothing in 2027-28, so nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: A young person's own income-related ESA report saved for an appointee removes the child element. + # An award saved for an appointee stays payable (SI 2025/1148 art. 3A(2)). + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 1] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_318.52 + +- name: A young person's own income-related ESA report in the year the claim stops paying removes the child element. + # The claim pays for 86 of the 365 days of 2026-27 in Great Britain. + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0.2356, 0.2356] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element while their legacy awards stay open. + # The young person also reports Pension Credit, so the model's migration + # proxy leaves their legacy awards open (person_legacy_benefits_closed is + # false), but the claim still pays nothing in 2027-28 + # (esa_income_payable_share 0) and nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + person_legacy_benefits_closed: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: An 18-year-old claimant's own income-related ESA report after the claim stops paying leaves them receiving education while their legacy awards stay open. + # A single 18-year-old on a full-time non-advanced course with no + # maintenance funding reports their own income-related ESA and Pension + # Credit, and claims Universal Credit. The Pension Credit report leaves + # their legacy awards open (person_legacy_benefits_closed false), but the + # ESA claim pays nothing in 2027-28 (esa_income_payable_share 0), so on the + # first day of their Universal Credit award they aren't entitled to it + # either. They count as a qualifying young person for the education + # condition and receive education (UC Regs 2013 reg. 12(1A)), with no + # exception, so they get no Universal Credit. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + is_claimant_or_partner: true + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: NORTH_EAST + output: + esa_income_payable_share: [0] + person_legacy_benefits_closed: [false] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index cc0a03a6f2..8966b13925 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -8,8 +8,9 @@ # the model's legacy closure, or for a claimant or partner the family's # Universal Credit claim, ends the award on it, and only if no reform # neutralises it. The model pays Income Support and income-based -# JSA until 31 March 2026 and tax credits until 5 April 2025, and has no -# closure date for Housing Benefit or income-related ESA. It also ends a +# JSA until 31 March 2026 and tax credits until 5 April 2025, has no +# closure date for Housing Benefit, and pays an income-related ESA claim +# only while it stays payable (esa_income_payable_share). It also ends a # person's legacy awards when its annual migration proxy closes them, and # the predicate follows that closure # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index e5c5d5cd07..694dd2eaf5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -29,9 +29,11 @@ # 2026 without the child element: 12 x 424.90 = 5,098.80 - name: A young person's own income-related ESA report alone removes the child element in 2026. - # No closure applies: the young person reports no closed benefit, so the - # model pays the £3,000 of income-related ESA (esa_income) and the young - # person is entitled to it. UC: 12 x 424.90 = 5,098.80. + # No closure applies: the young person reports no closed benefit, and the + # claim pays for 86 of the 365 days of 2026-27 (esa_income_payable_share + # 0.2356), so the model pays 3,000 x 86 / 365 = 706.85 of income-related + # ESA (esa_income) and the young person is entitled to it. UC: 12 x 424.90 + # = 5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -55,7 +57,7 @@ tenure_type: OWNED_OUTRIGHT output: person_legacy_benefits_closed: [false, false] - esa_income: 3_000 + esa_income: 706.85 is_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 universal_credit: 5_098.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 545524e659..f348fa21c5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -59,9 +59,10 @@ - name: An 18-year-old claimant's own income-related ESA report excludes them while they don't claim Universal Credit. # Nothing ends the award: the claimant doesn't claim Universal Credit and - # reports no closed benefit, so the model pays the £3,000 of income-related - # ESA on their report and they're entitled to it. They get no Universal - # Credit. + # reports no closed benefit, and the claim pays for 86 of the 365 days of + # 2026-27 (esa_income_payable_share 0.2356), so the model pays 3,000 x 86 / + # 365 = 706.85 of income-related ESA on their report and they're entitled + # to it. They get no Universal Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -83,7 +84,7 @@ tenure_type: OWNED_OUTRIGHT output: esa_income_report_closed: [false] - esa_income: 3_000 + esa_income: 706.85 is_qualifying_young_person_for_universal_credit: [false] universal_credit: 0 diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 773a5cf406..292a35cc03 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -898,7 +898,7 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers assert unreformed.calculate(predicate, year)[1::2].tolist() == [ legacy_closed, legacy_closed, - False, + year >= 2027, False, tax_credits_closed, tax_credits_closed, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 458ec0a4a6..32b04bd7ab 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -25,9 +25,11 @@ class is_qualifying_young_person_for_universal_credit(Variable): "ESA only after their capital and work screens. The model pays Income " "Support, income-based JSA and tax credits only while " "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " - "gov.dwp.tax_credits.active hold, and has no closure date for Housing " - "Benefit or income-related ESA. The formula also reads those three " - "benefits' reports while " + "gov.dwp.tax_credits.active hold, has no closure date for Housing " + "Benefit, and pays an income-related ESA claim only while it stays " + "payable (esa_income_payable_share), so the formula reads its report " + "only then. The formula also reads the Income Support, income-based " + "JSA and tax credit reports while " "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " "that switch turns on pays them at their reported amounts in the " "dataset year and the nine years after it, and the formula still reads " @@ -138,9 +140,14 @@ def paying(active, *names): # false, so while the switch is on the formula keeps the person's own # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) - esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & (paid_as_reported or at_claim_start) - ) + # The Secretary of State (SI 2025/1148 art. 3A) and, in Northern + # Ireland, the Department for Communities (SR 2025/176 art. 3A) end old + # style income-related ESA claims on the appointed date unless an + # appointee saving keeps them (esa_income_payable_share). + esa_income_open = ( + ~person("esa_income_report_closed", period) + | (own_award_open & (paid_as_reported or at_claim_start)) + ) & ((person("esa_income_payable_share", period) > 0) | paid_as_reported) claim_ends_own_award = ( claimant_or_partner & person.benunit("claims_universal_credit", period) From 98b56ca0802f552c2afe6ab8e94a34822f1261df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 08:59:08 -0500 Subject: [PATCH 19/20] Bind the Northern Ireland claim termination rules to the first-day test SR 2016/226 reg. 6 is the twin of SI 2014/1230 reg. 8 with one difference: a claim ends a rate rebate the day before the award starts (reg. 6(2)), and only a rent rebate or allowance runs on for two weeks (reg. 6(2A)). The first-day test now keeps the Housing Benefit claim limb for a Northern Ireland family that doesn't rent, whose report the model reads as a rate rebate. The variables also cite SR 2017/190 art. 6, SR 2019/152 reg. 4 and the Housing Benefit Regulations (Northern Ireland) 2006 regs. 12 and 13. The predicate now keeps a Housing Benefit report for a family in specified or temporary accommodation, since neither a claim nor the closure ends that award (SI 2014/1230 regs. 8(3) and 46(2); SR 2016/226 regs. 6(3) and 47(2)). Co-Authored-By: Claude Opus 5.5 --- changelog.d/2804.fixed.md | 2 +- ...ualifying_young_person_legacy_closure.yaml | 47 +++++++- .../uc_receiving_education.yaml | 110 ++++++++++++++++++ .../test_uc_receiving_education_properties.py | 47 +++++++- .../gov/dwp/esa_income_report_closed.py | 4 +- ...fying_young_person_for_universal_credit.py | 63 ++++++++-- ...ualifying_young_person_for_uc_education.py | 23 +++- ...ifying_young_person_on_uc_migration_day.py | 3 +- 8 files changed, 277 insertions(+), 22 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index b95c3b9b00..e741d4f257 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit or income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 694dd2eaf5..450b158b04 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -13,12 +13,12 @@ # 6 April 2025). The predicate excludes nobody on those reports, as each # benefit's year gate has closed. # -# Cases 1 to 6 and the last use one household: a parent aged 45 and a +# Cases 1 to 6 and 10 use one household: a parent aged 45 and a # 17-year-old on a non-advanced course in the parent's benefit unit, North -# East, owned outright, no income. Cases 7 and 8 use the same family renting +# East, owned outright, no income. Cases 7, 8 and 11 use the same family renting # from the council at £6,000 a year and claiming no Universal Credit, so only # the closure can end its Housing Benefit. Case 9 has a parent aged 70. In -# every case but 7 and 8 the parent claims Universal Credit as a single +# every case but 7, 8 and 11 the parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person @@ -389,3 +389,44 @@ legacy_benefits_closed: false is_qualifying_young_person_for_universal_credit: [false, false] universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report in temporary accommodation still excludes them after the parent's legacy awards close. + # The family of case 7 in temporary accommodation. The parent's own Income + # Support report closes the family's legacy awards, but the move off legacy + # benefits doesn't end a Housing Benefit award for specified or temporary + # accommodation (SI 2014/1230 reg. 46(2)). The model keeps the award + # (housing_benefit_eligible) and the predicate keeps the young person's + # report, so the family gets no child element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 31b6796266..3368618e24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1274,3 +1274,113 @@ meets_uc_education_condition: [true] is_uc_eligible: true universal_credit: 4_062.96 + +- name: Case 43, the 18-year-old of case 31 in Northern Ireland, whose family rents, keeps the ordinary route. + # SR 2016/226 reg. 6(2A): a claim ends a rent rebate or allowance two weeks + # after the award starts, so on the first day the claimant keeps an existing + # benefit and reg. 29 keeps them out of the definition, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 44, the same 18-year-old in Northern Ireland in a home the family owns receives education. + # A rent rebate or allowance needs rent or a similar payment (Housing Benefit + # Regulations (Northern Ireland) 2006 reg. 13(1)), so the model reads the report + # as a rate rebate (reg. 12). SR 2016/226 reg. 6(2): the claim ends it the day + # before the award starts. On the first day the claimant has no existing + # benefit, so they count as a qualifying young person and receive education + # (Universal Credit Regulations (Northern Ireland) 2016 reg. 12(1)). No + # exception applies, so they get no Universal Credit. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 45, the same 18-year-old in England in a home the family owns keeps the ordinary route. + # SI 2014/1230 reg. 8(2A) runs on an award of Housing Benefit whatever its + # form, so reg. 28 keeps the claimant out of the definition on the first day. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 46, the 18-year-old of case 31 in temporary accommodation stays outside the definition all year. + # SI 2014/1230 reg. 8(3): a claim doesn't end a Housing Benefit award for + # specified or temporary accommodation, so reg. 28 keeps the claimant out of + # the definition after the first day as well, and they take the ordinary + # route, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + in_specified_or_temporary_accommodation: true + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index be3bf5aa66..2342980b67 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -357,7 +357,9 @@ def excluded_on_first_day(case, result): Universal Credit award. That day a Housing Benefit or income-related ESA award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim has already ended a claimant's or partner's Child Tax Credit (reg. - 8(2)(a)).""" + 8(2)(a)) and, in Northern Ireland, their rate rebate (SR 2016/226 reg. + 6(2)), which the model reads from a report in a family that doesn't + rent.""" flags = [] index = 0 for f, fam in enumerate(case["families"]): @@ -374,7 +376,13 @@ def is_open(i, name): if name in OWN_CLOSURE: return not result["person_legacy_benefits_closed"][i] if name == "housing_benefit_reported": - return pension_age or not family_closed + rate_rebate_ended = ( + fam["household"]["region"] == "NORTHERN_IRELAND" + and fam["household"]["tenure_type"] == "OWNED_OUTRIGHT" + and claims + and result["is_claimant_or_partner"][i] + ) + return pension_age or not (family_closed or rate_rebate_ended) if family_closed: return False return not ( @@ -782,7 +790,10 @@ def no_loans_or_grants(i, values): # Credit: in 2026, after the family's legacy awards closed on their tax # credit report, which ended their Housing Benefit; and in 2024, reporting # their own Child Tax Credit, which the claim ends the day before the award, -# with no child to except them. +# with no child to except them. In Northern Ireland in 2026, an owner reports +# what the model reads as a rate rebate, which the claim ends the day before +# the award (SR 2016/226 reg. 6(2)), and a tenant reports a rent rebate or +# allowance, which runs on (reg. 6(2A)). CLAIM_EXAMPLES = [ dict( year=2026, @@ -815,6 +826,35 @@ def no_loans_or_grants(i, values): ) ], ), + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + ), + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, + ), + ], + ), ] @@ -825,6 +865,7 @@ def no_loans_or_grants(i, values): @example(EXAMPLES[3]) @example(CLAIM_EXAMPLES[0]) @example(CLAIM_EXAMPLES[1]) +@example(CLAIM_EXAMPLES[2]) @given(cases()) def test_education_condition_invariants(case): check(case) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index 6c02b921d2..80cbc3dec7 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -21,7 +21,8 @@ class esa_income_report_closed(Variable): "in Northern Ireland, Welfare Reform (Northern Ireland) Order 2015 " "art. 39(1)(b) and SR 2017/190 art. 6(1) to (3) and (6)). For claims " "from 22 July 2020 the award runs on for two weeks after that day (SI " - "2019/1152 reg. 5), which this annual model does not represent. A " + "2019/1152 reg. 5; in Northern Ireland, SR 2019/152 reg. 4), which this " + "annual model does not represent. A " "contributory allowance in the award continues as new style ESA " "(esa_contrib). A member who is neither the claimant nor the partner, " "such as a non-dependent adult, claims in their own right and is not " @@ -48,6 +49,7 @@ class esa_income_report_closed(Variable): "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", "https://www.legislation.gov.uk/nisi/2015/2006/article/39", "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", "https://www.legislation.gov.uk/nisr/2025/176/article/3A", ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 32b04bd7ab..1f303f8ad6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -46,8 +46,9 @@ class is_qualifying_young_person_for_universal_credit(Variable): "legacy_benefits_closed holds and the family doesn't meet the Pension " "Credit age conditions. The formula follows both closures. A claim for " "Universal Credit ends the claimant's and partner's own awards (SI " - "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and the model ends " - "the family's continuing Housing Benefit award " + "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in Northern Ireland, " + "SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. 6), and the model " + "ends the family's continuing Housing Benefit award " "(housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible)," " and the claimant's and partner's income-related ESA " "(esa_income_report_closed), when the family claims " @@ -60,7 +61,12 @@ class is_qualifying_young_person_for_universal_credit(Variable): "left_pension_route_at_legacy_closure, which reads this variable " "through is_uc_entitled and the child element. So the formula keeps a " "Housing Benefit report while the family meets the Pension Credit age " - "conditions. The formula checks each report against its own variable: " + "conditions. It also keeps the report while the family lives in " + "specified or temporary accommodation " + "(in_specified_or_temporary_accommodation), since neither the claim nor " + "the closure ends that award (SI 2014/1230 regs. 8(3) and 46(2); SR " + "2016/226 regs. 6(3) and 47(2)). The formula checks each report against " + "its own variable: " "income_support, jsa_income, esa_income, housing_benefit, " "child_tax_credit or tax_credits for Child Tax Credit, and " "working_tax_credit or tax_credits for Working Tax Credit. A reform " @@ -75,9 +81,19 @@ class is_qualifying_young_person_for_universal_credit(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/47", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", ] @@ -95,7 +111,11 @@ def qualifying_young_person(person, period, parameters, at_claim_start=False): (reg. 8(2A); esa_income_report_closed's run-on), so the formula leaves out those two claim limbs. The claim has already ended a tax credit award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. - The year gates and the legacy closures apply either way.""" + In Northern Ireland the claim has also already ended a rate rebate (SR + 2016/226 reg. 6(2)), and only a rent rebate or allowance runs on (reg. + 6(2A)), so there the formula keeps the Housing Benefit limb for a family + that doesn't rent. The year gates and the legacy closures apply either + way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -129,8 +149,9 @@ def paying(active, *names): # Credit variable. own_award_open = ~person("person_legacy_benefits_closed", period) # A claim for Universal Credit ends the claimant's and partner's own - # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and - # nobody else's. The model ends the claimant's and partner's + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in + # Northern Ireland, SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. + # 6), and nobody else's. The model ends the claimant's and partner's # income-related ESA when the family claims (esa_income_report_closed), # and the formula follows it. claims_universal_credit reads # would_claim_uc, would_claim_uc_at_legacy_closure and @@ -154,8 +175,21 @@ def paying(active, *names): & (not paid_as_reported) ) # On the first day of the award a Housing Benefit award the claim ends - # still runs (reg. 8(2A)). - claim_has_ended_housing_benefit = claim_ends_own_award & (not at_claim_start) + # still runs (reg. 8(2A)). In Northern Ireland only a rent rebate or + # allowance still runs (SR 2016/226 reg. 6(2A)): the claim has already + # ended a rate rebate (reg. 6(2)). The report doesn't show the form. A + # rent rebate or allowance needs rent or a similar payment (Housing + # Benefit Regulations (Northern Ireland) 2006 reg. 13(1)), so the formula + # reads a report in a family that doesn't rent as a rate rebate (reg. 12). + if at_claim_start: + country = person.household("country", period) + claim_has_ended_housing_benefit = ( + claim_ends_own_award + & (country == country.possible_values.NORTHERN_IRELAND) + & ~person.benunit("benunit_is_renting", period) + ) + else: + claim_has_ended_housing_benefit = claim_ends_own_award # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, @@ -167,12 +201,17 @@ def paying(active, *names): # unless it claims Universal Credit at the closure and is entitled to # it (left_pension_route_at_legacy_closure). That variable reads this # one through is_uc_entitled and the child element, so the formula - # keeps the Housing Benefit report for such a family. + # keeps the Housing Benefit report for such a family. Neither the claim nor + # the closure ends an award for specified or temporary accommodation (SI + # 2014/1230 regs. 8(3) and 46(2); SR 2016/226 regs. 6(3) and 47(2)), so the + # formula keeps that report too, as housing_benefit_eligible keeps the award. family_closed = person.benunit("legacy_benefits_closed", period) family_award_open = ~family_closed - housing_benefit_open = person.benunit( - "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_has_ended_housing_benefit) + housing_benefit_open = ( + person.benunit("meets_pension_credit_age_conditions", period) + | person.benunit("in_specified_or_temporary_accommodation", period) + | (~family_closed & ~claim_has_ended_housing_benefit) + ) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index aa3d74752f..1a0314998e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -47,17 +47,38 @@ class is_qualifying_young_person_for_uc_education(Variable): "reg. 29 of the Universal Credit (Transitional Provisions) Regulations " "(Northern Ireland) 2016, with reg. 6 of the Universal Credit " "Regulations (Northern Ireland) 2016 in place of the Universal Credit " - "Regulations 2013 reg. 5." + "Regulations 2013 reg. 5. SR 2016/226 reg. 6 ends awards on the same " + "days as reg. 8, and SR 2019/152 reg. 4 gives the income-related ESA " + "run-on to claims made from 22 July 2020, with one difference: a claim " + "ends a rate rebate the day before the award starts (reg. 6(2)), and " + "only a rent rebate or allowance runs on (reg. 6(2A)). The report " + "doesn't show the form. A rent rebate or allowance needs rent or a " + "similar payment (Housing Benefit Regulations (Northern Ireland) 2006 " + "reg. 13(1)), so the formula reads a report in a family that doesn't " + "rent as a rate rebate (reg. 12), and a claimant with no other " + "existing benefit counts as a qualifying young person on the first " + "day. The model can't see someone who owns a caravan or houseboat and " + "pays site or mooring charges (reg. 13(1)(f) and (g)), so it reads " + "their rent allowance as a rate rebate." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py index 2ef8cb2e31..076ac7d474 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py @@ -34,7 +34,7 @@ class is_qualifying_young_person_on_uc_migration_day(Variable): "credits, which a couple claims jointly. " "uc_is_excepted_from_education_condition reads this status for the " "reg. 60(1) protection. In Northern Ireland the decision maker applies " - "the same rules under regs. 29 and 50 of the Universal Credit " + "the same rules under regs. 6(2), 29 and 50 of the Universal Credit " "(Transitional Provisions) Regulations (Northern Ireland) 2016." ) definition_period = YEAR @@ -44,6 +44,7 @@ class is_qualifying_young_person_on_uc_migration_day(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/50", ) From 590a2cc39c218941496c01e6e3cb8f720a5e2833 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 10:36:24 -0500 Subject: [PATCH 20/20] Read the Northern Ireland rate rebate from a supplied input, not tenure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit housing_benefit_reported doesn't show whether a Northern Ireland award takes the form of a rate rebate or of a rent rebate or allowance, and tenure doesn't settle it: SR 2006/405 reg. 13(1)(f) and (g) pays a rent rebate or allowance on mooring charges and site payments, so an owner can get one, and SR 2016/226 reg. 6(2A) runs it on. The first-day test now reads housing_benefit_report_is_rate_rebate_only, false by default, so a report takes the run-on unless the input marks it as a rate rebate alone. Case 47 covers an owner with a rent allowance, who keeps Universal Credit of £4,062.96. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 46 +++++++++++++++---- .../test_uc_receiving_education_properties.py | 31 +++++++++---- ...sing_benefit_report_is_rate_rebate_only.py | 35 ++++++++++++++ ...fying_young_person_for_universal_credit.py | 14 +++--- ...ualifying_young_person_for_uc_education.py | 15 +++--- 6 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index e741d4f257..8a73e94f80 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. The supplied input `housing_benefit_report_is_rate_rebate_only` marks a Northern Ireland report that covers only a rate rebate, which the claim ends the day before the award (SR 2016/226 reg. 6(2)). diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 3368618e24..35386bd3d5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1301,14 +1301,12 @@ uc_receiving_education: [false] is_uc_eligible: true -- name: Case 44, the same 18-year-old in Northern Ireland in a home the family owns receives education. - # A rent rebate or allowance needs rent or a similar payment (Housing Benefit - # Regulations (Northern Ireland) 2006 reg. 13(1)), so the model reads the report - # as a rate rebate (reg. 12). SR 2016/226 reg. 6(2): the claim ends it the day - # before the award starts. On the first day the claimant has no existing - # benefit, so they count as a qualifying young person and receive education - # (Universal Credit Regulations (Northern Ireland) 2016 reg. 12(1)). No - # exception applies, so they get no Universal Credit. +- name: Case 44, the same 18-year-old in Northern Ireland, whose report covers only a rate rebate, receives education. + # SR 2016/226 reg. 6(2): the claim ends a rate rebate the day before the award + # starts. On the first day the claimant has no existing benefit, so they count + # as a qualifying young person and receive education (Universal Credit + # Regulations (Northern Ireland) 2016 reg. 12(1)). No exception applies, so they + # get no Universal Credit. period: 2026 input: people: @@ -1317,6 +1315,7 @@ current_education: POST_SECONDARY is_in_full_time_education: true housing_benefit_reported: 6_000 + housing_benefit_report_is_rate_rebate_only: true benunits: benunit: members: [person1] @@ -1384,3 +1383,34 @@ is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true + +- name: Case 47, the same 18-year-old in a caravan the family owns in Northern Ireland, with a rent allowance, keeps the ordinary route. + # Housing Benefit Regulations (Northern Ireland) 2006 reg. 13(1)(f) and (g): a + # rent rebate or allowance covers mooring charges for a houseboat and site + # payments for a caravan or mobile home, so an owner can get one. SR 2016/226 + # reg. 6(2A) runs it on for two weeks, so on the first day reg. 29 keeps the + # claimant out of the definition, as in case 43. They get the standard allowance + # for a single person under 25: 12 x 338.58 (SR 2026/54 reg. 3(3)(a)). + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + accommodation_type: MOBILE + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 4_062.96 diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 2342980b67..a211977060 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -208,6 +208,7 @@ def adult(draw, ages): uc_is_responsible_foster_parent_of_child_under_one=draw(rarely), uc_is_foster_parent_or_new_friend_or_family_carer=draw(rarely), housing_benefit_reported=draw(st.sampled_from([0, 0, 0, 0, 6_000])), + housing_benefit_report_is_rate_rebate_only=draw(rarely), working_tax_credit_reported=draw(st.sampled_from([0, 0, 0, 0, 1_000])), employment_income=draw(st.sampled_from([0, 0, 4_000, 15_000])), is_claimant_or_partner=True, @@ -357,9 +358,8 @@ def excluded_on_first_day(case, result): Universal Credit award. That day a Housing Benefit or income-related ESA award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim has already ended a claimant's or partner's Child Tax Credit (reg. - 8(2)(a)) and, in Northern Ireland, their rate rebate (SR 2016/226 reg. - 6(2)), which the model reads from a report in a family that doesn't - rent.""" + 8(2)(a)) and, in Northern Ireland, a report of a rate rebate alone (SR + 2016/226 reg. 6(2); housing_benefit_report_is_rate_rebate_only).""" flags = [] index = 0 for f, fam in enumerate(case["families"]): @@ -378,7 +378,9 @@ def is_open(i, name): if name == "housing_benefit_reported": rate_rebate_ended = ( fam["household"]["region"] == "NORTHERN_IRELAND" - and fam["household"]["tenure_type"] == "OWNED_OUTRIGHT" + and fam["people"][i - index].get( + "housing_benefit_report_is_rate_rebate_only", False + ) and claims and result["is_claimant_or_partner"][i] ) @@ -790,10 +792,11 @@ def no_loans_or_grants(i, values): # Credit: in 2026, after the family's legacy awards closed on their tax # credit report, which ended their Housing Benefit; and in 2024, reporting # their own Child Tax Credit, which the claim ends the day before the award, -# with no child to except them. In Northern Ireland in 2026, an owner reports -# what the model reads as a rate rebate, which the claim ends the day before -# the award (SR 2016/226 reg. 6(2)), and a tenant reports a rent rebate or -# allowance, which runs on (reg. 6(2A)). +# with no child to except them. In Northern Ireland in 2026, one owner reports +# a rate rebate alone, which the claim ends the day before the award (SR +# 2016/226 reg. 6(2)). Another owner and a tenant report a rent rebate or +# allowance, which runs on (reg. 6(2A)): an owner can get one on site or +# mooring payments (SR 2006/405 reg. 13(1)(f) and (g)). CLAIM_EXAMPLES = [ dict( year=2026, @@ -829,6 +832,18 @@ def no_loans_or_grants(i, values): dict( year=2026, families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + housing_benefit_report_is_rate_rebate_only=True, + ) + ], + region="NORTHERN_IRELAND", + ), single_family( [ person( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py new file mode 100644 index 0000000000..ccb2eb3350 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_report_is_rate_rebate_only(Variable): + value_type = bool + entity = Person + label = "Housing Benefit report covers only a Northern Ireland rate rebate" + documentation = ( + "Whether this person's housing_benefit_reported covers only a rate " + "rebate on the home they occupy in Northern Ireland, with no rent " + "rebate or allowance. The Housing Benefit Regulations (Northern " + "Ireland) 2006 (SR 2006/405) and their twin for people at the " + "qualifying age for State Pension Credit (SR 2006/406) pay a rate " + "rebate on rates (reg. 12(1)) and a rent rebate or allowance on rent " + "and similar payments (reg. 13(1)), including mooring charges for a " + "houseboat and site payments for a caravan or mobile home (reg. " + "13(1)(f) and (g)). So tenure doesn't settle the form: an owner can " + "get a rent allowance. A Universal Credit claim ends a rate rebate the " + "day before the award starts (SR 2016/226 reg. 6(2)) and runs a rent " + "rebate or allowance on for two weeks (reg. 6(2A)). Only the first-day " + "test that is_qualifying_young_person_for_uc_education reads uses this " + "status, and only in Northern Ireland. A person with both forms leaves " + "it false, because their rent rebate or allowance runs on. It has no " + "formula and defaults to False, so the model reads a report as a rent " + "rebate or allowance unless this input says otherwise." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 1f303f8ad6..b770f9000e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -113,9 +113,9 @@ def qualifying_young_person(person, period, parameters, at_claim_start=False): award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. In Northern Ireland the claim has also already ended a rate rebate (SR 2016/226 reg. 6(2)), and only a rent rebate or allowance runs on (reg. - 6(2A)), so there the formula keeps the Housing Benefit limb for a family - that doesn't rent. The year gates and the legacy closures apply either - way.""" + 6(2A)), so there the formula keeps the Housing Benefit limb for a report + of a rate rebate alone (housing_benefit_report_is_rate_rebate_only). The + year gates and the legacy closures apply either way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -177,16 +177,14 @@ def paying(active, *names): # On the first day of the award a Housing Benefit award the claim ends # still runs (reg. 8(2A)). In Northern Ireland only a rent rebate or # allowance still runs (SR 2016/226 reg. 6(2A)): the claim has already - # ended a rate rebate (reg. 6(2)). The report doesn't show the form. A - # rent rebate or allowance needs rent or a similar payment (Housing - # Benefit Regulations (Northern Ireland) 2006 reg. 13(1)), so the formula - # reads a report in a family that doesn't rent as a rate rebate (reg. 12). + # ended a rate rebate (reg. 6(2)). The report doesn't show the form, so + # the formula reads housing_benefit_report_is_rate_rebate_only. if at_claim_start: country = person.household("country", period) claim_has_ended_housing_benefit = ( claim_ends_own_award & (country == country.possible_values.NORTHERN_IRELAND) - & ~person.benunit("benunit_is_renting", period) + & person("housing_benefit_report_is_rate_rebate_only", period) ) else: claim_has_ended_housing_benefit = claim_ends_own_award diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 1a0314998e..cee52f742f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -51,15 +51,12 @@ class is_qualifying_young_person_for_uc_education(Variable): "days as reg. 8, and SR 2019/152 reg. 4 gives the income-related ESA " "run-on to claims made from 22 July 2020, with one difference: a claim " "ends a rate rebate the day before the award starts (reg. 6(2)), and " - "only a rent rebate or allowance runs on (reg. 6(2A)). The report " - "doesn't show the form. A rent rebate or allowance needs rent or a " - "similar payment (Housing Benefit Regulations (Northern Ireland) 2006 " - "reg. 13(1)), so the formula reads a report in a family that doesn't " - "rent as a rate rebate (reg. 12), and a claimant with no other " - "existing benefit counts as a qualifying young person on the first " - "day. The model can't see someone who owns a caravan or houseboat and " - "pays site or mooring charges (reg. 13(1)(f) and (g)), so it reads " - "their rent allowance as a rate rebate." + "only a rent rebate or allowance runs on (reg. 6(2A)). " + "housing_benefit_reported doesn't show the form, so the formula reads " + "housing_benefit_report_is_rate_rebate_only: a claimant with that " + "status and no other existing benefit counts as a qualifying young " + "person on the first day. It defaults to False, so the formula reads " + "any other report as a rent rebate or allowance." ) definition_period = YEAR reference = (