From 6af701944e985ca93f280bbf7b694a67abf43273 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:09:35 -0400 Subject: [PATCH 01/26] Add the UC and Pension Credit qualifying young person route with no education condition UC Regs 2013 reg. 5(1)(a) and SPC Regs 2002 reg. 4A(1)(a) make a person who has reached 16 a qualifying young person up to the 1 September following their 16th birthday, whatever their education. The model encoded only the education route, so a 16-year-old outside education was not a UC or PC qualifying young person. is_before_first_september_following_16th_birthday places that date from the date of birth and counts the route for a year that starts (6 April) before it: every annual age of 16, and a 17-year-old born from 1 September to 6 October. Both QYP variables OR it with the education route, under the same age band and own-right benefits exclusion. Tests: YAML cases hand-computed from the regulations (flags, date edges, UC child element and work allowance, PC child addition), the property-test oracle, a calendar differential over every birth date in 2008-2011 and across years, and a grid test of sufficiency, monotonicity, exclusion and UC/PC agreement. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-pc-qyp-sixteen-route.fixed.md | 1 + .../is_uc_work_allowance_eligible.yaml | 22 +- ...fying_young_person_no_education_route.yaml | 268 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 137 ++++++++- ..._young_person_for_uc_or_housing_benefit.py | 11 +- ...lifying_young_person_for_pension_credit.py | 32 ++- ...fying_young_person_for_universal_credit.py | 29 +- ...first_september_following_16th_birthday.py | 45 +++ 8 files changed, 523 insertions(+), 22 deletions(-) create mode 100644 changelog.d/uc-pc-qyp-sixteen-route.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml create mode 100644 policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py diff --git a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md new file mode 100644 index 0000000000..7c8723f135 --- /dev/null +++ b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md @@ -0,0 +1 @@ +- Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml index 4710360580..362a550cd7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml @@ -79,7 +79,7 @@ output: is_uc_work_allowance_eligible: false -- name: Case 6, a nonqualifying 16-year-old does not create a work allowance. +- name: Case 6, a 16-year-old outside education past the reg. 5(1)(a) route does not create a work allowance. period: 2025 input: people: @@ -91,6 +91,7 @@ age: 16 is_in_non_advanced_education: false is_in_approved_training: false + is_before_first_september_following_16th_birthday: false uc_limited_capability_for_WRA: false benunits: benunit: @@ -98,6 +99,25 @@ output: is_uc_work_allowance_eligible: false +- name: Case 6a, a 16-year-old outside education in the reg. 5(1)(a) route creates a work allowance. + period: 2025 + input: + people: + person1: + age: 40 + is_parent: true + uc_limited_capability_for_WRA: false + person2: + age: 16 + is_in_non_advanced_education: false + is_in_approved_training: false + uc_limited_capability_for_WRA: false + benunits: + benunit: + members: [person1, person2] + output: + is_uc_work_allowance_eligible: true + - name: Case 7, a looked-after child does not create a work allowance. period: 2025 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml new file mode 100644 index 0000000000..8a0ebb1c25 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml @@ -0,0 +1,268 @@ +# UC Regs 2013 reg. 5(1)(a) and SPC Regs 2002 reg. 4A(1)(a): a person who has +# reached 16 is a qualifying young person "up to, but not including, the 1st +# September following" their 16th birthday, whatever their education. The +# model counts the route for a year that starts (6 April) before that date. +# Ages are ages on 6 October; with no date of birth, a whole age places the +# birthday on 6 April (months_since_last_birthday 6). + +- name: A 16-year-old outside education is a qualifying young person for UC and Pension Credit. + # 16th birthday 6 April 2026; the route runs to 31 August 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_in_non_advanced_education: [false, false] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + is_child_or_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16-year-old apprentice qualifies through reg. 5(1)(a), which has no contract-of-employment exclusion. + # Reg. 5(3) excludes training under a contract of employment from the + # education route (b) only. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + benunits: + benunit: + members: [parent, young_person] + output: + is_in_non_advanced_education: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16-year-old outside education whose route has ended for the year is not a qualifying young person. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_first_september_following_16th_birthday: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + +- name: The education route still applies after the 1 September following the 16th birthday. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: UPPER_SECONDARY + is_before_first_september_following_16th_birthday: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 17-year-old with a 16th birthday on 15 September 2025 is in the route until 31 August 2026. + # Born 15 September 2009: aged 17 on 6 October 2026. The 1 September + # following the 16th birthday is 1 September 2026, after 6 April 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16th birthday on 1 September runs the route to the 17th birthday. + # Born 1 September 2009: the 1 September following the 16th birthday (1 + # September 2025) is 1 September 2026, after 6 April 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090901 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: A 16th birthday on 31 August ends the route that day. + # Born 31 August 2009: the route ran only on 31 August 2025, before the + # 2026-27 fiscal year starts. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: A 16-year-old receiving Universal Credit in their own right is not a qualifying young person. + # Reg. 5(5) and reg. 4A(5) apply to both routes. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: A looked-after 16-year-old is a qualifying young person but not the family's. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + is_looked_after_by_local_authority: true + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + +- name: Neither 15- nor 18-year-olds outside education are qualifying young persons. + # A 15-year-old is a child (Welfare Reform Act 2012 s. 40); an 18-year-old's + # 1 September following their 16th birthday has passed. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + child: + age: 15 + current_education: NOT_IN_EDUCATION + adult_child: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, adult_child] + output: + is_before_first_september_following_16th_birthday: [false, true, false] + is_qualifying_young_person_for_universal_credit: [false, false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true, false] + is_qualifying_young_person_for_pension_credit: [false, false, false] + +- name: A lone parent with a 16-year-old outside education gets the child element and a work allowance. + # 2026-27: born 6 April 2010, before 6 April 2017, so the higher first-child + # amount: GBP 351.88 a month x 12 = GBP 4,222.56. Responsibility for a + # qualifying young person gives the work allowance (reg. 22(1)(b)): no + # housing element, so GBP 710 a month x 12 = GBP 8,520. Maximum amount: the + # single 25-or-over standard allowance GBP 424.90 x 12 = GBP 5,098.80, plus + # the child element, = GBP 9,321.36. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + output: + uc_individual_child_element: [0, 4_222.56] + uc_child_element: 4_222.56 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + is_uc_work_allowance_eligible: true + uc_work_allowance: 8_520 + uc_maximum_amount: 9_321.36 + is_benefit_cap_single_claimant_rate: false + +- name: A pensioner with a 16-year-old outside education gets the Pension Credit child addition. + # 2026-27: born 6 April 2010, before 6 April 2017, so the first child + # addition (Sch. IIA para. 10): GBP 81.07 a week x 52 = GBP 4,215.64. The + # single standard minimum guarantee is GBP 238.00 x 52 = GBP 12,376, so the + # minimum guarantee is GBP 16,591.64. + period: 2026 + input: + people: + pensioner: + age: 70 + is_SP_age: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [pensioner, young_person] + households: + household: + members: [pensioner, young_person] + output: + child_minimum_guarantee_addition: 4_215.64 + standard_minimum_guarantee: 12_376 + minimum_guarantee: 16_591.64 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 8c947e0471..f99951f8ec 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -36,12 +36,21 @@ 12-year-old explains the flag and makes them a couple. Before/after families share one Simulation to avoid repeated model construction, but occupy distinct households and benefit units. +5. UC reg 5(1)(a) and SPC reg 4A(1)(a): the year counts in the route when it + starts (6 April) before the 1 September following the 16th birthday, for + every date of birth checked against calendar arithmetic. Within ages 16 to + 19 and without own-right benefits, that route alone makes a UC or PC + qualifying young person whatever the education inputs; without it the + education route is unchanged. Own-right benefits exclude both routes. UC + and PC agree below 19, where only UC's terminal date differs. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment contracts, approved providers and interruptions are unobserved. CB/CTC leaver -extensions are omitted, as is UC/PC's unconditional age-16-to-next-September -route. UC's terminal date is an input; PC's September terminal date is absent. +extensions are omitted. UC/PC's route to the 1 September after the 16th +birthday, which has no education condition, counts for a year that starts +before that date (invariant 5). UC's terminal date is an input; PC's September +terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -55,11 +64,12 @@ the independent scalar references below. No network or survey data is used. """ +from datetime import date, timedelta from itertools import product from unittest.mock import patch import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -175,8 +185,17 @@ def statutory_child(age): return age < 16 +def in_route_to_first_september_after_16th_birthday(p): + # UC reg 5(1)(a) and SPC reg 4A(1)(a) have no education condition. With no + # date of birth, a whole age on 6 October places the birthday on 6 April, + # so the year (from 6 April) starts before the 1 September following the + # 16th birthday exactly when that age is under 17. + return p.get("is_before_first_september_following_16th_birthday", p["age"] < 17) + + def qualifying_young_person(p, programme): - # Education/training routes only (see omissions in module docstring): + # CB and CTC: education/training routes only (see omissions in module + # docstring). UC and PC: also the route with no education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 @@ -184,6 +203,11 @@ def qualifying_young_person(p, programme): # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: return False + if programme in { + "universal_credit", + "pension_credit", + } and in_route_to_first_september_after_16th_birthday(p): + return True if not (non_advanced_education(p) or p["is_in_approved_training"]): return False if ( @@ -897,3 +921,108 @@ def test_frs_shaped_units_keep_the_under_20_presumption(families): ] assert_values(sim, "is_claimant_or_partner", expected) assert expected == [c for f in families for c in claimants_or_partners(f)] + + +ROUTE = "is_before_first_september_following_16th_birthday" + + +def first_september_following_16th_birthday(born): + # Calendar arithmetic, independent of the model's month grid: a 16th + # birthday before 1 September is followed by 1 September of the same year, + # and one on or after it by the next year's. A 29 February birthday is + # before 1 September whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 16 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_route_matches_calendar(births, year): + families = [ + [person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}"))] + for born in births + ] + # The fiscal year starts on 6 April. + expected = [ + date(year, 4, 6) < first_september_following_16th_birthday(born) + for born in births + ] + assert_values(simulate(families, year), ROUTE, expected, year) + + +def test_route_year_starts_before_first_september_for_every_birthday(): + # Every date of birth from 2008 to 2011: ages 14 to 18 on 6 October 2026, + # including the 31 August / 1 September and 6 April / 6 October edges. + start, end = date(2008, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + assert_route_matches_calendar(births, YEAR) + + +@PROPERTY_SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_route_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_route_matches_calendar(births, year) + + +def test_route_alone_qualifies_and_leaves_the_education_route_unchanged(): + # Ages 14 to 21, every education value, own-right benefits, approved + # training, apprenticeship and looked-after status, with the route input + # off then on (innermost, so each subject's two rows are adjacent). + rows = list( + product( + range(14, 22), + EDUCATIONS, + product((False, True), repeat=4), + (False, True), + ) + ) + families = [ + [ + person(40, is_parent=True, is_benunit_head=True, **{ROUTE: False}), + person( + age, + current_education=education, + receives_benefits_in_own_right=own_benefits, + is_in_approved_training=training, + is_apprentice=apprentice, + is_looked_after_by_local_authority=looked_after, + **{ROUTE: route}, + ), + ] + for age, education, flags, route in rows + for own_benefits, training, apprentice, looked_after in [flags] + ] + # The oracle: the route, else the education route as before. + assert_definitions(families) + sim = simulate(families) + age = np.array([row[0] for row in rows[::2]]) + own_benefits = np.array([row[2][0] for row in rows[::2]]) + qyp = { + programme: sim.calculate(f"is_qualifying_young_person_for_{programme}", YEAR)[ + 1::2 + ].reshape(-1, 2) + for programme in ("universal_credit", "pension_credit") + } + for programme, status in qyp.items(): + # Switching the route on never removes the status. + assert (status[:, 1] >= status[:, 0]).all(), programme + # With the route on, ages 16 to 19 without own-right benefits qualify, + # whatever the education inputs, and nobody else does. + np.testing.assert_array_equal( + status[:, 1], (16 <= age) & (age < 20) & ~own_benefits, err_msg=programme + ) + # Own-right benefits exclude both routes. + assert not status[own_benefits].any(), programme + # Below 19 the two programmes' definitions are the same. + below_19 = age < 19 + np.testing.assert_array_equal( + qyp["universal_credit"][below_19], qyp["pension_credit"][below_19] + ) diff --git a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py index 2897b52f3a..4a3eba2ffc 100644 --- a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py +++ b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py @@ -11,11 +11,12 @@ class is_responsible_for_child_or_young_person_for_uc_or_housing_benefit(Variabl The family rates apply if either scheme's test is met, so neither scheme's families are moved onto the single rate by the other's narrower definition. - UC reg 5(1)(a) also makes every 16-year-old a qualifying young person until - the 1 September after their 16th birthday, whatever their education. Annual - ages cannot place that date, so a 16-year-old in the benefit unit who is not - a claimant or partner counts here; the UC child element does not yet model - reg 5(1)(a). + The UC test includes reg 5(1)(a), which makes a 16-year-old a qualifying + young person until the 1 September after their 16th birthday, whatever + their education. Housing Benefit's young person follows Child Benefit, + whose school-leaver rules (Child Benefit (General) Regulations 2006 regs 4, + 5 and 7) are not modelled, so a 16-year-old in the benefit unit who is not + a claimant or partner also counts here for the year. """ value_type = bool diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index ee1d028e8d..6fc28d3f3a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -5,22 +5,40 @@ class is_qualifying_young_person_for_pension_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Pension Credit" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " + "non-advanced education or approved training that meets the entry " + "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " + "benefits in their own right (reg. 4A(5)). The 1 September after the " + "19th birthday in reg. 4A(1)(b) is not modelled, and the own-right " + "benefits input covers Universal Credit, Employment and Support " + "Allowance and Jobseeker's Allowance but not Income Support." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" def formula(person, period, parameters): + # Reg. 4A(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 4A(1)(b), (2) and (3). + in_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", + period, + ) & person( + "meets_qualifying_young_person_entry_condition_for_pension_credit", + period, + ) return ( person( "meets_qualifying_young_person_age_condition_for_pension_credit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_pension_credit", - period, + & ( + before_first_september_following_16th_birthday + | in_education_or_training ) & ~person("receives_benefits_in_own_right", period) ) 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 2b1608d539..1b1cfd602d 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 @@ -5,16 +5,25 @@ class is_qualifying_young_person_for_universal_credit(Variable): value_type = bool 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))." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/5" def formula(person, period, parameters): - return ( + # 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_age_condition_for_universal_credit", - period, - ) - & person( "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", period, ) @@ -26,5 +35,15 @@ def formula(person, period, parameters): "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", period, ) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_universal_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) & ~person("receives_benefits_in_own_right", period) ) diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py new file mode 100644 index 0000000000..1b20640745 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_16th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 16th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 16th birthday, so that some of the year falls " + "in the qualifying young person route that has no education condition " + "(UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)). The date " + "comes from date_of_birth, or from age and months_since_last_birthday. " + "A 16th birthday on 31 August ends the route that day, and one on 1 " + "September runs it to the 17th birthday. The route ends on a fixed 1 " + "September that annual periods cannot split, so a person in it for " + "part of the year counts for the year, as the benefit cap and LHA " + "family rules count a 16-year-old member. Everyone aged 16 on 6 " + "October qualifies, as does a 17-year-old born from 1 September to 6 " + "October. With birthdays spread evenly, a 16-year-old outside " + "education is a child or in the route for 46% of the year on average, " + "and a 17-year-old for 4%. Set this to false for a year that the " + "route does not reach." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 16th birthday falls in the birth year plus 16; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 16 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year From 505ac7e28d238efc77bed16003ab1e6e449b82cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 19:57:23 -0400 Subject: [PATCH 02/26] Add the Child Benefit qualifying young person conditions for school leavers Child Benefit (General) Regulations 2006 regs 4, 5 and 7 were not modelled: is_qualifying_young_person_for_child_benefit was reg. 3 (education or training) alone, so a 16-year-old who had left school was not a qualifying young person for Child Benefit, nor a young person for Housing Benefit, the legacy benefits or Council Tax Reduction (SSCBA 1992 s.142). - Reg. 4: until the 31 August after the 16th birthday, read from is_before_first_september_following_16th_birthday. - Reg. 5: the 20-week extension period for a 16- or 17-year-old not in remunerative work, from the new input is_in_child_benefit_extension_period. - Reg. 7: until the terminal date after leaving, from the new input is_before_child_benefit_terminal_date. - Reg. 7(3): no Child Benefit for a young person who qualifies through reg. 7 alone and works 24 hours or more a week (is_child_benefit_payable_for_person); they stay a qualifying young person. Co-Authored-By: Claude Opus 5.5 --- .../cb-qyp-school-leaver-routes.fixed.md | 1 + .../extension_period_age_limit.yaml | 9 + .../eligibility/remunerative_work_hours.yaml | 9 + .../child_benefit_school_leaver_routes.yaml | 382 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 32 +- ...benefit_school_leaver_routes_properties.py | 327 +++++++++++++++ .../gov/hmrc/child_benefit_child_index.py | 2 +- .../hmrc/child_benefit_respective_amount.py | 2 +- .../is_before_child_benefit_terminal_date.py | 20 + .../is_child_benefit_payable_for_person.py | 55 +++ .../is_in_child_benefit_extension_period.py | 23 ++ ..._in_remunerative_work_for_child_benefit.py | 18 + ...alifying_young_person_for_child_benefit.py | 58 ++- ...sion_period_condition_for_child_benefit.py | 24 ++ 14 files changed, 940 insertions(+), 22 deletions(-) create mode 100644 changelog.d/cb-qyp-school-leaver-routes.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml create mode 100644 policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py create mode 100644 policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..ae0e08f434 --- /dev/null +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..54c952537e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Benefit qualifying young person during an extension period. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Benefit extension period age limit + reference: + - title: Child Benefit (General) Regulations 2006 reg. 5(1) + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/5 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..e6271ff838 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Benefit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Benefit remunerative work hours + reference: + - title: Child Benefit (General) Regulations 2006 reg. 1(3), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/1 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml new file mode 100644 index 0000000000..8a32748910 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -0,0 +1,382 @@ +# The Child Benefit qualifying young person conditions for a young person who +# has left education: Child Benefit (General) Regulations 2006 reg. 4 (until +# the 31 August after the 16th birthday), reg. 5 (the 20-week extension +# period for a 16- or 17-year-old), reg. 7 (until the terminal date) and +# reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative +# work). Reg. 2(2): a qualifying young person until the last applicable +# condition ceases to be met. Reg. 8: not while receiving a benefit in their +# own right. +# +# Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for +# each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the +# other legacy schemes take their young person from the same definition +# (SSCBA 1992 s.142; HB Regs 2006 reg. 19). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + # 27.05 * 52. + child_benefit_entitlement: 1_406.60 + # The legacy schemes' young person follows Child Benefit. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 2, a 16-year-old apprentice is a qualifying young person under reg. 4, which has no education condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # Reg. 7(3) bars only Child Benefit payable through reg. 7. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 3, born 15 September 2009, the 31 August after the 16th birthday is in 2026-27 (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 4, born 31 August 2009, reg. 4 ended on 1 September 2025, before the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + is_lone_parent: false + +- name: Case 5, a 17-year-old and an 18-year-old who have left education are not qualifying young persons by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + seventeen: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + eighteen: + age: 18 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, seventeen, eighteen] + output: + is_qualifying_young_person_for_child_benefit: [false, false, false] + child_benefit_entitlement: 0 + +- name: Case 6, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 8). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 7, a 17-year-old in an extension period is a qualifying young person (reg. 5). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 8, the extension period needs the young person not to be in remunerative work (reg. 5(2)(c)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 9, the extension period is for under-18s only (reg. 5(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 10, an 18-year-old before the terminal date is a qualifying young person (reg. 7). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 11, in remunerative work a young person before the terminal date stays a qualifying young person but brings no Child Benefit (reg. 7(3)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, false] + child_benefit_entitlement: 0 + # Still a young person for Housing Benefit and the other legacy schemes. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + # Reg. 4 also applies at 16, and has no remunerative work bar. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 13, a working school leaver before the terminal date does not take the eldest-child rate from a younger child. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 30 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver, child] + output: + child_benefit_child_index: [-1, -1, 1] + # One child at the eldest rate: 27.05 * 52. + child_benefit_entitlement: 1_406.60 + +- name: Case 14, the terminal date condition ends at 20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 20 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 15, a 16-year-old at school and a 16-year-old who has left are both qualifying young persons, so two rates. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + at_school: + age: 16 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, at_school, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true, true] + # (27.05 + 17.90) * 52. + child_benefit_entitlement: 2_337.40 + +- name: Case 16, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_benefit: true + is_child_or_qualifying_young_person_for_child_benefit: false + child_benefit_entitlement: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index fcf9c8f9f4..2c62d983bc 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -44,15 +44,20 @@ 19 and without own-right benefits, that route alone makes a UC or PC qualifying young person whatever the education inputs; without it the education route is unchanged. Own-right benefits exclude both routes. UC - and PC agree below 19, where only UC's terminal date differs. + and PC agree below 19, where only UC's terminal date differs. Child + Benefit reg 4 (until the 31 August after the 16th birthday) counts for + the same years. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. CB/CTC leaver -extensions are omitted. UC/PC's route to the 1 September after the 16th -birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). UC's terminal date is an input; PC's September -terminal date is absent. +contracts, approved providers and interruptions are unobserved. The CTC +leaver extensions are omitted; Child Benefit's extension period and terminal +date (regs 5 and 7) are inputs, false here, with their own tests in +test_child_benefit_school_leaver_routes_properties.py. The route to the 1 +September after the 16th birthday (UC, PC) or the 31 August before it (Child +Benefit reg 4), which has no education condition, counts for a year that +starts before that date (invariant 5). UC's terminal date is an input; PC's +September terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -188,17 +193,19 @@ def statutory_child(age): def in_route_to_first_september_after_16th_birthday(p): - # UC reg 5(1)(a) and SPC reg 4A(1)(a) have no education condition. With no - # date of birth, a whole age on 6 October places the birthday on 6 April, - # so the year (from 6 April) starts before the 1 September following the - # 16th birthday exactly when that age is under 17. + # UC reg 5(1)(a), SPC reg 4A(1)(a) and Child Benefit reg 4 have no + # education condition. With no date of birth, a whole age on 6 October + # places the birthday on 6 April, so the year (from 6 April) starts before + # the 1 September following the 16th birthday exactly when that age is + # under 17. Child Benefit's 31 August is the day before. return p.get("is_before_first_september_following_16th_birthday", p["age"] < 17) def qualifying_young_person(p, programme): - # CB and CTC: education/training routes only (see omissions in module - # docstring). UC and PC: also the route with no education condition. + # CTC: education/training routes only (see omissions in module + # docstring). UC, PC and CB: also the route with no education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 @@ -208,6 +215,7 @@ def qualifying_young_person(p, programme): if programme in { "universal_credit", "pension_credit", + "child_benefit", } and in_route_to_first_september_after_16th_birthday(p): return True if not (non_advanced_education(p) or p["is_in_approved_training"]): diff --git a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..a29e48450f --- /dev/null +++ b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py @@ -0,0 +1,327 @@ +"""Child Benefit qualifying young persons who have left education. + +Child Benefit (General) Regulations 2006: a person aged 16 to 19 is a +qualifying young person while any of regs 3 to 7 applies (reg. 2(2)) and +they are not receiving a benefit in their own right (reg. 8). + +- Reg. 3: non-advanced education or approved training, begun before 19. +- Reg. 4: under 17 and left education, until the 31 August next following + the 16th birthday (the 1 September for a 16th birthday on 31 August). +- Reg. 5: under 18, in an extension period, not in remunerative work. +- Reg. 7: until the terminal date after leaving; reg. 7(3) pays no Child + Benefit "by virtue of this regulation" in a week of remunerative work (24 + hours or more, reg. 1(3)). + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education is a qualifying young person for a year + exactly when they are 16 to 19 on 6 October and the year starts (6 April) + on or before the reg. 4 end date, for every date of birth. +2. Over every combination of age, education, training, own-right benefits, + the three leaver facts and hours: the status equals the union of the four + conditions within ages 16 to 19 and without own-right benefits. So each + condition ends at its age limit, own-right benefits exclude every + condition, and with the three leaver facts off the status is reg. 3's + alone, as before. +3. Switching a leaver fact on never removes the status. +4. Child Benefit is payable for the benefit unit's child or qualifying young + person unless they qualify through reg. 7 alone and are in remunerative + work. They stay a qualifying young person, and a legacy young person. +5. Child Benefit entitlement is the eldest payable member at the eldest + rate and each other at the additional rate, for 52 weeks. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, 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, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_benefit_extension_period" +TERMINAL = "is_before_child_benefit_terminal_date" +QYP = "is_qualifying_young_person_for_child_benefit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + TERMINAL: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(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, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def regulation_4_end_date(born): + # Reg. 4(2): "the 31st August next following the person's 16th birthday"; + # reg. 4(3): for a 16th birthday on 31 August, "the 1st September + # immediately following". A 29 February birthday is before 31 August + # whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 9, 1) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + # "Has not passed": the year, from 6 April, reaches the period if it + # starts on or before the end date. + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= regulation_4_end_date(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2006 to 2011: ages 14 to 20 on 6 October 2026, + # with the 31 August and 1 September edges in each year. + start, end = date(2006, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + # Not vacuous, and the edges fall where the regulation puts them. + by_birth = dict(zip(births, expected)) + assert by_birth[date(2010, 6, 1)] and not by_birth[date(2009, 6, 1)] + # 16 on 31 August 2025: the period ended on 1 September 2025. + assert not by_birth[date(2009, 8, 31)] + # 16 on 1 September 2025: the period runs to 31 August 2026. + assert by_birth[date(2009, 9, 1)] + # 16 on 6 October 2026 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2010, 10, 6)] and not by_birth[date(2010, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def regulation_3(p): + # Education status stands in for full-time non-advanced education. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + studying = ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return studying and begun_before_19 + + +def remunerative_work(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/1 + return p["weekly_hours"] >= 24 + + +def regulation_5(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/5 + return p["age"] < 18 and p[EXTENSION] and not remunerative_work(p) + + +def qualifying_young_person(p): + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + return regulation_3(p) or p[ROUTE] or regulation_5(p) or p[TERMINAL] + + +def payable(p): + # Reg. 7(3) bars Child Benefit payable through reg. 7 only. + if p["is_claimant_or_partner"]: + return False + if p["age"] < 16: + return True + if not qualifying_young_person(p): + return False + through_another = regulation_3(p) or p[ROUTE] or regulation_5(p) + return through_another or not remunerative_work(p) + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension, TERMINAL: terminal}, + ) + for age, education, training, own_right, hours in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (0, 23, 24) + ) + for route, extension, terminal in product((False, True), repeat=3) +] + + +def test_status_is_the_union_of_the_four_conditions(): + # 8 ages * every education value * 2 * 2 * 3 hours, each with the 8 + # combinations of the three leaver facts (adjacent rows). + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[ + np.array([p["receives_benefits_in_own_right"] for p in GRID]) + ].any() + assert status.any() + # With the leaver facts off, reg. 3 alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION] or p[TERMINAL]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and regulation_3(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: within each block of 8, a row with more facts on never + # loses the status. Rows are ordered (route, extension, terminal). + blocks = status.reshape(-1, 8) + for less, more in ( + (0, 1), + (0, 2), + (0, 4), + (1, 3), + (1, 5), + (2, 3), + (2, 6), + (4, 5), + (4, 6), + (3, 7), + (5, 7), + (6, 7), + ): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4. + np.testing.assert_array_equal( + sim.calculate("is_child_benefit_payable_for_person", YEAR)[1::2], + [payable(p) for p in GRID], + ) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_benefit", YEAR + )[1::2] + legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR)[1::2] + np.testing.assert_array_equal(family, (age < 16) | status) + np.testing.assert_array_equal(legacy, family) + barred = family & ~np.array([payable(p) for p in GRID]) + # Not vacuous: some young persons are barred, all of them 16 or over, + # before the terminal date and working 24 hours. + assert barred.any() + assert all( + p[TERMINAL] and p["weekly_hours"] == 24 and p["age"] >= 16 + for p, b in zip(GRID, barred) + if b + ) + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2024, 2026])) +def test_child_benefit_entitlement_counts_payable_members(units, year): + # Invariant 5, from the inputs and the weekly rates alone. + sim = simulate(units, year) + rates = sim.tax_benefit_system.parameters(f"{year}-06-01").gov.hmrc.child_benefit + entitlement = sim.calculate("child_benefit_entitlement", year) + for unit, actual in zip(units, entitlement): + number = sum(payable(p) for p in unit) + weekly = ( + 0 + if number == 0 + else rates.amount.eldest + (number - 1) * rates.amount.additional + ) + np.testing.assert_allclose(actual, weekly * 52, rtol=1e-6) diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py index fbf470039c..785e042a10 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py @@ -8,7 +8,7 @@ class child_benefit_child_index(Variable): definition_period = YEAR def formula(person, period, parameters): - child = person("is_child_or_qualifying_young_person_for_child_benefit", period) + child = person("is_child_benefit_payable_for_person", period) rank = ( person.get_rank( person.benunit, diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py index e8e66493c9..3f5b702adc 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py @@ -12,7 +12,7 @@ class child_benefit_respective_amount(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/part/IX", "https://www.legislation.gov.uk/uksi/2006/965/regulation/2", ) - defined_for = "is_child_or_qualifying_young_person_for_child_benefit" + defined_for = "is_child_benefit_payable_for_person" def formula(person, period, parameters): eligible = True diff --git a/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py new file mode 100644 index 0000000000..64f48ee6e7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_before_child_benefit_terminal_date(Variable): + value_type = bool + entity = Person + label = "Before the Child Benefit terminal date after leaving education" + documentation = ( + "Whether, for some of the year, the person has left relevant " + "education or approved training and the week that includes their " + "terminal date has not ended. The terminal date is the last day of " + "February, May, August or November, whichever comes first after they " + "left, or after their last external examination if they were entered " + "for one (Child Benefit (General) Regulations 2006 reg. 7(2), Cases 1 " + "and 2). The model holds one education status for the year and no " + "leaving date, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/7" diff --git a/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py new file mode 100644 index 0000000000..8114bf40e2 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class is_child_benefit_payable_for_person(Variable): + value_type = bool + entity = Person + label = "Child Benefit is payable for this person" + documentation = ( + "Whether the benefit unit's Child Benefit includes an amount for this " + "person: they are its child or qualifying young person, and Child " + "Benefit is not barred by remunerative work. Child Benefit is not " + "payable for a qualifying young person 'by virtue of' the terminal " + "date rule for any week they are in remunerative work (Child Benefit " + "(General) Regulations 2006 reg. 7(3)). So a young person who " + "qualifies only through the terminal date, and works 24 hours or " + "more a week, stays a qualifying young person (and a Housing Benefit " + "young person) but brings no Child Benefit. One who also meets " + "another condition is paid through that one (reg. 2(2))." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + ) + + def formula(person, period, parameters): + another_condition = ( + ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + ) + | person("is_before_first_september_following_16th_birthday", period) + | person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + ) + only_through_terminal_date = ( + person("is_qualifying_young_person_for_child_benefit", period) + & ~person("is_child_for_child_benefit", period) + & ~another_condition + ) + barred = only_through_terminal_date & person( + "is_in_remunerative_work_for_child_benefit", period + ) + return ( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~barred + ) diff --git a/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py new file mode 100644 index 0000000000..beb5ed3938 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_in_child_benefit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Benefit extension period" + documentation = ( + "Whether the person is in a Child Benefit extension period in the " + "year: they have ceased education or training, are registered for " + "work, education or training with a qualifying body (the Careers " + "Service, Connexions or the Ministry of Defence), the person " + "responsible for them was entitled to Child Benefit for them " + "immediately before and asked for the extension within three months, " + "and the 20 weeks from the week after they left have not run out " + "(Child Benefit (General) Regulations 2006 reg. 5(2)(a), (b), (d), " + "(e) and (f), and (3)). The model applies the age limit (reg. 5(1)) " + "and the bar on remunerative work (reg. 5(2)(c)). Surveys do not " + "record registration or the request, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" diff --git a/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py new file mode 100644 index 0000000000..10a05ee191 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_benefit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Benefit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Benefit (General) Regulations 2006 " + "reg. 1(3)), read from average weekly hours over the year." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/1" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 898a91b7cd..03a6cdd3ce 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -5,25 +5,67 @@ class is_qualifying_young_person_for_child_benefit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Benefit" + documentation = ( + "A person aged 16 to 19 who meets any of the conditions in Child " + "Benefit (General) Regulations 2006 regs 3 to 7 (reg. 2(2): a " + "qualifying young person until the last of them ceases to be met) " + "and is not receiving a benefit in their own right (reg. 8). The " + "conditions modelled are: non-advanced education or approved " + "training, begun before 19 (reg. 3); the period to the 31 August " + "after the 16th birthday, whatever their education (reg. 4); an " + "extension period for a 16- or 17-year-old (reg. 5); and the period " + "to the terminal date after leaving education (reg. 7). Reg. 4 " + "reads is_before_first_september_following_16th_birthday: its 31 " + "August (1 September for a 16th birthday on 31 August) falls in the " + "same fiscal year as Universal Credit's 1 September, so a year that " + "starts before one starts before the other. Interruptions (reg. 6) " + "are not modelled. The own-right benefits input covers Universal " + "Credit, Employment and Support Allowance and Jobseeker's Allowance " + "but not the Income Support, incapacity benefit and tax credits that " + "reg. 8 also lists." + ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/ukpga/1992/4/section/142", - "https://www.legislation.gov.uk/uksi/2006/223", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/4", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", ] def formula(person, period, parameters): + # Reg. 3: education or training, begun before 19. + in_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + # Reg. 4: until the 31 August after the 16th birthday. A person in + # that period is under 17, as reg. 4(1) requires. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5: the extension period. + in_extension_period = person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + # Reg. 7: until the terminal date after leaving. + before_terminal_date = person("is_before_child_benefit_terminal_date", period) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_benefit", - period, + & ( + in_education_or_training + | before_31_august_following_16th_birthday + | in_extension_period + | before_terminal_date ) & ~person("receives_benefits_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py new file mode 100644 index 0000000000..592e0189f7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_extension_period_condition_for_child_benefit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person extension period condition for Child Benefit" + documentation = ( + "A person under 18 who has left education or training, is registered " + "with a qualifying body and is not in remunerative work, for the 20 " + "weeks of the extension period their parent or carer asked for." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return ( + (person("age", period) < p.extension_period_age_limit) + & person("is_in_child_benefit_extension_period", period) + & ~person("is_in_remunerative_work_for_child_benefit", period) + ) From 9b3b82aa9c101f73e85c7558b8a0a148b2c1cb80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:08:53 -0400 Subject: [PATCH 03/26] Add the Child Tax Credit qualifying young person rules for school leavers Child Tax Credit Regulations 2002 reg. 4 makes a person a qualifying young person from their 16th birthday until the 31 August that next follows, with no education condition, and reg. 5(3)(b) adds the 20 weeks after leaving for an under-18 registered for work or training. The model had only the education condition (reg. 5(3)(a), (ab)). - Reg. 4 reads is_before_first_september_following_16th_birthday. - Reg. 5(3)(b) reads the new input is_in_child_tax_credit_extension_period. - Reg. 5(4)(a): a young person who has left education or training and works 24 hours a week or more is not a qualifying young person through either. Tax credits ended on 5 April 2025, so this changes years to 2024-25. Co-Authored-By: Claude Opus 5.5 --- .../ctc-qyp-school-leaver-routes.fixed.md | 1 + .../extension_period_age_limit.yaml | 9 + .../eligibility/remunerative_work_hours.yaml | 9 + ...child_tax_credit_school_leaver_routes.yaml | 309 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 39 ++- ..._credit_school_leaver_routes_properties.py | 298 +++++++++++++++++ ...is_in_child_tax_credit_extension_period.py | 21 ++ ..._remunerative_work_for_child_tax_credit.py | 20 ++ ...fying_young_person_for_child_tax_credit.py | 63 +++- 9 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 changelog.d/ctc-qyp-school-leaver-routes.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml create mode 100644 policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py create mode 100644 policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..33a0ea5ad5 --- /dev/null +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)). diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..2266a1fe0c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Tax Credit qualifying young person for 20 weeks while registered for work or training. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Tax Credit extension period age limit + reference: + - title: The Child Tax Credit Regulations 2002 reg. 5(3)(b) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..dc614090d1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Tax Credit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Tax Credit remunerative work hours + reference: + - title: The Child Tax Credit Regulations 2002 reg. 2(1), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/2 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml new file mode 100644 index 0000000000..fe113d9ad4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -0,0 +1,309 @@ +# Child Tax Credit qualifying young persons who have left education: Child +# Tax Credit Regulations 2002 reg. 4 (from the 16th birthday until the 31 +# August that next follows, with no education condition), reg. 5(3)(b) (20 +# weeks after leaving, under 18, while registered for work or training) and +# the second condition in reg. 5(4), which applies to both: not in +# remunerative work (24 hours a week or more, reg. 2(1)) after leaving, and +# not receiving a benefit in their own right. +# +# The child element was £3,455 in 2024-25, the last year of tax credits +# (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] + is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit: true + CTC_child_element: 3_455 + +- name: Case 2, a 16-year-old who has left education and works 24 hours a week is not a qualifying young person (reg. 5(4)(a)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_tax_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + # Child Benefit reg. 4 has no remunerative work bar. + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 3, working 23 hours a week does not end the period. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 4, remunerative work does not matter for a 16-year-old still at school. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pupil: + age: 16 + current_education: UPPER_SECONDARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, pupil] + output: + # Reg. 5(4)(a) applies to a person "having ceased" education or training. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, a 16-year-old apprentice working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + # Education "by virtue of his employment" is not the first condition + # (reg. 5(3)(a)(ii)), so the apprentice has left it and is in + # remunerative work. + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 6, born 15 September 2007, the period runs to 31 August 2024. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 7, born 31 August 2007, reg. 4(1A) gives no period to the next 31 August. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 8, born 31 August 2008, the year in which they are a child until 30 August counts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + date_of_birth: 20080831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 9, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 5(4)(c)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 10, a 17-year-old who has left education is not a qualifying young person by default. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 11, a 17-year-old in the 20 weeks after leaving, registered for work or training, is a qualifying young person (reg. 5(3)(b)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 12, the 20 weeks end if the 17-year-old starts remunerative work. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 13, the 20 weeks are for under-18s only. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 14, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: true + is_child_or_qualifying_young_person_for_child_tax_credit: false + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 2c62d983bc..c3807f0b2e 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -45,19 +45,21 @@ qualifying young person whatever the education inputs; without it the education route is unchanged. Own-right benefits exclude both routes. UC and PC agree below 19, where only UC's terminal date differs. Child - Benefit reg 4 (until the 31 August after the 16th birthday) counts for - the same years. + Benefit reg 4 and CTC reg 4 (until the 31 August after the 16th birthday) + count for the same years. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. The CTC -leaver extensions are omitted; Child Benefit's extension period and terminal -date (regs 5 and 7) are inputs, false here, with their own tests in -test_child_benefit_school_leaver_routes_properties.py. The route to the 1 +contracts, approved providers and interruptions are unobserved. Child +Benefit's extension period and terminal date (regs 5 and 7) and CTC's 20 +weeks after leaving (reg 5(3)(b)) are inputs, false here, with their own +tests in test_child_benefit_school_leaver_routes_properties.py and +test_child_tax_credit_school_leaver_routes_properties.py. The route to the 1 September after the 16th birthday (UC, PC) or the 31 August before it (Child -Benefit reg 4), which has no education condition, counts for a year that -starts before that date (invariant 5). UC's terminal date is an input; PC's -September terminal date is absent. +Benefit reg 4, CTC reg 4), which has no education condition, counts for a +year that starts before that date (invariant 5). Nobody here is in +remunerative work, which ends CTC's route. UC's terminal date is an input; +PC's September terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -202,23 +204,24 @@ def in_route_to_first_september_after_16th_birthday(p): def qualifying_young_person(p, programme): - # CTC: education/training routes only (see omissions in module - # docstring). UC, PC and CB: also the route with no education condition. + # Every programme: the education/training route, and the route with no + # education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + # CTC at 16: https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: return False - if programme in { - "universal_credit", - "pension_credit", - "child_benefit", - } and in_route_to_first_september_after_16th_birthday(p): - return True - if not (non_advanced_education(p) or p["is_in_approved_training"]): + studying = non_advanced_education(p) or p["is_in_approved_training"] + if in_route_to_first_september_after_16th_birthday(p): + # CTC reg 4 lifts only the first condition, for a person who has left + # education; one still in it is tested as a student below. + if programme != "child_tax_credit" or not studying: + return True + if not studying: return False if ( p["age"] >= 19 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..fde32f0087 --- /dev/null +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -0,0 +1,298 @@ +"""Child Tax Credit qualifying young persons who have left education. + +Child Tax Credit Regulations 2002: + +- Reg. 5(3)(a), (ab), (3A): the first condition is full-time non-advanced + education or approved training, begun before 19. +- Reg. 4(1)-(2): from the 16th birthday until the 31 August that next + follows, the first condition is not required. Reg. 4(1A): a 16th birthday + on 31 August gets no such period. +- Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for + work or training satisfy the first condition. +- Reg. 5(4), the second condition, always applies: a person who has left + education or training must not be in remunerative work (24 hours a week or + more, reg. 2(1)), and no one may be receiving a benefit in their own right. + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education, not working, is a qualifying young person for + a year exactly when they are 16 to 19 on 6 October and some of the year, + from 6 April, falls on or before the last day they are a child or in the + reg. 4 period. For a 16th birthday on 31 August that day is 30 August. +2. Over every combination of age, education, training, own-right benefits, + the two leaver facts and hours: the status is the education condition, or + a leaver fact for a person who has left and is not in remunerative work, + within ages 16 to 19 and without own-right benefits. So remunerative work + ends both leaver routes and neither a student's status, the 20 weeks end + at 18, and with the leaver facts off the status is the education + condition's alone, as before. +3. Switching a leaver fact on never removes the status. +4. The benefit unit's child or qualifying young person is a child or + qualifying young person who is not its claimant or partner. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, 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, +) + +YEAR = 2024 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_tax_credit_extension_period" +QYP = "is_qualifying_young_person_for_child_tax_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(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, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def last_day_as_child_or_in_regulation_4_period(born): + # Reg. 4(1): "until 31st August which next follows" the 16th birthday. + # Reg. 4(1A): a 16th birthday on 31 August starts no such period, so the + # last day covered is the day before it, the last day under 16. + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 8, 30) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= last_day_as_child_or_in_regulation_4_period(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 14 to 20 on 6 October 2024. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + by_birth = dict(zip(births, expected)) + assert by_birth[date(2008, 6, 1)] and not by_birth[date(2007, 6, 1)] + # 16 on 31 August 2023: reg. 4(1A) gives no period to 31 August 2024. + assert not by_birth[date(2007, 8, 31)] + # 16 on 31 August 2024: a child until 30 August 2024, in the year. + assert by_birth[date(2008, 8, 31)] + # 16 on 1 September 2023: the period runs to 31 August 2024. + assert by_birth[date(2007, 9, 1)] + # 16 on 6 October 2024 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2008, 10, 6)] and not by_birth[date(2008, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def receiving_education_or_training(p): + # Education status stands in for full-time non-advanced education that is + # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def first_condition_through_education(p): + # Reg. 5(3A): begun before 19. + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return receiving_education_or_training(p) and begun_before_19 + + +def qualifying_young_person(p): + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if first_condition_through_education(p): + return True + left = not receiving_education_or_training(p) + leaver_fact = p[ROUTE] or (p["age"] < 18 and p[EXTENSION]) + # Reg. 2(1): remunerative work is 24 hours a week or more. + return left and leaver_fact and p["weekly_hours"] < 24 + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension}, + ) + for age, education, training, apprentice, own_right, hours in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (0, 23, 24), + ) + for route, extension in product((False, True), repeat=2) +] + + +def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + hours = np.array([p["weekly_hours"] for p in GRID]) + student = np.array([receiving_education_or_training(p) for p in GRID]) + own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[own_right].any() + # Remunerative work ends a leaver's status and not a student's. + assert not status[~student & (hours >= 24)].any() + assert status[student & (hours >= 24)].any() + # The 20 weeks alone do not reach an 18-year-old. + alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) + assert not status[alone & ~student & (age >= 18)].any() + assert status[alone & ~student & (age == 17)].any() + # With the leaver facts off, the education condition alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and first_condition_through_education(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: rows are ordered (route, extension) within blocks of 4. + blocks = status.reshape(-1, 4) + for less, more in ((0, 1), (0, 2), (1, 3), (2, 3)): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4, for members who are not the claimant. + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", YEAR + ) + np.testing.assert_array_equal(family[1::2], (age < 16) | status) + assert not family[0::2].any() + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + is_looked_after_by_local_authority=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2020, 2024])) +def test_family_members_in_any_benefit_unit(units, year): + # Invariants 2 and 4 on benefit units of any shape, where a claimant or + # partner can be a qualifying young person by status. + sim = simulate(units, year) + people = [p for unit in units for p in unit] + status = sim.calculate(QYP, year) + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in people]) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", year + ) + expected = [ + (p["age"] < 16 or qualifying_young_person(p)) + and not p["is_claimant_or_partner"] + and not p["is_looked_after_by_local_authority"] + for p in people + ] + np.testing.assert_array_equal(family, expected) diff --git a/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py new file mode 100644 index 0000000000..de6be75a66 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class is_in_child_tax_credit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Tax Credit extension period" + documentation = ( + "Whether, for some of the year, the person has ceased full-time " + "education or approved training, HMRC was notified within three " + "months that they are registered for work or training with a " + "qualifying body (the Careers Service, Connexions or the Ministry of " + "Defence), and no more than 20 weeks have passed since they left " + "(Child Tax Credit Regulations 2002 reg. 5(3)(b)(i)-(iii)). The model " + "applies the age limit (reg. 5(3)(b)) and the second condition (reg. " + "5(4)). Surveys do not record the registration, so this is false " + "unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" diff --git a/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py new file mode 100644 index 0000000000..5369423124 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_tax_credit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Tax Credit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Tax Credit Regulations 2002 reg. " + "2(1)), read from average weekly hours over the year. The Working " + "Tax Credit rules the definition refers to, for counting hours and " + "for excluded work, are not modelled." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/2" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2c5e98dd46..3fa5ac8721 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -5,22 +5,69 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Tax Credit" + documentation = ( + "A person aged 16 to 19 who meets the Child Tax Credit Regulations " + "2002's conditions. The first condition (reg. 5(3)) is full-time " + "non-advanced education or approved training, begun before 19, or, " + "under 18, the 20 weeks after leaving while registered for work or " + "training. Reg. 4(1) lifts the first condition from the 16th " + "birthday until the 31 August that next follows it. That period " + "reads is_before_first_september_following_16th_birthday, so it " + "counts in any year that starts before the 31 August. For a 16th " + "birthday on 31 August, reg. 4(1A) gives no such period, where " + "Universal Credit gives that day and Child Benefit two: the year " + "counts all the same, as the one in which the person is a child " + "until 30 August, and not the year after. The second condition " + "(reg. 5(4)) always applies: a young person who has left education or " + "training must not be in remunerative work, and no one may be " + "receiving a benefit in their own right. The own-right benefits " + "input covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " + "Income Support and the income-related and income-based allowances. " + "Interruptions (reg. 5(7)) are not modelled." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/4", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ] def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + # Reg. 5(3)(a), (ab) and (3A). + receiving_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", + period, + ) + in_education_or_training = receiving_education_or_training & person( + "meets_qualifying_young_person_entry_condition_for_child_tax_credit", + period, + ) + # Reg. 4(1): from the 16th birthday to the 31 August that next + # follows. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(3)(b): 20 weeks after leaving, under 18. + in_extension_period = ( + person("age", period) < p.extension_period_age_limit + ) & person("is_in_child_tax_credit_extension_period", period) + # Reg. 5(4)(a): having left, not in remunerative work. + left_and_not_working = ~receiving_education_or_training & ~person( + "is_in_remunerative_work_for_child_tax_credit", period + ) return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_tax_credit", - period, + & ( + in_education_or_training + | ( + (before_31_august_following_16th_birthday | in_extension_period) + & left_and_not_working + ) ) + # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) ) From 086b78ba8ef23da9dd69f72453dc1dd95473b3bf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:31:02 -0400 Subject: [PATCH 04/26] Update the Housing Benefit childcare case for a disabled 16-year-old school leaver A disabled 16-year-old outside education is now a young person until the 31 August after their 16th birthday (Child Benefit reg. 4; HB reg 19(1)), so HB reg 28(6) counts them. Keep the original point, that the disability age extension does not itself create young-person status, as a second case with the reg. 4 period ended. Co-Authored-By: Claude Opus 5.5 --- ...t_applicable_income_childcare_element.yaml | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml index 411dd36183..dfa142a349 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml @@ -219,7 +219,33 @@ num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 -- name: Disabled 16-year-old outside education and training is not a relevant young person +- name: Disabled 16-year-old outside education and training is a relevant young person until the 31 August after their 16th birthday + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9_000 + young_person: + age: 16 + is_disabled_for_benefits: true + is_in_non_advanced_education: false + is_in_approved_training: false + benunits: + benunit: + members: [claimant, young_person] + output: + # A 16-year-old who has left education is a young person until that 31 + # August (Child Benefit (General) Regulations 2006 reg. 4; HB reg 19(1)), + # in any year that starts before it. HB reg 28(6) then counts a disabled + # one: min(9,000, one-child cap * 52). + is_child_or_young_person_for_legacy_benefits: [false, true] + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9_000 + +- name: Disabled 16-year-old outside education and training is not a relevant young person after that 31 August period: 2025 input: people: @@ -232,11 +258,13 @@ is_disabled_for_benefits: true is_in_non_advanced_education: false is_in_approved_training: false + is_before_first_september_following_16th_birthday: false benunits: benunit: members: [claimant, young_person] output: # HB reg 28(6) requires a child of the family, including a qualifying # young person; the disability age extension does not create that status. + is_child_or_young_person_for_legacy_benefits: [false, false] num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 From 2ef1d8dbf968fb123429be3dbbf7a56e190ca2da Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:45:06 -0400 Subject: [PATCH 05/26] Place the property tests' 16-year-old outside the reg 5(1)(a) route Two property tests build a member who is outside every programme's family as a 16 to 19 year old not in education. At 16 that member is now a UC qualifying young person under reg 5(1)(a), so the family's UC gains a child element and the tests' premise no longer holds. Their generators now set is_before_first_september_following_16th_birthday false for that member. The test functions are unchanged, so Hypothesis draws the same examples. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_legacy_award_readers_properties.py | 8 +++++--- .../tests/test_means_test_income_properties.py | 11 +++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 7c6ef57d72..4fe4f668e1 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -159,13 +159,15 @@ def families(draw): def other_members(draw): """An adult who is neither claimant, partner nor a dependant. - They are under state pension age and not in education, so a 16 to 19 year - old is not a qualifying young person. They have no income other than the - awards they report. + They are under state pension age, not in education and past the 1 + September following their 16th birthday (UC Regs 2013 reg. 5(1)(a)), so a + 16 to 19 year old is not a qualifying young person. They have no income + other than the awards they report. """ return { "age": draw(st.integers(16, 60)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, **draw(award_reports()), } diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py index 49a9088ef2..04dfad9e74 100644 --- a/policyengine_uk/tests/test_means_test_income_properties.py +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -3,7 +3,8 @@ Invariant: for any benefit unit, giving income to a member who is neither the claimant, the partner, nor the programme's own child or young person (for example an 18- or 19-year-old presumed the claimant's child but not in -education, or a third adult) never changes a means test's income. The UC, HB, +education, a 16-year-old outside education and past the 1 September following +their 16th birthday, or a third adult) never changes a means test's income. The UC, HB, IS, tax credit, CTR and Pension Credit income totals are covered. Each example builds families twice in one simulation: once with the other @@ -45,9 +46,10 @@ def families(draw): # A head, an optional partner, and one member who by construction is # outside every programme's family: a 16-19-year-old at least 16 years # younger than the head (presumed the head's child) who is not in - # non-advanced education, so no programme's young person; or a third adult - # no older than the partner (the partner, earlier in member order, wins any - # age tie). + # non-advanced education and is past the 1 September following their 16th + # birthday (UC Regs 2013 reg. 5(1)(a)), so no programme's young person; or + # a third adult no older than the partner (the partner, earlier in member + # order, wins any age tie). kind = draw(st.sampled_from(["presumed_child", "third_adult"])) if kind == "presumed_child": other_age = draw(st.integers(16, 19)) @@ -72,6 +74,7 @@ def families(draw): { "age": other_age, "current_education": education, + "is_before_first_september_following_16th_birthday": False, "employment_income": draw(st.floats(1, 50_000)), "private_pension_income": draw(money), "savings_interest_income": draw(money), From bb6f48795eabc56d5d8f022c40bf5e54177f3902 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 21:07:01 -0400 Subject: [PATCH 06/26] Keep the Income Support property's outside member out of the Child Benefit reg. 4 period test_excluded_member_never_changes_is_eligibility adds a member aged 16 or over who is not in education, on the premise that they are not a qualifying young person. With reg. 4 a 16-year-old who has left education is one until the 31 August after their 16th birthday, so the generator now puts the member past that date, as #2279 does for the same reason in two other property tests. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_support_eligibility_properties.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 2ad7e658bb..20db7f7554 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -206,8 +206,10 @@ def families(draw): def excluded_members(draw): """An adult who is neither claimant, partner nor in the family. - They are not in education, so a 16 to 19 year old is not a qualifying - young person. Most are primed with what barred or opened the claim when + They are not in education and past the 31 August following their 16th + birthday (Child Benefit (General) Regulations 2006 reg. 4), so a 16 to 19 + year old is not a qualifying young person. Most are primed with what + barred or opened the claim when every member counted: over state pension age, income-related ESA, an Income Support report, or caring. Some are primed with what the s.124(1)(g) proxy reads for a couple: over the qualifying age, with @@ -216,6 +218,7 @@ def excluded_members(draw): adult = { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, } primed = draw( st.sampled_from(["random", "elderly", "esa", "award", "carer", "pension"]) From 1954ebe02ffa005b5c3cabd065ba202eabeb9c55 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 21:38:20 -0400 Subject: [PATCH 07/26] Vary the age the course began in the Child Tax Credit leaver grid A mutation that opened the leaver routes to a student who fails the entry condition (reg. 5(3A)) passed every test: the grid fixed the entry age at 17. The grid now also draws a course begun at 19, with the date fact set directly, and asserts that such a student never qualifies. Co-Authored-By: Claude Opus 5.5 --- ..._credit_school_leaver_routes_properties.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index fde32f0087..8442535e3d 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -20,13 +20,15 @@ a year exactly when they are 16 to 19 on 6 October and some of the year, from 6 April, falls on or before the last day they are a child or in the reg. 4 period. For a 16th birthday on 31 August that day is 30 August. -2. Over every combination of age, education, training, own-right benefits, - the two leaver facts and hours: the status is the education condition, or - a leaver fact for a person who has left and is not in remunerative work, - within ages 16 to 19 and without own-right benefits. So remunerative work - ends both leaver routes and neither a student's status, the 20 weeks end - at 18, and with the leaver facts off the status is the education - condition's alone, as before. +2. Over every combination of age, education, training, apprenticeship, + own-right benefits, the age the course began, the two leaver facts and + hours: the status is the education condition, or a leaver fact for a + person who has left and is not in remunerative work, within ages 16 to 19 + and without own-right benefits. So remunerative work ends both leaver + routes and neither a student's status, the 20 weeks end at 18, a leaver + fact never reaches a student whose course began at 19, and with the + leaver facts off the status is the education condition's alone, as + before. 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. @@ -199,15 +201,19 @@ def qualifying_young_person(p): is_apprentice=apprentice, receives_benefits_in_own_right=own_right, weekly_hours=hours, + age_started_or_accepted_current_education_or_training=entry_age, **{ROUTE: route, EXTENSION: extension}, ) - for age, education, training, apprentice, own_right, hours in product( + for age, education, training, apprentice, own_right, hours, entry_age in product( range(14, 22), EDUCATIONS, (False, True), (False, True), (False, True), (0, 23, 24), + # A course begun at 19 fails reg. 5(3A). Such a student has not left + # education, so neither leaver fact reaches them. + (17, 1000), ) for route, extension in product((False, True), repeat=2) ] @@ -227,6 +233,17 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) assert not status[(age < 16) | (age >= 20)].any() assert not status[own_right].any() + # A student whose course began at 19 does not qualify, whatever the + # leaver facts say. + late_student = np.array( + [ + receiving_education_or_training(p) + and p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + for p in GRID + ] + ) + assert late_student.any() and not status[late_student].any() # Remunerative work ends a leaver's status and not a student's. assert not status[~student & (hours >= 24)].any() assert status[student & (hours >= 24)].any() From b171ef789fa78026fced50a602ddcd62fc118494 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:53:07 -0400 Subject: [PATCH 08/26] Address review r1c: partial-input warning, two-child limit and mixed date-of-birth tests - The route variable's docs now warn that setting it for some people gives everyone else false, and point to date_of_birth for placing one person. They also state that the rule departs from the 6 October reading that is_SP_age uses. - YAML: a 2025-26 case where the 16-year-old ranks first under the two-child limit and the youngest child loses the element (GBP 7,581.72), and a case mixing a person with date_of_birth and one without (both in the route). The second kills a mutant that reads the raw date_of_birth input instead of birth_day. - means_test_income_members.yaml: the 17-year-old case's name says why they are outside the route. Co-Authored-By: Claude Opus 5.5 --- ...fying_young_person_no_education_route.yaml | 67 +++++++++++++++++++ .../gov/dwp/means_test_income_members.yaml | 2 +- ...first_september_following_16th_birthday.py | 15 +++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml index 8a0ebb1c25..b06ae2d535 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml @@ -266,3 +266,70 @@ child_minimum_guarantee_addition: 4_215.64 standard_minimum_guarantee: 12_376 minimum_guarantee: 16_591.64 + +- name: In 2025-26 the 16-year-old counts first under the two-child limit, and the youngest child loses the element. + # 2025-26: the 16-year-old (born 6 April 2009) is a qualifying young person + # through reg. 5(1)(a), so the eldest of three. The higher first-child + # amount: GBP 339 a month x 12 = GBP 4,068. The 7-year-old (born 6 April + # 2018): GBP 292.81 x 12 = GBP 3,513.72. The 5-year-old (born 6 April 2020, + # after 6 April 2017) is the third child, so no element (reg. 24A). Total + # GBP 7,581.72. + period: 2025 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + child_7: + age: 7 + child_5: + age: 5 + benunits: + benunit: + members: [parent, young_person, child_7, child_5] + households: + household: + members: [parent, young_person, child_7, child_5] + output: + uc_child_index: [-1, 1, 2, 3] + uc_individual_child_element: [0, 4_068, 3_513.72, 0] + uc_is_child_limit_affected: [false, false, false, true] + uc_child_element: 7_581.72 + +- name: A date of birth given for one person leaves another's age-placed date unchanged. + # The 17-year-old's date of birth (15 September 2009) puts them in the + # route; the 16-year-old elsewhere has none, so age places their birthday + # on 6 April 2010 and they are in it too. + period: 2026 + input: + people: + parent_1: + age: 34 + is_parent: true + young_person_17: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + parent_2: + age: 40 + is_parent: true + young_person_16: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit_1: + members: [parent_1, young_person_17] + benunit_2: + members: [parent_2, young_person_16] + households: + household_1: + members: [parent_1, young_person_17] + household_2: + members: [parent_2, young_person_16] + output: + is_before_first_september_following_16th_birthday: [false, true, false, true] + is_qualifying_young_person_for_universal_credit: [false, true, false, true] + is_qualifying_young_person_for_pension_credit: [false, true, false, true] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml index a9d6e1605e..ce709391d1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml @@ -47,7 +47,7 @@ output: universal_credit: 9_321.36 -- name: A 17-year-old outside education is not a UC young person, so their pay does not count +- name: A 17-year-old outside education and past the 1 September after their 16th birthday is not a UC young person, so their pay does not count period: 2026 absolute_error_margin: 1 input: diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py index 1b20640745..22dc35fb9d 100644 --- a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py @@ -18,12 +18,15 @@ class is_before_first_september_following_16th_birthday(Variable): "September runs it to the 17th birthday. The route ends on a fixed 1 " "September that annual periods cannot split, so a person in it for " "part of the year counts for the year, as the benefit cap and LHA " - "family rules count a 16-year-old member. Everyone aged 16 on 6 " - "October qualifies, as does a 17-year-old born from 1 September to 6 " - "October. With birthdays spread evenly, a 16-year-old outside " - "education is a child or in the route for 46% of the year on average, " - "and a 17-year-old for 4%. Set this to false for a year that the " - "route does not reach." + "family rules count a 16-year-old member. This departs from the 6 " + "October reading of annual status that is_SP_age uses. Everyone aged " + "16 on 6 October qualifies, as does a 17-year-old born from 1 " + "September to 6 October. With birthdays spread evenly, a 16-year-old " + "outside education is a child or in the route for 46% of the year on " + "average, and a 17-year-old for 4%. To place the route for one " + "person, set their date_of_birth. Setting this variable for some " + "people gives everyone else in the situation false, so set it for " + "every person or not at all." ) definition_period = YEAR reference = ( From 061e087ac3a25bff38d37ad8ae8dd81a8731c412 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:44:29 -0400 Subject: [PATCH 09/26] Bar remunerative work only for a young person who has ceased full-time education Review round 1 of #2301: the formula took failing the non-advanced education test as having left education, so a 16-year-old in full-time advanced education working 24 hours a week lost the reg. 4 period. Reg. 5(4)(a) bars work only for a person "having ceased to receive full-time education or approved training", and full-time education at a school or college counts at any level (reg. 5(5)). Reg. 4(2) lifts the first condition only. The 20 weeks of reg. 5(3)(b) are for a person who has ceased. The property oracle had the same conflation and is fixed with it; the grid now draws a course begun at 19, as its comment says. YAML Cases 15 and 16 pin the advanced student. Co-Authored-By: Claude Opus 5.5 --- ...child_tax_credit_school_leaver_routes.yaml | 46 +++++++++++++ ..._credit_school_leaver_routes_properties.py | 65 ++++++++++++++----- ...fying_young_person_for_child_tax_credit.py | 38 +++++++---- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index fe113d9ad4..a77dc789c2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -307,3 +307,49 @@ is_qualifying_young_person_for_child_tax_credit: true is_child_or_qualifying_young_person_for_child_tax_credit: false CTC_child_element: 0 + +- name: Case 15, a 16-year-old in full-time advanced education working 24 hours a week is a qualifying young person (reg. 4(2), 5(4)(a), 5(5)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 16 + date_of_birth: 20080101 + current_education: TERTIARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # Advanced education fails the first condition, which reg. 4(2) lifts. + # It is still full-time education (reg. 5(5)), so the student has not + # "ceased" it and the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 16, the same advanced student aged 17, outside the reg. 4 period, is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + date_of_birth: 20070101 + current_education: TERTIARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 8442535e3d..18d63930ee 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -9,9 +9,12 @@ on 31 August gets no such period. - Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for work or training satisfy the first condition. -- Reg. 5(4), the second condition, always applies: a person who has left - education or training must not be in remunerative work (24 hours a week or - more, reg. 2(1)), and no one may be receiving a benefit in their own right. +- Reg. 5(4), the second condition, always applies: a person who has ceased + full-time education or approved training must not be in remunerative work + (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit + in their own right. Full-time education at any level counts (reg. 5(5)), so + an advanced student has not ceased; an apprentice is treated as having + ceased. Invariants: @@ -22,13 +25,15 @@ reg. 4 period. For a 16th birthday on 31 August that day is 30 August. 2. Over every combination of age, education, training, apprenticeship, own-right benefits, the age the course began, the two leaver facts and - hours: the status is the education condition, or a leaver fact for a - person who has left and is not in remunerative work, within ages 16 to 19 - and without own-right benefits. So remunerative work ends both leaver - routes and neither a student's status, the 20 weeks end at 18, a leaver - fact never reaches a student whose course began at 19, and with the - leaver facts off the status is the education condition's alone, as - before. + hours: the status is the education condition; or the reg. 4 period, + unless the person has ceased education or training and is in remunerative + work; or the 20 weeks for a person under 18 who has ceased and is not in + remunerative work; within ages 16 to 19 and without own-right benefits. + So remunerative work ends both routes for a person who has ceased and + never a student's status, an advanced student keeps the reg. 4 period + whatever their hours, the 20 weeks end at 18 and never reach a student + whose course began at 19, and with the leaver facts off the status is the + education condition's alone, as before. 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. @@ -173,6 +178,15 @@ def receiving_education_or_training(p): ) or p["is_in_approved_training"] +def has_ceased_education_or_training(p): + # Reg. 5(4)(a), (5): full-time education at any level, not received by + # virtue of employment, or approved training, has not ceased. + in_full_time_education = ( + p["current_education"] != "NOT_IN_EDUCATION" and not p["is_apprentice"] + ) + return not (in_full_time_education or p["is_in_approved_training"]) + + def first_condition_through_education(p): # Reg. 5(3A): begun before 19. begun_before_19 = ( @@ -187,10 +201,14 @@ def qualifying_young_person(p): return False if first_condition_through_education(p): return True - left = not receiving_education_or_training(p) - leaver_fact = p[ROUTE] or (p["age"] < 18 and p[EXTENSION]) + ceased = has_ceased_education_or_training(p) # Reg. 2(1): remunerative work is 24 hours a week or more. - return left and leaver_fact and p["weekly_hours"] < 24 + working = p["weekly_hours"] >= 24 + # Reg. 4(2): the period needs only the second condition. + if p[ROUTE] and not (ceased and working): + return True + # Reg. 5(3)(b): under 18, having ceased, and reg. 5(4)(a). + return p["age"] < 18 and p[EXTENSION] and ceased and not working GRID = [ @@ -213,7 +231,7 @@ def qualifying_young_person(p): (0, 23, 24), # A course begun at 19 fails reg. 5(3A). Such a student has not left # education, so neither leaver fact reaches them. - (17, 1000), + (17, 19), ) for route, extension in product((False, True), repeat=2) ] @@ -230,23 +248,34 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): age = np.array([p["age"] for p in GRID]) hours = np.array([p["weekly_hours"] for p in GRID]) student = np.array([receiving_education_or_training(p) for p in GRID]) + ceased = np.array([has_ceased_education_or_training(p) for p in GRID]) + route = np.array([p[ROUTE] for p in GRID]) own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) assert not status[(age < 16) | (age >= 20)].any() assert not status[own_right].any() - # A student whose course began at 19 does not qualify, whatever the - # leaver facts say. + # A student whose course began at 19 does not qualify through the 20 + # weeks, which need them to have ceased. (The grid sets the reg. 4 fact + # directly; from a date of birth it is never true at 19.) late_student = np.array( [ receiving_education_or_training(p) and p["age"] >= 19 and p["age_started_or_accepted_current_education_or_training"] >= 19 + and not p[ROUTE] for p in GRID ] ) assert late_student.any() and not status[late_student].any() - # Remunerative work ends a leaver's status and not a student's. - assert not status[~student & (hours >= 24)].any() + # Remunerative work ends the status of a person who has ceased education + # or training, and not a student's. + assert not status[ceased & (hours >= 24)].any() assert status[student & (hours >= 24)].any() + # An advanced student in the reg. 4 period qualifies whatever their hours. + advanced = np.array([p["current_education"] == "TERTIARY" for p in GRID]) + in_age = (age >= 16) & (age < 20) & ~own_right + advanced_in_route = advanced & ~ceased & route & in_age + assert advanced_in_route.any() and status[advanced_in_route].all() + assert (advanced_in_route & (hours >= 24)).any() # The 20 weeks alone do not reach an 18-year-old. alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) assert not status[alone & ~student & (age >= 18)].any() diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 3fa5ac8721..2905c084d8 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) class is_qualifying_young_person_for_child_tax_credit(Variable): @@ -18,9 +21,12 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "Universal Credit gives that day and Child Benefit two: the year " "counts all the same, as the one in which the person is a child " "until 30 August, and not the year after. The second condition " - "(reg. 5(4)) always applies: a young person who has left education or " - "training must not be in remunerative work, and no one may be " - "receiving a benefit in their own right. The own-right benefits " + "(reg. 5(4)) always applies: a young person who has ceased full-time " + "education or approved training must not be in remunerative work, and " + "no one may be receiving a benefit in their own right. Full-time " + "education at any level counts (reg. 5(5)), so an advanced student " + "has not ceased it; an apprentice's education, received by virtue of " + "employment, is treated as having ceased. The own-right benefits " "input covers Universal Credit, Employment and Support Allowance and " "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " "Income Support and the income-related and income-based allowances. " @@ -52,22 +58,28 @@ def formula(person, period, parameters): in_extension_period = ( person("age", period) < p.extension_period_age_limit ) & person("is_in_child_tax_credit_extension_period", period) - # Reg. 5(4)(a): having left, not in remunerative work. - left_and_not_working = ~receiving_education_or_training & ~person( - "is_in_remunerative_work_for_child_tax_credit", period + # Reg. 5(4)(a) bars remunerative work only for a person "having + # ceased to receive full-time education or approved training". + # Full-time education at a school or college counts at any level + # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does + # not mean having ceased it. + in_full_time_education = ( + person("current_education", period) != EducationType.NOT_IN_EDUCATION + ) & ~person("is_apprentice", period) + ceased = ~in_full_time_education & ~person("is_in_approved_training", period) + working = person("is_in_remunerative_work_for_child_tax_credit", period) + # Reg. 4(2): the period needs no first condition, only the second. + in_regulation_4_period = before_31_august_following_16th_birthday & ~( + ceased & working ) + # Reg. 5(3)(b) is for a person who has ceased. + in_20_weeks = in_extension_period & ceased & ~working return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & ( - in_education_or_training - | ( - (before_31_august_following_16th_birthday | in_extension_period) - & left_and_not_working - ) - ) + & (in_education_or_training | in_regulation_4_period | in_20_weeks) # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) ) From e626d3f6ff72ccda9e17508cc0a5cfdac6493ca6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:21:36 -0400 Subject: [PATCH 10/26] Give the working school-leaver in Case 11 earnings, and check the parent's means tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 of #2299: a young person who qualifies through reg. 7 and works 24 hours stays a young person, so their earnings must not enter the parent's Housing Benefit, Income Support or Council Tax Reduction income. #2387, merged in here, makes that so; Case 11 now has £20,000 of earnings and asserts the three applicable incomes are nil. Co-Authored-By: Claude Opus 5.5 --- .../child_benefit_school_leaver_routes.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index 8a32748910..75a9d25169 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -256,10 +256,16 @@ current_education: NOT_IN_EDUCATION is_before_child_benefit_terminal_date: true weekly_hours: 24 + employment_income: 20_000 is_claimant_or_partner: false benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false + households: + household: + members: [parent, school_leaver] + country: ENGLAND output: is_qualifying_young_person_for_child_benefit: [false, true] is_child_or_qualifying_young_person_for_child_benefit: [false, true] @@ -268,6 +274,12 @@ # Still a young person for Housing Benefit and the other legacy schemes. is_child_or_young_person_for_legacy_benefits: [false, true] is_lone_parent: true + # Their £20,000 of earnings is not the parent's (HB Regs 2006 reg 25(3), + # IS Regs 1987 reg 23(2), CTR applicant-and-partner rules; #2387). The + # parent has no income of their own. + housing_benefit_applicable_income: 0 + income_support_applicable_income: 0 + council_tax_reduction_applicable_income: 0 - name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. period: 2026 From 3737d29e9c4d7b2aff5217c099360d61b85dd67b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:28:38 -0400 Subject: [PATCH 11/26] Count an apprentice still on a course as not having ceased full-time education MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 of #2301: the formula took apprenticeship as ceasing education, so a 16-year-old apprentice still on a college course and working 24 hours or more lost the reg. 4 period. Education received by virtue of employment is left out of the first condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts; reg. 5(5) counts a course at a school or college without that exclusion, so the apprentice has not "ceased to receive full-time education" and the reg. 5(4)(a) bar does not apply. The property oracle had the same assumption and is fixed with it. YAML Case 5 now expects the apprentice on a course to qualify (£3,455); new Case 17 keeps an apprentice who has left every course out. Co-Authored-By: Claude Opus 5.5 --- ...child_tax_credit_school_leaver_routes.yaml | 36 +++++++++++++++---- ..._credit_school_leaver_routes_properties.py | 12 +++---- ...fying_young_person_for_child_tax_credit.py | 10 ++++-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index a77dc789c2..0db7aece61 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -98,7 +98,7 @@ is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 -- name: Case 5, a 16-year-old apprentice working 30 hours a week is not a qualifying young person. +- name: Case 5, a 16-year-old apprentice still on a college course and working 30 hours a week is a qualifying young person. period: 2024 absolute_error_margin: 0.01 input: @@ -117,11 +117,12 @@ benunit: members: [parent, apprentice] output: - # Education "by virtue of his employment" is not the first condition - # (reg. 5(3)(a)(ii)), so the apprentice has left it and is in - # remunerative work. - is_qualifying_young_person_for_child_tax_credit: [false, false] - CTC_child_element: 0 + # Education "by virtue of his employment" is left out of the first + # condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts. The apprentice has + # not "ceased to receive full-time education" (reg. 5(4)(a), 5(5)), so + # the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 - name: Case 6, born 15 September 2007, the period runs to 31 August 2024. period: 2024 @@ -353,3 +354,26 @@ output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 + +- name: Case 17, a 16-year-old apprentice no longer on any course and working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: NOT_IN_EDUCATION + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + # Ceased full-time education and in remunerative work (reg. 5(4)(a)). + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 18d63930ee..0381c3728a 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -13,8 +13,8 @@ full-time education or approved training must not be in remunerative work (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit in their own right. Full-time education at any level counts (reg. 5(5)), so - an advanced student has not ceased; an apprentice is treated as having - ceased. + an advanced student has not ceased, nor has an apprentice still on a + course: the employment exclusion belongs to the first condition. Invariants: @@ -179,11 +179,9 @@ def receiving_education_or_training(p): def has_ceased_education_or_training(p): - # Reg. 5(4)(a), (5): full-time education at any level, not received by - # virtue of employment, or approved training, has not ceased. - in_full_time_education = ( - p["current_education"] != "NOT_IN_EDUCATION" and not p["is_apprentice"] - ) + # Reg. 5(4)(a), (5): full-time education at any level, an apprentice's + # course included, or approved training, has not ceased. + in_full_time_education = p["current_education"] != "NOT_IN_EDUCATION" return not (in_full_time_education or p["is_in_approved_training"]) diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2905c084d8..e23714dd8b 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -25,8 +25,9 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "education or approved training must not be in remunerative work, and " "no one may be receiving a benefit in their own right. Full-time " "education at any level counts (reg. 5(5)), so an advanced student " - "has not ceased it; an apprentice's education, received by virtue of " - "employment, is treated as having ceased. The own-right benefits " + "has not ceased it, nor has an apprentice still on a course; the " + "employment exclusion is part of the first condition only. The " + "own-right benefits " "input covers Universal Credit, Employment and Support Allowance and " "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " "Income Support and the income-related and income-based allowances. " @@ -63,9 +64,12 @@ def formula(person, period, parameters): # Full-time education at a school or college counts at any level # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does # not mean having ceased it. + # Education received by virtue of employment is left out of the first + # condition (reg. 5(3)(a)), which reg. 4(2) lifts, and not out of reg. + # 5(5): an apprentice still on a course has not ceased it. in_full_time_education = ( person("current_education", period) != EducationType.NOT_IN_EDUCATION - ) & ~person("is_apprentice", period) + ) ceased = ~in_full_time_education & ~person("is_in_approved_training", period) working = person("is_in_remunerative_work_for_child_tax_credit", period) # Reg. 4(2): the period needs no first condition, only the second. From 16b5c56d5915440d63951e3128c0a31497f333c2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:30:45 -0400 Subject: [PATCH 12/26] Assert the UC benefit cap single rate by its name after #2089 #2089 replaced the shared is_benefit_cap_single_claimant_rate with is_uc_benefit_cap_single_claimant_rate and its Housing Benefit counterpart. The lone-parent case asserts the UC rate, which is still false: the 16-year-old outside education is a qualifying young person the claimant is responsible for. Co-Authored-By: Claude Opus 5.5 --- .../benefit/qualifying_young_person_no_education_route.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml index b06ae2d535..3aadede91b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml @@ -240,7 +240,7 @@ is_uc_work_allowance_eligible: true uc_work_allowance: 8_520 uc_maximum_amount: 9_321.36 - is_benefit_cap_single_claimant_rate: false + is_uc_benefit_cap_single_claimant_rate: false - name: A pensioner with a 16-year-old outside education gets the Pension Credit child addition. # 2026-27: born 6 April 2010, before 6 April 2017, so the first child From 2f0f15a520d7fa024fa95733636ebacaba29def0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:41:04 -0400 Subject: [PATCH 13/26] Let a school-leaver's DLA and AFIP lift the Housing Benefit cap After main's benefit cap work, two cases asserted that a 16-year-old who has left education is not a Housing Benefit young person, so their DLA or armed forces independence payment does not lift the cap, and the exemption's docstring named this PR's issue (#2287) as the gap. Under reg. 4 they are a Child Benefit qualifying young person until the 31 August after their 16th birthday, and so a Housing Benefit young person (HB Regs 2006 reg. 19), whose benefit lifts the cap (reg. 75F(1)). Both expectations and the docstring now say so; Universal Credit's exemption is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../benefit_cap_specified_benefits.yaml | 21 ++++++++++++------- ...it_benefit_cap_exempt_specified_benefit.py | 17 +++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 082ac4d353..e8ba38e8ea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -821,13 +821,15 @@ members: [parent, sixteen] region: NORTH_EAST output: - is_child_or_young_person_for_legacy_benefits: [false, false] + # HB reg 19(1) requires a Child Benefit "qualifying young person". A + # 16-year-old who has left education is one until the 31 August after + # their 16th birthday (Child Benefit (General) Regulations 2006 reg. 4), + # so they are the family's young person for Housing Benefit too. + is_child_or_young_person_for_legacy_benefits: [false, true] # UC reg 5(1)(a): "up to, but not including, the 1st September following # their 16th birthday". Annual ages count the 16-year-old for the year. - # HB reg 19(1) requires a Child Benefit "qualifying young person"; - # this person is outside education and is not one in the model. is_uc_benefit_cap_exempt_specified_benefit: true - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 19-year-old outside education is not a young person, so their DLA does not lift the cap period: 2025 @@ -1188,7 +1190,7 @@ housing_benefit: 30_000 absolute_error_margin: 1 -- name: A 16-year-old out of education with armed forces independence payment does not lift the cap +- name: A 16-year-old out of education with armed forces independence payment lifts the Housing Benefit cap, not Universal Credit's period: 2025 input: people: @@ -1207,10 +1209,13 @@ members: [parent, sixteen] region: NORTH_EAST output: - # Not a Housing Benefit young person (not in education), and Universal - # Credit counts the payment for a claimant only (UC Regs 2013 reg 2). + # A Housing Benefit young person until the 31 August after the 16th + # birthday (Child Benefit (General) Regulations 2006 reg. 4), whose armed + # forces independence payment lifts the cap (HB Regs 2006 reg. 75F(1)). + # Universal Credit counts the payment for a claimant only (UC Regs 2013 + # reg 2). is_uc_benefit_cap_exempt_specified_benefit: false - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 16-year-old receiving benefits in their own right is not a young person, so their DLA does not lift the cap period: 2025 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 6ebc6992ed..90ad82cc9a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -60,16 +60,13 @@ class is_housing_benefit_benefit_cap_exempt_specified_benefit(Variable): "Universal Credit before the cap (universal_credit_pre_benefit_cap), " "so a Universal Credit award the cap reduces to nil still lifts the " "Housing Benefit cap. A young person is the model's Child Benefit " - "qualifying young person, which leaves out a 16-year-old in neither " - "non-advanced education nor approved training. The Housing Benefit " - "cap rate (is_housing_benefit_benefit_cap_single_claimant_rate) " - "counts such a 16-year-old for the year, so a lone parent with one " - "gets the family rate, but the 16-year-old's own disability living " - "allowance or personal independence payment does not lift the cap. " - "In law one status decides both: a school leaver remains a " - "qualifying young person until a terminal date or the end of an " - "extension period (Child Benefit (General) Regulations 2006 regs. 5 " - "and 7), which the model does not place (#2287)." + "qualifying young person (HB Regs 2006 reg. 19), which includes a " + "16-year-old who has left education, until the 31 August after their " + "16th birthday (Child Benefit (General) Regulations 2006 reg. 4), and " + "a school leaver in an extension period or before a terminal date " + "where those inputs are set (regs. 5 and 7). Such a young person's " + "own disability living allowance, personal independence payment or " + "armed forces independence payment lifts the cap." ) definition_period = YEAR reference = ( From 4772258fba9606ba05e40f14f3f57b840eef536d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:53:20 -0400 Subject: [PATCH 14/26] Drop the comments that said a school leaver is not an HB young person Three comments still said the Housing Benefit cap exceptions leave out a 16-year-old in neither non-advanced education nor approved training (#2287). Under reg. 4 the model's Child Benefit qualifying young person now counts every 16-year-old for the year, whatever their education, so the exceptions and the cap rate agree on them. Comments and documentation only; no formula changes. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/benefit_unit.py | 4 ++-- ...housing_benefit_benefit_cap_exempt_specified_benefit.py | 7 +++---- .../is_housing_benefit_benefit_cap_single_claimant_rate.py | 7 +++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 2df7693ab5..cc1503b4b6 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -43,8 +43,8 @@ def has_sixteen_year_old_dependant(benunit, period): those dates, so the benefit cap rates, which turn on responsibility for a child or young person, count such a member for the year. The Housing Benefit cap exceptions read the model's Child Benefit qualifying young - person instead, which leaves out a 16-year-old in neither non-advanced - education nor approved training (#2287). + person, which counts a 16-year-old for the year too, whatever their + education (Child Benefit (General) Regulations 2006 reg. 4). """ person = benunit.members age = person("age", period) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 90ad82cc9a..a16ebed2e0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -83,10 +83,9 @@ def formula(benunit, period, parameters): # A child under 16 or a Child Benefit qualifying young person the # claimant or partner is responsible for (HB Regs 2006 regs 2(1), 19). - # A 16-year-old in neither non-advanced education nor approved - # training is not one here, although the cap rate counts them for the - # year (has_sixteen_year_old_dependant); the model's qualifying young - # person has no school-leaver terminal date (#2287). + # A 16-year-old who has left education is one for the year (Child + # Benefit (General) Regulations 2006 reg. 4), as the cap rate counts + # them (has_sixteen_year_old_dependant). child_or_young_person = person( "is_child_or_young_person_for_legacy_benefits", period ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py index 9d5daa2c20..562729465f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py @@ -15,11 +15,10 @@ class is_housing_benefit_benefit_cap_single_claimant_rate(Variable): "the other rate even where the partner could not be a joint claimant " "for Universal Credit. A 16-year-old member counts as a young person " "for the year (reg. 19(1); Child Benefit (General) Regulations 2006 " - "regs. 5 and 7). The exceptions " + "regs. 4, 5 and 7). The exceptions " "(is_housing_benefit_benefit_cap_exempt_specified_benefit) read the " - "model's Child Benefit qualifying young person instead, which leaves " - "out a 16-year-old in neither non-advanced education nor approved " - "training (#2287)." + "model's Child Benefit qualifying young person, which counts a " + "16-year-old for the year too, whatever their education (reg. 4)." ) definition_period = YEAR reference = ( From 560005cfa5dc76bfae59157549a3c975f5816cbf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:32:30 -0400 Subject: [PATCH 15/26] Place other_members_with_awards outside the reg 5(1)(a) route too #2549 added other_members_with_awards ("As other_members") for the young singles and working parents properties. It draws a 16-60-year-old outside education, but unlike other_members it did not set is_before_first_september_following_16th_birthday false. A drawn 16-year-old was therefore a UC qualifying young person, so the family changed. In the shrunk case, a single 19-year-old in Wales with GBP 12,000 earnings, housing_benefit_applicable_income went from GBP 11,740 to 0, and the two properties failed. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_legacy_award_readers_properties.py | 1 + 1 file changed, 1 insertion(+) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 932d9e5533..911375702f 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -536,6 +536,7 @@ def other_members_with_awards(draw): return { "age": draw(st.integers(16, 60)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, **awards, } From 8a5980c997ebe3842bd8698312ba3471c2baf8d0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:52:39 -0400 Subject: [PATCH 16/26] Count a 16-year-old school leaver in the benefit cap property oracle reference_specified_benefit counted the school-leaver role as a young person for Universal Credit only, matching the old model, where the Child Benefit qualifying young person (and so Housing Benefit's young person, reg. 19) had no route for a 16-year-old who has left education. Under reg. 4 they are one for the year, so their DLA, PIP, AFIP or carer benefit lifts the Housing Benefit cap too, as the formula now gives. Neither scheme counts one receiving benefits in their own right. Co-Authored-By: Claude Opus 5.5 --- ...nefit_cap_specified_benefits_properties.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 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 9648177f44..81e968235f 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -30,8 +30,10 @@ 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)). -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 +HB young persons follow Child Benefit (reg 19), which includes a 16-year-old +school leaver until the 31 August after their 16th birthday (Child Benefit +(General) Regulations 2006 reg 4), counted for the year as the model does, +unless they receive benefits in their own right (reg 8). UC reg 83(1)(c) names "a claimant" for attendance allowance, which includes armed forces independence payment (reg 2); HB reg 75F(1)(ea) also names "a young person" for it. Only UC reg 83(1)(a), (j) names the LCWRA and carer elements; only @@ -273,10 +275,10 @@ def reference_specified_benefit(members, benunit_inputs, scheme): claimants = [m for m in members if m["_role"] == "claimant"] # UC reg 83 names a "qualifying young person" (reg 5), which includes a # 16-year-old school leaver (reg 5(1)(a)); HB reg 75F names a "young - # person" (reg 19), a Child Benefit qualifying young person. The - # school-leaver role here has no Child Benefit education or terminal-date - # circumstance, so it is not one. - young_roles = ("young_person", "sixteen") if scheme == "uc" else ("young_person",) + # person" (reg 19), a Child Benefit qualifying young person, which + # includes one too (Child Benefit (General) Regulations 2006 reg 4). + # Neither counts one receiving benefits in their own right. + young_roles = ("young_person", "sixteen") young_persons = [m for m in members if m["_role"] in young_roles] children = [m for m in members if m["_role"] == "child"] # No capital, so the couple's income-related award is their reports. @@ -376,8 +378,8 @@ def reference_cap(members): # Each scheme's rate: a single claimant (UC reg 80A(2)(c)) or a claimant # who neither has a partner nor is a lone parent (HB regs 2(1), 75CA(2)), # with no child or young person. The family-rate helper counts any - # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit's - # school-leaver terminal dates, which annual ages cannot place); it does + # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit reg 4 + # and its school-leaver terminal dates, which annual ages cannot place); it does # not yet apply reg 5(5) to one receiving benefits in their own right. claimants = sum(m["_role"] == "claimant" for m in members) dependants = any( @@ -573,8 +575,8 @@ def test_each_head_alone_matches_regulations(mode): assert not mismatches, mismatches # Both outcomes occur under each scheme. assert exempt.any() and not exempt.all() - # The schemes differ: UC's elements and 16-year-old school leaver, HB's - # young person's armed forces independence payment. + # The schemes differ: UC's elements, HB's young person's armed forces + # independence payment. assert (exemptions["uc"] != exemptions["hb"]).any() # Every role meets every circumstance. assert len(units) == len(SINGLE_HEAD_ROLES) * len(CIRCUMSTANCES) From 68a1fd7be92b0a20024f1d74bd2d62c0c8aee20d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 16:04:02 -0400 Subject: [PATCH 17/26] test: reproduce QYP exclusion for reported own awards --- ...ying_young_person_reported_own_awards.yaml | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml 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 new file mode 100644 index 0000000000..814e37ce8f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -0,0 +1,362 @@ +# UC reg. 5(5) and SPC reg. 4A(5) exclude own awards from both QYP routes. +# Omit receives_benefits_in_own_right: the reports identify the recipient. + +- name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + 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 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting Universal Credit does not attract child additions for a parent aged 40. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + universal_credit_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting contributory ESA does not attract child additions for a parent aged 40. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + esa_contrib_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting contribution-based JSA does not attract child additions for a parent aged 40. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 70. + period: 2026 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + 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 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting Universal Credit does not attract child additions for a parent aged 70. + period: 2026 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + universal_credit_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting contributory ESA does not attract child additions for a parent aged 70. + period: 2026 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + esa_contrib_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 + +- name: A 16-year-old reporting contribution-based JSA does not attract child additions for a parent aged 70. + period: 2026 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + savings: 0 + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + output: + receives_benefits_in_own_right: [false, true] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + uc_child_element: 0 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_uc_work_allowance_eligible: false + uc_work_allowance: 0 + child_minimum_guarantee_addition: 0 From cc4d3bb8a76d41510f8f455eec1ef424b89cf344 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 16:10:33 -0400 Subject: [PATCH 18/26] test: correct household savings and cover QYP award boundaries --- ...ying_young_person_reported_own_awards.yaml | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) 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 814e37ce8f..e021ff475a 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 @@ -17,14 +17,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -53,14 +52,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -89,14 +87,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -125,14 +122,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -161,14 +157,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -197,14 +192,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -233,14 +227,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -269,14 +262,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -305,14 +297,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -341,14 +332,13 @@ benunits: benunit: members: [parent, young_person] - savings: 0 households: household: members: [parent, young_person] country: ENGLAND rent: 0 + savings: 0 output: - receives_benefits_in_own_right: [false, true] is_before_first_september_following_16th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] @@ -360,3 +350,76 @@ is_uc_work_allowance_eligible: false uc_work_allowance: 0 child_minimum_guarantee_addition: 0 + +- name: A reported award also excludes the education route. + period: 2026 + input: + 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] + esa_income_reported: [0, 5_000, 0, 0, 0] + esa_contrib_reported: [0, 0, 5_000, 0, 0] + jsa_income_reported: [0, 0, 0, 5_000, 0] + jsa_contrib_reported: [0, 0, 0, 0, 5_000] + output: + 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: An explicit false own-right flag does not erase a reported own ESA award. + period: 2026 + input: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: false + esa_income_reported: 5_000 + output: + is_qualifying_young_person_for_universal_credit: false + is_qualifying_young_person_for_pension_credit: false + +- name: A parent's report does not identify their child as an own-award recipient. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + is_benunit_head: true + esa_income_reported: 5_000 + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + +- name: Zero reports leave the no-education route available. + period: 2026 + input: + age: 16 + current_education: NOT_IN_EDUCATION + universal_credit_reported: 0 + esa_income_reported: 0 + esa_contrib_reported: 0 + jsa_income_reported: 0 + jsa_contrib_reported: 0 + output: + is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_pension_credit: true + +- name: Contribution-based JSA excludes UC and PC QYPs without changing Child Benefit's distinct rule. + # Child Benefit (General) Regulations 2006 reg. 8 lists income-based JSA, + # but UC reg. 5(5) and SPC reg. 4A(5) also exclude contribution-based JSA. + period: 2026 + input: + age: 18 + current_education: UPPER_SECONDARY + jsa_contrib_reported: 5_000 + output: + is_qualifying_young_person_for_universal_credit: false + is_qualifying_young_person_for_pension_credit: false + is_qualifying_young_person_for_child_benefit: true From d6bcbf89fe8e5651d3dc905f77e7bddc1d14b3e9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 16:13:41 -0400 Subject: [PATCH 19/26] fix: exclude reported own awards from UC and PC young persons --- changelog.d/uc-pc-qyp-sixteen-route.fixed.md | 1 + ...nefit_cap_specified_benefits_properties.py | 38 ++++++++++++++++--- ...lifying_young_person_for_pension_credit.py | 13 ++++++- ...fying_young_person_for_universal_credit.py | 13 ++++++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md index 7c8723f135..b7da318f8c 100644 --- a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md +++ b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md @@ -1 +1,2 @@ - Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. +- Excluded young people reporting their own Universal Credit, income-related or contributory ESA, or income-based or contribution-based JSA from Universal Credit and Pension Credit qualifying young person status. 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 9648177f44..39b2f8cfaa 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -52,7 +52,8 @@ earnings exception the claimants (UC Regs 2013 reg 82(1)(a)), so the draws give the member State Pension age or earnings at times. 2. Each scheme's exemption equals its own reference written directly from - the regulations, with roles fixed by construction (differential test). + the regulations, with claimant roles fixed and young-person status + following each scheme's own exclusions (differential test). The union of the two equals the former shared reference. 3. A family exempt under a scheme has no cap and no reduction to that scheme's award; any other family has that scheme's statutory cap (UC reg @@ -121,6 +122,13 @@ "incapacity_benefit": "incapacity_benefit_reported", "sda": "sda_reported", } +OWN_RIGHT_REPORTS = ( + "universal_credit_reported", + "esa_contrib_reported", + "esa_income_reported", + "jsa_contrib_reported", + "jsa_income_reported", +) CIRCUMSTANCES = AMOUNTS + [ "esa_contrib", "esa_income", @@ -267,6 +275,11 @@ def own_esa(m): return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 +def own_benefits(m): + # UC reg 5(5): 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) + + def reference_specified_benefit(members, benunit_inputs, scheme): """UC Regs 2013 reg 83(1) or HB Regs 2006 reg 75F(1), read directly, with roles known by construction.""" @@ -277,7 +290,16 @@ def reference_specified_benefit(members, benunit_inputs, scheme): # school-leaver role here has no Child Benefit education or terminal-date # circumstance, so it is not one. young_roles = ("young_person", "sixteen") if scheme == "uc" else ("young_person",) - young_persons = [m for m in members if m["_role"] in young_roles] + # The UC predicate supplements its qualifying-young-person test with a + # separate age-16 approximation, which reads only the explicit own-right + # flag. Reported awards remove older UC young persons, but do not change + # that approximation or the Housing Benefit test. + young_persons = [ + m + for m in members + if m["_role"] in young_roles + and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) + ] children = [m for m in members if m["_role"] == "child"] # No capital, so the couple's income-related award is their reports. couple_award = any(m["esa_income_reported"] > 0 for m in claimants) @@ -372,16 +394,22 @@ def reference_union(members, benunit_inputs): ) -def reference_cap(members): +def reference_cap(members, scheme): # Each scheme's rate: a single claimant (UC reg 80A(2)(c)) or a claimant # who neither has a partner nor is a lone parent (HB regs 2(1), 75CA(2)), # with no child or young person. The family-rate helper counts any # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit's # school-leaver terminal dates, which annual ages cannot place); it does # not yet apply reg 5(5) to one receiving benefits in their own right. + # Older UC young persons cease to count when they report their own award; + # Housing Benefit continues to read the existing Child Benefit test. claimants = sum(m["_role"] == "claimant" for m in members) dependants = any( - m["_role"] in ("child", "young_person", "sixteen", "sixteen_own_right") + m["_role"] in ("child", "sixteen", "sixteen_own_right") + or ( + m["_role"] == "young_person" + and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) + ) for m in members ) return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP @@ -516,7 +544,7 @@ def test_exempt_families_have_no_cap_and_others_the_statutory_cap(drawn): if exempt[i]: assert np.isinf(cap[i]) and reduction[i] == 0, (scheme, units[i]) else: - assert cap[i] == reference_cap(members), (scheme, units[i]) + assert cap[i] == reference_cap(members, scheme), (scheme, units[i]) excess = max(total[part][i] - cap[i], 0) # UC reg 81: the excess less the childcare element (nil # here). HB reg 75D(2): no more than leaves reg 75's 50p a diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 6fc28d3f3a..9abd4683ae 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -31,6 +31,17 @@ def formula(person, period, parameters): "meets_qualifying_young_person_entry_condition_for_pension_credit", 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. + 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) + ) return ( person( "meets_qualifying_young_person_age_condition_for_pension_credit", @@ -40,5 +51,5 @@ def formula(person, period, parameters): before_first_september_following_16th_birthday | in_education_or_training ) - & ~person("receives_benefits_in_own_right", period) + & ~receives_own_benefits ) 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 1b1cfd602d..4281403300 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 @@ -36,6 +36,17 @@ 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. + 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) + ) return ( person( "meets_qualifying_young_person_age_condition_for_universal_credit", @@ -45,5 +56,5 @@ def formula(person, period, parameters): before_first_september_following_16th_birthday | in_education_or_training ) - & ~person("receives_benefits_in_own_right", period) + & ~receives_own_benefits ) From 781245da6125689c19c1b42c94019d5ada0c444d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 16:13:56 -0400 Subject: [PATCH 20/26] refactor: name the statutory QYP birthday age --- ...s_before_first_september_following_16th_birthday.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py index 22dc35fb9d..c1358f9e53 100644 --- a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py @@ -2,6 +2,10 @@ from policyengine_uk.utils.dates import birth_day +# UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a). +NO_EDUCATION_ROUTE_BIRTHDAY_AGE = 16 + + class is_before_first_september_following_16th_birthday(Variable): value_type = bool entity = Person @@ -36,12 +40,14 @@ class is_before_first_september_following_16th_birthday(Variable): def formula(person, period, parameters): date_of_birth = birth_day(person, period) - # The 16th birthday falls in the birth year plus 16; the 1 September + # The statutory birthday falls in the birth year plus its age; the 1 September # following it is in that year if the birthday is before 1 September, # and otherwise in the next. born_on_or_after_first_september = date_of_birth % 10_000 >= 901 year_of_first_september = ( - date_of_birth // 10_000 + 16 + born_on_or_after_first_september + date_of_birth // 10_000 + + NO_EDUCATION_ROUTE_BIRTHDAY_AGE + + born_on_or_after_first_september ) # The year starts on 6 April, before 1 September of the same calendar # year and after 1 September of the one before. From 4b09a360185feff341747aa1b9f34e48f7d89dfb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 16:18:50 -0400 Subject: [PATCH 21/26] test: cover own awards in young-single readers without route overrides --- .../test_legacy_award_readers_properties.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index bbbb79b4b6..9fa4db56d9 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -87,6 +87,7 @@ """ import numpy as np +import pytest from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st @@ -656,6 +657,7 @@ def _family_readers_never_change(drawn, enter_is_mixed_age_couple=False): values[starts[i] : starts[i] + n], values[starts[k + i] : starts[k + i] + n], ), (variable, drawn[i]) + return sim @SETTINGS @@ -682,6 +684,44 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): _family_readers_never_change(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. + family = ( + [ + { + "age": 18, + "is_benunit_head": True, + "current_education": "NOT_IN_EDUCATION", + **NO_AWARDS, + } + ], + [], + {"country": "SCOTLAND", "savings": 0}, + ) + other = outside_adult(16, is_benunit_head=False, **{report: 5_000}) + sim = _family_readers_never_change([(family, other)]) + assert sim.calculate("is_before_first_september_following_16th_birthday", YEAR)[-1] + assert not sim.calculate("receives_benefits_in_own_right", YEAR).any() + for programme in ("universal_credit", "pension_credit"): + assert not sim.calculate(f"is_qualifying_young_person_for_{programme}", YEAR)[ + -1 + ] + assert not sim.calculate( + f"is_child_or_qualifying_young_person_for_{programme}", YEAR + )[-1] + for variable in ( + "uc_child_element", + "is_uc_work_allowance_eligible", + "uc_work_allowance", + ): + assert not sim.calculate(variable, YEAR).any(), variable + for variable in ("uc_earned_income", "uc_unearned_income"): + values = sim.calculate(variable, YEAR) + assert np.isclose(values[0], values[1], atol=0.01), variable + + @SETTINGS @given( st.lists( From 33168ccbb8faf7a634d2ea5c36193503ffe32d69 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 00:08:31 -0400 Subject: [PATCH 22/26] Exclude a young person's own Income Support from Pension Credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPC Regs 2002 reg. 4A(5) ends qualifying young person status for a person receiving income support in their own right; UC Regs 2013 reg. 5(5) does not list it. Without this, a 70-year-old's 16-year-old reporting their own Income Support in 2025 counted under the new no-education route and drew a Pension Credit child addition of £4,061.20 (review of 4b09a3601, P2). The Pension Credit predicate now reads receives_income_support_in_own_right, taken from draft #2302: the person's own reported Income Support while it is in payment and no reform removes it, as is_on_income_support reads another member's own claim. Its documentation here names Pension Credit only, the one reader at this head. Tests: the 2025 case, the education route, a report after Income Support stops paying, a reform that removes it, and a one-penny report of each listed award (the review's P3: '> 0' changed to '>= 1' survived before). The YAML header now says the reports count whether the own-right flag is omitted or false, and the changelog fragment drops its list markers. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-pc-qyp-sixteen-route.fixed.md | 3 +- ...ying_young_person_reported_own_awards.yaml | 76 ++++++++++++++++++- ...sion_credit_young_person_income_support.py | 74 ++++++++++++++++++ ...lifying_young_person_for_pension_credit.py | 16 ++-- .../receives_income_support_in_own_right.py | 29 +++++++ 5 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_young_person_income_support.py create mode 100644 policyengine_uk/variables/household/income/receives_income_support_in_own_right.py diff --git a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md index b7da318f8c..a44cdf0a96 100644 --- a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md +++ b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md @@ -1,2 +1 @@ -- Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. -- Excluded young people reporting their own Universal Credit, income-related or contributory ESA, or income-based or contribution-based JSA from Universal Credit and Pension Credit qualifying young person status. +Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. Excluded young people reporting their own Universal Credit, income-related or contributory ESA, or income-based or contribution-based JSA from both statuses, and those reporting their own Income Support from Pension Credit's (UC Regs 2013 reg. 5(5); SPC Regs 2002 reg. 4A(5)). 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 e021ff475a..d0d093c39f 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,5 +1,7 @@ # UC reg. 5(5) and SPC reg. 4A(5) exclude own awards from both QYP routes. -# Omit receives_benefits_in_own_right: the reports identify the recipient. +# 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. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -423,3 +425,75 @@ is_qualifying_young_person_for_universal_credit: false 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). + period: 2025 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: false + income_support_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + country: ENGLAND + rent: 0 + savings: 0 + output: + is_before_first_september_following_16th_birthday: [false, true] + receives_income_support_in_own_right: [false, true] + 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] + +- name: Reported Income Support excludes the Pension Credit education route only. + period: 2025 + input: + age: 18 + current_education: UPPER_SECONDARY + income_support_reported: 5_000 + output: + is_qualifying_young_person_for_pension_credit: false + is_qualifying_young_person_for_universal_credit: true + +- name: Income Support reported once it is no longer paid leaves the Pension Credit route available. + # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a + # report then is no award in payment, as is_on_income_support reads it. + period: 2026 + input: + age: 16 + current_education: NOT_IN_EDUCATION + income_support_reported: 5_000 + output: + receives_income_support_in_own_right: false + is_qualifying_young_person_for_pension_credit: true + is_qualifying_young_person_for_universal_credit: true + +- name: A report of one penny of any listed own award excludes the young person. + # The sixth person reports only Income Support, which UC reg. 5(5) omits. + period: 2025 + input: + age: [16, 16, 16, 16, 16, 16] + current_education: [NOT_IN_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, 0] + esa_income_reported: [0, 0.01, 0, 0, 0, 0] + esa_contrib_reported: [0, 0, 0.01, 0, 0, 0] + jsa_income_reported: [0, 0, 0, 0.01, 0, 0] + jsa_contrib_reported: [0, 0, 0, 0, 0.01, 0] + income_support_reported: [0, 0, 0, 0, 0, 0.01] + output: + is_qualifying_young_person_for_universal_credit: [false, false, false, false, false, true] + is_qualifying_young_person_for_pension_credit: [false, false, false, false, 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 new file mode 100644 index 0000000000..648bfbbad1 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -0,0 +1,74 @@ +"""Pension Credit's young person and their own Income Support (SPC reg. 4A(5)). + +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. +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation + +YEAR = 2025 + + +class abolish_income_support(Reform): + def apply(self): + self.neutralize_variable("income_support") + + +def simulation(reform=None): + young_person = { + "age": 16, + "is_benunit_head": False, + "current_education": "NOT_IN_EDUCATION", + "income_support_reported": 3_000, + } + parent = {"age": 70, "is_parent": True, "is_benunit_head": True} + return Simulation( + situation={ + "people": { + name: {key: {YEAR: value} for key, value in inputs.items()} + for name, inputs in ( + ("parent", parent), + ("young_person", young_person), + ) + }, + "benunits": {"benunit": {"members": ["parent", "young_person"]}}, + "households": { + "household": { + "members": ["parent", "young_person"], + "country": {YEAR: "ENGLAND"}, + "rent": {YEAR: 0}, + "savings": {YEAR: 0}, + } + }, + }, + reform=reform, + ) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): + kept = simulation() + removed = simulation(abolish_income_support) + assert kept.calculate("receives_income_support_in_own_right", YEAR).tolist() == [ + False, + True, + ] + assert not removed.calculate("receives_income_support_in_own_right", YEAR).any() + assert kept.calculate( + "is_qualifying_young_person_for_pension_credit", YEAR + ).tolist() == [False, False] + assert removed.calculate( + "is_qualifying_young_person_for_pension_credit", YEAR + ).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, True] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 9abd4683ae..e57b0108cb 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -10,10 +10,10 @@ class is_qualifying_young_person_for_pension_credit(Variable): "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " "non-advanced education or approved training that meets the entry " "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " - "benefits in their own right (reg. 4A(5)). The 1 September after the " - "19th birthday in reg. 4A(1)(b) is not modelled, and the own-right " - "benefits input covers Universal Credit, Employment and Support " - "Allowance and Jobseeker's Allowance but not Income Support." + "Universal Credit, Employment and Support Allowance, Jobseeker's " + "Allowance or Income Support in their own right (reg. 4A(5)), as the " + "own-right flag or their own reported award shows. The model does not " + "apply reg. 4A(1)(b)'s end at the 1 September after the 19th birthday." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -31,9 +31,10 @@ def formula(person, period, parameters): "meets_qualifying_young_person_entry_condition_for_pension_credit", 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. 4A(5) applies to both routes. Raw person-level reports identify + # own awards without calculating family benefits, which depend on QYP + # status and could recurse. Unlike UC reg. 5(5), it lists income + # support. receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) @@ -41,6 +42,7 @@ def formula(person, period, parameters): | (person("esa_contrib_reported", period) > 0) | (person("jsa_income_reported", period) > 0) | (person("jsa_contrib_reported", period) > 0) + | person("receives_income_support_in_own_right", period) ) return ( person( diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py new file mode 100644 index 0000000000..fa32b4cf5b --- /dev/null +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class receives_income_support_in_own_right(Variable): + value_type = bool + entity = Person + label = "Receives Income Support in own right" + documentation = ( + "Whether this person receives Income Support on a claim of their own. " + "It ends qualifying young person status for Pension Credit (SPC Regs " + "2002 reg. 4A(5)); Universal Credit's list (UC Regs 2013 reg. 5(5)) " + "does not include it. By default it is the person's own reported " + "Income Support (income_support_reported) while Income Support is in " + "payment and no reform removes it (neutralizes income_support), as " + "is_on_income_support reads another member's own claim; set it " + "directly otherwise. It does not read the benefit unit's calculated " + "award, which belongs to the claimant and depends on who the family's " + "young persons are." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + # A reform that removes Income Support leaves no award to receive. + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized + return active & (not removed) & (person("income_support_reported", period) > 0) From c5b9850a3592769ba35f4d5b5c72c7ba8d2e6f22 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 10:12:17 -0400 Subject: [PATCH 23/26] Read is_on_income_support in the Pension Credit young person test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-report helper from 33168ccbb applied the rule for a non-claimant member to everyone. A claimant or partner needs a calculated award, as is_on_income_support already requires. So a reform replacing the income_support formula with zero left a young carer excluded, which kept them a counted resident and removed a co-resident pensioner's severe disability addition: £4,238 of payable Pension Credit in 2024. The predicate now reads is_on_income_support, and the helper goes. That variable keeps the non-claimant report, the positive-award test and the payee. Income Support's family follows the Child Benefit test and not this one, so the award does not depend on this status. A source graph of the model shows that outside the labour supply branches, is_on_income_support reaches 232 variables and none is this status or Pension Credit. The tests add the cross-unit regression at both reform mechanisms, both calculation orders, labour supply responses, a claimant with a report and no award, a partner who is not the payee, and an exhaustive grid for two invariants: nobody on Income Support is a Pension Credit young person, and zeroing or neutralizing the award gives claimants the same status. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-pc-qyp-sixteen-route.fixed.md | 2 +- ...ying_young_person_reported_own_awards.yaml | 159 +++++++++++-- ...sion_credit_young_person_income_support.py | 216 +++++++++++++++++- ...lifying_young_person_for_pension_credit.py | 12 +- .../receives_income_support_in_own_right.py | 29 --- 5 files changed, 352 insertions(+), 66 deletions(-) delete mode 100644 policyengine_uk/variables/household/income/receives_income_support_in_own_right.py diff --git a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md index a44cdf0a96..9ccff7ad6a 100644 --- a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md +++ b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md @@ -1 +1 @@ -Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. Excluded young people reporting their own Universal Credit, income-related or contributory ESA, or income-based or contribution-based JSA from both statuses, and those reporting their own Income Support from Pension Credit's (UC Regs 2013 reg. 5(5); SPC Regs 2002 reg. 4A(5)). +Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. Excluded young people reporting their own Universal Credit, income-related or contributory ESA, or income-based or contribution-based JSA from both statuses, and those receiving Income Support from Pension Credit's (UC Regs 2013 reg. 5(5); SPC Regs 2002 reg. 4A(5)). 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 d0d093c39f..3fd69d83e7 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,7 +1,9 @@ # 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. +# 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. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -453,47 +455,154 @@ savings: 0 output: is_before_first_september_following_16th_birthday: [false, true] - receives_income_support_in_own_right: [false, true] + is_on_income_support: [false, true] 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] -- name: Reported Income Support excludes the Pension Credit education route only. +- name: A member's own reported Income Support excludes the Pension Credit education route only. period: 2025 input: - age: 18 + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 18 + is_benunit_head: false + current_education: UPPER_SECONDARY + income_support_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + country: ENGLAND + 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] + +- 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. + period: 2026 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + income_support_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + country: ENGLAND + 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] + +- name: A claimant's calculated Income Support excludes them from the Pension Credit status. + period: 2025 + input: + age: 17 + is_carer_for_benefits: true current_education: UPPER_SECONDARY - income_support_reported: 5_000 + income_support_reported: 1 output: + 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 -- name: Income Support reported once it is no longer paid leaves the Pension Credit route available. - # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment, as is_on_income_support reads it. - period: 2026 +- name: A claimant's report without a calculated award leaves the Pension Credit status available. + # The claimant falls within no prescribed category, so the model calculates + # no Income Support for them. + period: 2025 input: - age: 16 - current_education: NOT_IN_EDUCATION - income_support_reported: 5_000 + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 1 output: - receives_income_support_in_own_right: false + income_support: 0 + is_on_income_support: false is_qualifying_young_person_for_pension_credit: true is_qualifying_young_person_for_universal_credit: true -- name: A report of one penny of any listed own award excludes the young person. - # The sixth person reports only Income Support, which UC reg. 5(5) omits. +- 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 + # not receiving it in their own right. period: 2025 input: - age: [16, 16, 16, 16, 16, 16] - current_education: [NOT_IN_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, 0] - esa_income_reported: [0, 0.01, 0, 0, 0, 0] - esa_contrib_reported: [0, 0, 0.01, 0, 0, 0] - jsa_income_reported: [0, 0, 0, 0.01, 0, 0] - jsa_contrib_reported: [0, 0, 0, 0, 0.01, 0] - income_support_reported: [0, 0, 0, 0, 0, 0.01] + people: + claimant: + age: 25 + is_parent: true + is_benunit_head: true + is_carer_for_benefits: true + income_support_reported: 1 + partner: + age: 17 + is_parent: true + is_benunit_head: false + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND output: - is_qualifying_young_person_for_universal_credit: [false, false, false, false, false, true] - is_qualifying_young_person_for_pension_credit: [false, false, false, false, false, false] + is_on_income_support: [true, false] + 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. + period: 2025 + input: + 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] + esa_income_reported: [0, 0.01, 0, 0, 0] + esa_contrib_reported: [0, 0, 0.01, 0, 0] + jsa_income_reported: [0, 0, 0, 0.01, 0] + jsa_contrib_reported: [0, 0, 0, 0, 0.01] + output: + 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. + period: 2025 + input: + people: + parent: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + income_support_reported: 0.01 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + country: ENGLAND + 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] 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 648bfbbad1..18e2464fd6 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 @@ -1,16 +1,28 @@ -"""Pension Credit's young person and their own Income Support (SPC reg. 4A(5)). +"""Pension Credit's young person and their Income Support (SPC reg. 4A(5)). 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. + +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 +person, so they count as another resident and end the severe disability +addition of a pensioner they live with (Sch. I para. 2(2)(f)). A reform that +ends the award restores that addition however it ends it. """ +from itertools import product + +import numpy as np import pytest +from policyengine_core.model_api import YEAR as YEAR_PERIOD from policyengine_core.reforms import Reform +from policyengine_core.variables import Variable from policyengine_uk import Simulation +from policyengine_uk.entities import BenUnit YEAR = 2025 @@ -20,6 +32,20 @@ def apply(self): self.neutralize_variable("income_support") +class income_support(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR_PERIOD + + def formula(benunit, period, parameters): + return benunit.filled_array(0) + + +class zero_income_support(Reform): + def apply(self): + self.update_variable(income_support) + + def simulation(reform=None): young_person = { "age": 16, @@ -51,15 +77,109 @@ def simulation(reform=None): ) +def inputs_for(year, values): + return {key: {year: value} for key, value in values.items()} + + +def young_carer_and_pensioner(reform=None, year=2024): + young = { + "age": 19, + "date_of_birth": 20051002, + "is_benunit_head": True, + "is_claimant_or_partner": True, + "is_carer_for_benefits": True, + "care_hours": 35, + "would_claim_carers_allowance": False, + "current_education": "UPPER_SECONDARY", + "age_started_or_accepted_current_education_or_training": 17, + "income_support_reported": 1, + } + pensioner = { + "age": 70, + "is_benunit_head": True, + "is_claimant_or_partner": True, + "would_claim_carers_allowance": False, + "attendance_allowance": 1_000, + } + return Simulation( + situation={ + "people": { + "young": inputs_for(year, young), + "pensioner": inputs_for(year, pensioner), + }, + "benunits": { + "young_unit": { + "members": ["young"], + **inputs_for( + year, {"would_claim_IS": True, "would_claim_uc": False} + ), + }, + "pensioner_unit": { + "members": ["pensioner"], + **inputs_for( + year, {"would_claim_pc": True, "would_claim_uc": False} + ), + }, + }, + "households": { + "home": { + "members": ["young", "pensioner"], + **inputs_for(year, {"country": "ENGLAND", "rent": 0, "savings": 0}), + } + }, + }, + reform=reform, + ) + + +def grid(year, reform=None): + """A young person of each kind, with a pensioner in the same household.""" + people, benunits, households = {}, {}, {} + cases = product( + (16, 17, 18, 19), + ("NOT_IN_EDUCATION", "UPPER_SECONDARY"), + (0, 0.01, 3_000), + (False, True), + (False, True), + ) + for index, (age, education, report, carer, shares_a_unit) in enumerate(cases): + young, elder = f"young_{index}", f"elder_{index}" + people[young] = inputs_for( + year, + { + "age": age, + "current_education": education, + "income_support_reported": report, + "is_carer_for_benefits": carer, + "is_benunit_head": not shares_a_unit, + }, + ) + people[elder] = inputs_for( + year, {"age": 70, "is_parent": True, "is_benunit_head": True} + ) + units = [[elder, young]] if shares_a_unit else [[elder], [young]] + for number, members in enumerate(units): + benunits[f"unit_{index}_{number}"] = {"members": members} + households[f"household_{index}"] = { + "members": [elder, young], + **inputs_for(year, {"country": "ENGLAND", "rent": 0, "savings": 0}), + } + return Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + }, + reform=reform, + ) + + @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): kept = simulation() removed = simulation(abolish_income_support) - assert kept.calculate("receives_income_support_in_own_right", YEAR).tolist() == [ - False, - True, - ] - assert not removed.calculate("receives_income_support_in_own_right", YEAR).any() + assert kept.calculate("is_on_income_support", YEAR).tolist() == [False, True] + assert not removed.calculate("is_on_income_support", YEAR).any() assert kept.calculate( "is_qualifying_young_person_for_pension_credit", YEAR ).tolist() == [False, False] @@ -72,3 +192,87 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): assert sim.calculate( "is_qualifying_young_person_for_universal_credit", YEAR ).tolist() == [False, True] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("ending", [zero_income_support, abolish_income_support]) +def test_a_reform_ending_a_young_carers_award_restores_a_pensioners_addition(ending): + year = 2024 + kept = young_carer_and_pensioner() + ended = young_carer_and_pensioner(ending) + assert kept.calculate("income_support", year)[0] > 0 + assert ended.calculate("income_support", year)[0] == 0 + assert kept.calculate( + "is_qualifying_young_person_for_pension_credit", year + ).tolist() == [False, False] + assert ended.calculate( + "is_qualifying_young_person_for_pension_credit", year + ).tolist() == [True, False] + addition = ended.calculate("severe_disability_minimum_guarantee_addition", year)[1] + assert kept.calculate("severe_disability_minimum_guarantee_addition", year)[1] == 0 + assert addition > 0 + assert ended.calculate("pension_credit", year)[1] == pytest.approx( + kept.calculate("pension_credit", year)[1] + addition + ) + + +@pytest.mark.parametrize( + "first, second", + [ + ("is_qualifying_young_person_for_pension_credit", "income_support"), + ("income_support", "is_qualifying_young_person_for_pension_credit"), + ("pension_credit", "is_on_income_support"), + ], +) +def test_pension_credit_status_and_income_support_calculate_in_either_order( + first, second +): + reference = young_carer_and_pensioner() + sim = young_carer_and_pensioner() + sim.calculate(first, 2024) + for variable in (first, second): + assert sim.calculate(variable, 2024).tolist() == ( + reference.calculate(variable, 2024).tolist() + ) + + +def test_labour_supply_responses_calculate_without_recursing_through_the_status(): + year = 2024 + sim = young_carer_and_pensioner( + { + "gov.simulation.labour_supply_responses.income_elasticity": { + str(year): -0.05 + }, + "gov.simulation.labour_supply_responses.substitution_elasticity": { + str(year): 0.15 + }, + } + ) + assert sim.calculate( + "is_qualifying_young_person_for_pension_credit", year + ).tolist() == [False, False] + assert sim.calculate("pension_credit", year)[1] > 0 + + +@pytest.mark.parametrize("year", [2025, 2026]) +def test_nobody_on_income_support_is_a_pension_credit_young_person(year): + sim = grid(year) + on_income_support = sim.calculate("is_on_income_support", year) + young_person = sim.calculate("is_qualifying_young_person_for_pension_credit", year) + assert not np.any(on_income_support & young_person) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_zeroing_or_neutralizing_income_support_gives_claimants_the_same_status(): + year = 2025 + zeroed = grid(year, zero_income_support) + neutralized = grid(year, abolish_income_support) + claimant = zeroed.calculate("is_claimant_or_partner", year) + for variable in ( + "is_on_income_support", + "is_qualifying_young_person_for_pension_credit", + ): + assert ( + zeroed.calculate(variable, year)[claimant].tolist() + == neutralized.calculate(variable, year)[claimant].tolist() + ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index e57b0108cb..125702ebd4 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -11,9 +11,10 @@ class is_qualifying_young_person_for_pension_credit(Variable): "non-advanced education or approved training that meets the entry " "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " "Universal Credit, Employment and Support Allowance, Jobseeker's " - "Allowance or Income Support in their own right (reg. 4A(5)), as the " - "own-right flag or their own reported award shows. The model does not " - "apply reg. 4A(1)(b)'s end at the 1 September after the 19th birthday." + "Allowance or Income Support in their own right (reg. 4A(5)). The " + "own-right flag or their own reported award shows the first three; " + "is_on_income_support shows Income Support. The model does not apply " + "reg. 4A(1)(b)'s end at the 1 September after the 19th birthday." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -34,7 +35,8 @@ def formula(person, period, parameters): # Reg. 4A(5) applies to both routes. Raw person-level reports identify # own awards without calculating family benefits, which depend on QYP # status and could recurse. Unlike UC reg. 5(5), it lists income - # support. + # support, whose family follows the Child Benefit test and not this + # one, so is_on_income_support can read the calculated award. receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) @@ -42,7 +44,7 @@ def formula(person, period, parameters): | (person("esa_contrib_reported", period) > 0) | (person("jsa_income_reported", period) > 0) | (person("jsa_contrib_reported", period) > 0) - | person("receives_income_support_in_own_right", period) + | person("is_on_income_support", period) ) return ( person( diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py deleted file mode 100644 index fa32b4cf5b..0000000000 --- a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py +++ /dev/null @@ -1,29 +0,0 @@ -from policyengine_uk.model_api import * - - -class receives_income_support_in_own_right(Variable): - value_type = bool - entity = Person - label = "Receives Income Support in own right" - documentation = ( - "Whether this person receives Income Support on a claim of their own. " - "It ends qualifying young person status for Pension Credit (SPC Regs " - "2002 reg. 4A(5)); Universal Credit's list (UC Regs 2013 reg. 5(5)) " - "does not include it. By default it is the person's own reported " - "Income Support (income_support_reported) while Income Support is in " - "payment and no reform removes it (neutralizes income_support), as " - "is_on_income_support reads another member's own claim; set it " - "directly otherwise. It does not read the benefit unit's calculated " - "award, which belongs to the claimant and depends on who the family's " - "young persons are." - ) - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" - - def formula(person, period, parameters): - active = parameters(period).gov.dwp.income_support.active - # A reform that removes Income Support leaves no award to receive. - removed = person.simulation.tax_benefit_system.get_variable( - "income_support" - ).is_neutralized - return active & (not removed) & (person("income_support_reported", period) > 0) From e35595651db0198bb91932900ec8a9acf19b9716 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:24:07 +0000 Subject: [PATCH 24/26] Bar Child Tax Credit qualifying young persons who receive Income Support CTC Regs 2002 reg. 5(4)(c) bars a period in which the young person receives Income Support, and reg. 4(2) keeps that second condition for the 16th-birthday period. is_qualifying_young_person_for_child_tax_credit now reads is_on_income_support on every route. The H1 merge brought main's eligibility gate on CTC_child_element (is_CTC_eligible), so the existing cases that read the element now give the claimant a reported award and the family no Universal Credit claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- .../ctc-qyp-school-leaver-routes.fixed.md | 2 +- ...child_tax_credit_school_leaver_routes.yaml | 148 ++++++++++++++++++ ..._credit_school_leaver_routes_properties.py | 88 ++++++++++- ...fying_young_person_for_child_tax_credit.py | 21 ++- 4 files changed, 247 insertions(+), 12 deletions(-) diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md index 33a0ea5ad5..091fbd5db6 100644 --- a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -- Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)). +Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)), and Income Support received by the young person ends every route (reg. 5(4)(c)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index 0db7aece61..1ad7341981 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -8,6 +8,10 @@ # # The child element was £3,455 in 2024-25, the last year of tax credits # (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). +# +# The child element needs an existing claim that a Universal Credit claim has +# not ended (is_CTC_eligible): each case that reads it gives the claimant a +# reported award and the family no Universal Credit claim. - name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). period: 2024 @@ -15,6 +19,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -25,6 +30,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] @@ -37,6 +43,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -48,6 +55,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_in_remunerative_work_for_child_tax_credit: [false, true] is_qualifying_young_person_for_child_tax_credit: [false, false] @@ -61,6 +69,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -72,6 +81,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -82,6 +92,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -93,6 +104,7 @@ benunits: benunit: members: [parent, pupil] + would_claim_uc: false output: # Reg. 5(4)(a) applies to a person "having ceased" education or training. is_qualifying_young_person_for_child_tax_credit: [false, true] @@ -104,6 +116,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -116,6 +129,7 @@ benunits: benunit: members: [parent, apprentice] + would_claim_uc: false output: # Education "by virtue of his employment" is left out of the first # condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts. The apprentice has @@ -130,6 +144,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -141,6 +156,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -151,6 +167,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -162,6 +179,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -172,6 +190,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -183,6 +202,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -193,6 +213,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -204,6 +225,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -214,6 +236,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -224,6 +247,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -234,6 +258,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -245,6 +270,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -255,6 +281,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -267,6 +294,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -277,6 +305,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -288,6 +317,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -298,12 +328,14 @@ input: people: school_leaver: + child_tax_credit_reported: 1 age: 16 current_education: NOT_IN_EDUCATION is_claimant_or_partner: true benunits: benunit: members: [school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: true is_child_or_qualifying_young_person_for_child_tax_credit: false @@ -315,6 +347,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -327,6 +360,7 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: # Advanced education fails the first condition, which reg. 4(2) lifts. # It is still full-time education (reg. 5(5)), so the student has not @@ -340,6 +374,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -351,6 +386,7 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -361,6 +397,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -373,7 +410,118 @@ benunits: benunit: members: [parent, apprentice] + would_claim_uc: false output: # Ceased full-time education and in remunerative work (reg. 5(4)(a)). is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 + +# Cases 18 to 20: the Income Support bar in reg. 5(4)(c), which reg. 4(2) +# keeps for the 16th-birthday period. 2018-19 rates: child element £2,780 and +# family element £545 a year (child_element.yaml, family_element.yaml); PIP +# daily living standard rate £57.30 a week; Carer's Allowance £64.60 a week; +# Child Benefit eldest rate £20.70 a week; colour TV licence £150.50. The +# disability cost-of-living payment parameter starts at £150 on 1 January +# 2022, and the model applies that first value to earlier years, so the +# household receives £150 for the parent's PIP. + +- name: Case 18, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4(2), reg. 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. + is_before_first_september_following_16th_birthday: [false, true] + # Not the claimant or partner, so their own report while Income Support + # is in payment. + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + # No child or qualifying young person, so no CTC. + child_tax_credit: 0 + # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; + # Child Benefit 20.70 x 52 = 1,076.40; cost-of-living payment 150; less + # TV licence 150.50: 7,414.70. + household_net_income: 7_414.70 + +- name: Case 19, the same family without Income Support for the 16-year-old gets CTC until 31 August 2018 (reg. 4(1)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_on_income_support: [false, false] + is_qualifying_young_person_for_child_tax_credit: [false, true] + # No income: child element 2,780 + family element 545 = 3,325. + child_tax_credit: 3_325 + # Case 18's 7,414.70 + 3,325 = 10,739.70. + household_net_income: 10_739.70 + +- name: Case 20, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 5(3)(a), 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1 + student: + age: 17 + date_of_birth: 20010115 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + # The first condition holds: full-time non-advanced education begun + # before 19. + meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit: [false, true] + is_before_first_september_following_16th_birthday: [false, false] + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + child_tax_credit: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 0381c3728a..1f24a96512 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -37,6 +37,11 @@ 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. +5. Reg. 5(4)(c), which reg. 4(2) keeps for the reg. 4 period: a member who + is not the claimant or partner and reports Income Support of their own + while it is in payment is never a qualifying young person, on any route; + once Income Support has stopped paying (from April 2026) or a reform + neutralises it, the report does not change the status. """ from datetime import date, timedelta @@ -79,6 +84,7 @@ def person(age, **inputs): "weekly_hours": 0, EXTENSION: False, "is_claimant_or_partner": False, + "income_support_reported": 0, **inputs, } @@ -169,6 +175,66 @@ def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): assert_regulation_4_matches_calendar(births, year) +INCOME_SUPPORT_IN_PAYMENT = {2018: True, 2024: True, 2026: False} + + +def qualifying_young_person_given_income_support(p, year): + # Reg. 5(4)(c): a member who is not the claimant or partner receives + # Income Support on their own report while Income Support is paid. + own_income_support = ( + INCOME_SUPPORT_IN_PAYMENT[year] + and not p["is_claimant_or_partner"] + and p["income_support_reported"] > 0 + ) + return qualifying_young_person(p) and not own_income_support + + +def test_income_support_ends_every_route(): + # Invariant 5 on every grid row that qualifies without Income Support. + qualifying = [p for p in GRID if qualifying_young_person(p)] + reporting = [dict(p, income_support_reported=3_000) for p in qualifying] + routes = { + "education": [first_condition_through_education(p) for p in qualifying], + "regulation 4": [ + p[ROUTE] and not first_condition_through_education(p) for p in qualifying + ], + "20 weeks": [ + p[EXTENSION] and not p[ROUTE] and not first_condition_through_education(p) + for p in qualifying + ], + } + assert all(any(rows) for rows in routes.values()) + for year in INCOME_SUPPORT_IN_PAYMENT: + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] + for p in reporting + ] + status = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal( + status, + [qualifying_young_person_given_income_support(p, year) for p in reporting], + ) + if INCOME_SUPPORT_IN_PAYMENT[year]: + assert not status.any(), year + else: + assert status.all(), year + + +def test_income_support_does_not_end_the_status_once_neutralised(): + # Invariant 5: a reform that removes Income Support leaves no award. + rows = [ + person(16, income_support_reported=3_000, **{ROUTE: True}), + person(17, current_education="UPPER_SECONDARY", income_support_reported=3_000), + person(17, income_support_reported=3_000, **{EXTENSION: True}), + ] + units = [[person(45, is_claimant_or_partner=True), p] for p in rows] + assert not simulate(units, 2018).calculate(QYP, 2018)[1::2].any() + removed = simulate(units, 2018) + removed.tax_benefit_system.neutralize_variable("income_support") + removed.delete_arrays("income_support") + assert removed.calculate(QYP, 2018)[1::2].all() + + def receiving_education_or_training(p): # Education status stands in for full-time non-advanced education that is # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). @@ -310,6 +376,7 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): receives_benefits_in_own_right=st.booleans(), is_looked_after_by_local_authority=st.booleans(), weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + income_support_reported=st.sampled_from([0, 0, 3_000]), **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, ) @@ -318,23 +385,34 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): def benefit_units(draw): claimants = draw(st.lists(member, min_size=1, max_size=2)) others = draw(st.lists(member, max_size=4)) - return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + # Claimants report no Income Support, so the family claims none and the + # model calculates no award; another member's report is their own claim. + return [ + dict(p, is_claimant_or_partner=True, income_support_reported=0) + for p in claimants + ] + others @SETTINGS -@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2020, 2024])) +@given( + st.lists(benefit_units(), min_size=1, max_size=6), + st.sampled_from(sorted(INCOME_SUPPORT_IN_PAYMENT)), +) def test_family_members_in_any_benefit_unit(units, year): - # Invariants 2 and 4 on benefit units of any shape, where a claimant or + # Invariants 2, 4 and 5 on benefit units of any shape, where a claimant or # partner can be a qualifying young person by status. sim = simulate(units, year) people = [p for unit in units for p in unit] status = sim.calculate(QYP, year) - np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in people]) + expected_status = [ + qualifying_young_person_given_income_support(p, year) for p in people + ] + np.testing.assert_array_equal(status, expected_status) family = sim.calculate( "is_child_or_qualifying_young_person_for_child_tax_credit", year ) expected = [ - (p["age"] < 16 or qualifying_young_person(p)) + (p["age"] < 16 or qualifying_young_person_given_income_support(p, year)) and not p["is_claimant_or_partner"] and not p["is_looked_after_by_local_authority"] for p in people diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index e23714dd8b..c54648254a 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -26,11 +26,18 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "no one may be receiving a benefit in their own right. Full-time " "education at any level counts (reg. 5(5)), so an advanced student " "has not ceased it, nor has an apprentice still on a course; the " - "employment exclusion is part of the first condition only. The " - "own-right benefits " - "input covers Universal Credit, Employment and Support Allowance and " - "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " - "Income Support and the income-related and income-based allowances. " + "employment exclusion is part of the first condition only. Reg. " + "5(4)(c) bars every route for a person who receives Income Support, " + "and the formula reads is_on_income_support for it: the award the " + "model calculates for a claimant or partner, and their own reported " + "award for any other member while Income Support is in payment and " + "no reform neutralises it. A reform that replaces Income Support's " + "formula with zero ends a claimant's or partner's receipt; for " + "another member it must neutralise income_support or set their " + "reported award to zero. The own-right benefits input covers " + "Universal Credit, Employment and Support Allowance and Jobseeker's " + "Allowance of either kind, where reg. 5(4)(c) lists only Universal " + "Credit and the income-related and income-based allowances. " "Interruptions (reg. 5(7)) are not modelled." ) definition_period = YEAR @@ -84,6 +91,8 @@ def formula(person, period, parameters): period, ) & (in_education_or_training | in_regulation_4_period | in_20_weeks) - # Reg. 5(4)(c). + # Reg. 5(4)(c): Universal Credit, ESA and JSA in their own right, + # and Income Support, which reg. 4(2) keeps for the reg. 4 period. & ~person("receives_benefits_in_own_right", period) + & ~person("is_on_income_support", period) ) From e5f239b3a72cadde6c384ef3bdc39a80e18b6c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:33:40 +0000 Subject: [PATCH 25/26] Fix the CTC Income Support properties' inputs and start the any-year test at 2015 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- ...x_credit_school_leaver_routes_properties.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 1f24a96512..5796d5c8e0 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -167,7 +167,10 @@ def test_regulation_4_for_every_date_of_birth(): @SETTINGS @given( st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), - st.integers(2013, 2031), + # From 2015: the status reads is_on_income_support, and Income Support + # reads Pension Credit's mixed-age couple date, which has no value + # before 2015 (child_tax_credit fails there already). + st.integers(2015, 2031), ) def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): births = [born for born in births if age_on_6_october(born, year) >= 0] @@ -224,10 +227,17 @@ def test_income_support_does_not_end_the_status_once_neutralised(): # Invariant 5: a reform that removes Income Support leaves no award. rows = [ person(16, income_support_reported=3_000, **{ROUTE: True}), - person(17, current_education="UPPER_SECONDARY", income_support_reported=3_000), - person(17, income_support_reported=3_000, **{EXTENSION: True}), + person( + 17, + current_education="UPPER_SECONDARY", + income_support_reported=3_000, + **{ROUTE: False}, + ), + person(17, income_support_reported=3_000, **{ROUTE: False, EXTENSION: True}), + ] + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in rows ] - units = [[person(45, is_claimant_or_partner=True), p] for p in rows] assert not simulate(units, 2018).calculate(QYP, 2018)[1::2].any() removed = simulate(units, 2018) removed.tax_benefit_system.neutralize_variable("income_support") From a447fe760351e09929bb0010ab0acc28ede23653 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:50:13 +0000 Subject: [PATCH 26/26] Read own reported awards in the Child Benefit qualifying young person test #2279's test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged failed at H1: a 16-year-old outside education who reported their own income-related ESA or income-based JSA became a Child Benefit qualifying young person through reg. 4, because the test read only the own-right flag. Child Benefit (General) Regulations 2006 reg. 8(2)(b), (e) and (f) list income-based JSA, ESA and Universal Credit, so the test now also reads the person's own reports of them, as the Universal Credit and Pension Credit tests do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- .../cb-qyp-school-leaver-routes.fixed.md | 2 +- .../child_benefit_school_leaver_routes.yaml | 48 +++++++++++++++++++ ...alifying_young_person_for_child_benefit.py | 22 +++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md index ae0e08f434..e1566ac616 100644 --- a/changelog.d/cb-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -- Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). +Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance is not one (reg. 8). diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index 75a9d25169..385a88be11 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -392,3 +392,51 @@ is_qualifying_young_person_for_child_benefit: true is_child_or_qualifying_young_person_for_child_benefit: false child_benefit_entitlement: 0 + +- name: Case 17, a 16-year-old who reports their own income-related ESA is not a qualifying young person (reg. 8(2)(e)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false + +- name: Case 18, a 16-year-old who reports their own income-based JSA is not a qualifying young person (reg. 8(2)(b)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 03a6cdd3ce..5d5b157ff4 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -19,10 +19,13 @@ class is_qualifying_young_person_for_child_benefit(Variable): "August (1 September for a 16th birthday on 31 August) falls in the " "same fiscal year as Universal Credit's 1 September, so a year that " "starts before one starts before the other. Interruptions (reg. 6) " - "are not modelled. The own-right benefits input covers Universal " - "Credit, Employment and Support Allowance and Jobseeker's Allowance " - "but not the Income Support, incapacity benefit and tax credits that " - "reg. 8 also lists." + "are not modelled. Reg. 8 reads the own-right benefits input, which " + "covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance, and the person's own reported Universal " + "Credit, Employment and Support Allowance of either kind and " + "income-based Jobseeker's Allowance, the awards reg. 8(2)(b), (e) " + "and (f) list. It does not read the Income Support, incapacity " + "benefit and tax credits that reg. 8 also lists." ) definition_period = YEAR reference = [ @@ -56,6 +59,15 @@ def formula(person, period, parameters): ) # Reg. 7: until the terminal date after leaving. before_terminal_date = person("is_before_child_benefit_terminal_date", period) + # Reg. 8(2)(b), (e) and (f): income-based JSA, ESA and Universal + # Credit. Reports are inputs, so reading them makes no cycle. + 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) + ) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", @@ -67,5 +79,5 @@ def formula(person, period, parameters): | in_extension_period | before_terminal_date ) - & ~person("receives_benefits_in_own_right", period) + & ~receives_own_benefits )