From 6af701944e985ca93f280bbf7b694a67abf43273 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:09:35 -0400 Subject: [PATCH 1/3] 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 2ef1d8dbf968fb123429be3dbbf7a56e190ca2da Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:45:06 -0400 Subject: [PATCH 2/3] 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 b171ef789fa78026fced50a602ddcd62fc118494 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:53:07 -0400 Subject: [PATCH 3/3] 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 = (