From 4e536b5d7e806857c18c59a789a9ba6ad8f136cc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 18:49:17 -0400 Subject: [PATCH 01/27] Apply the Universal Credit education condition to full-time students Add the person input is_in_full_time_education (false unless set), the reg 12 test uc_receiving_education, the reg 14(1) and SI 2014/1230 reg 60 exceptions in uc_is_excepted_from_education_condition, and the WRA 2012 s.4(1)(d) basic condition meets_uc_education_condition. is_uc_eligible needs one claimant to meet it, as reg 3(2)(b) provides for a couple. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2346.fixed.md | 1 + .../dwp/universal_credit/is_uc_eligible.py | 22 +++- .../meets_uc_education_condition.py | 27 ++++ ...uc_is_excepted_from_education_condition.py | 119 ++++++++++++++++++ .../uc_receiving_education.py | 62 +++++++++ .../demographic/is_in_full_time_education.py | 23 ++++ 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2346.fixed.md create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py create mode 100644 policyengine_uk/variables/household/demographic/is_in_full_time_education.py diff --git a/changelog.d/2346.fixed.md b/changelog.d/2346.fixed.md new file mode 100644 index 0000000000..9d89ed80f0 --- /dev/null +++ b/changelog.d/2346.fixed.md @@ -0,0 +1 @@ +Universal Credit now applies the condition that a claimant is not receiving education, with its exceptions, to people flagged with the new `is_in_full_time_education` input. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index bfd3232943..e77a662661 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -13,7 +13,10 @@ class is_uc_eligible(Variable): "nor can a partner who cannot be a joint claimant: where a member of " "a couple claims as a single person (regulation 3(3)), that member " "must meet the conditions, and the capital tested includes the other " - "member's (regulation 18(2)). A family on the Pension Credit route " + "member's (regulation 18(2)). A claimant must also meet, or be " + "excepted from, the condition that they are not receiving education " + "(meets_uc_education_condition); for joint claimants, one who does " + "suffices (regulation 3(2)(b)). A family on the Pension Credit route " "(meets_pension_credit_age_conditions) is not eligible unless it " "claims when DWP moves it off its legacy benefits " "(claims_uc_at_legacy_closure)." @@ -23,6 +26,7 @@ class is_uc_eligible(Variable): "https://www.legislation.gov.uk/ukpga/2012/5/section/3", "https://www.legislation.gov.uk/ukpga/2012/5/section/4", "https://www.legislation.gov.uk/uksi/2013/376/regulation/3", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/14", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", ) @@ -84,4 +88,18 @@ def formula(benunit, period, parameters): route = (has_qualifying_claimant & ~pension_credit_route) | ( closure_claim & (has_qualifying_claimant | reg_60a) ) - return route & (capital <= limit) + # WRA 2012 s.4(1)(d): "is not receiving education". A couple can be + # joint claimants where "one member does not meet the basic condition + # in section 4(1)(d) (not receiving education) and is not excepted + # from that condition if the other member does meet that condition or + # is excepted from it" (reg 3(2)(b)), so one claimant meeting it + # suffices, as one claimant under the qualifying age does under reg + # 3(2)(a). The two need not be the same member: reg 14(1)(f) excepts + # the older member of a mixed-age couple, which matters only if the + # younger member can meet the age condition while the older meets + # this one. A member of a couple who claims as a single person (reg + # 3(3)) must meet it themselves. + meets_education_condition = benunit.any( + claimant & benunit.members("meets_uc_education_condition", period) + ) + return route & meets_education_condition & (capital <= limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py new file mode 100644 index 0000000000..773c88ccd8 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class meets_uc_education_condition(Variable): + value_type = bool + entity = Person + label = "Meets the Universal Credit condition of not receiving education" + documentation = ( + "Whether this person meets the basic condition in the Welfare Reform " + "Act 2012 s. 4(1)(d) that a claimant is not receiving education, or " + "is excepted from it: they are not receiving education " + "(uc_receiving_education), or an exception applies " + "(uc_is_excepted_from_education_condition). The Welfare Reform " + "(Northern Ireland) Order 2015 art. 9(1)(d) sets the same condition. " + "is_uc_eligible applies it to each claimant, as the Universal Credit " + "Regulations 2013 reg. 3(2)(b) provides for a couple." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/4", + "https://www.legislation.gov.uk/nisi/2015/2006/article/9", + ) + + def formula(person, period, parameters): + return ~person("uc_receiving_education", period) | person( + "uc_is_excepted_from_education_condition", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py new file mode 100644 index 0000000000..c422b18944 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -0,0 +1,119 @@ +from policyengine_uk.model_api import * + + +class uc_is_excepted_from_education_condition(Variable): + value_type = bool + entity = Person + label = "Excepted from the Universal Credit condition of not receiving education" + documentation = ( + "Whether this person does not have to meet the basic condition in the " + "Welfare Reform Act 2012 s. 4(1)(d) (not receiving education), under " + "the Universal Credit Regulations 2013 reg. 14(1) or the Universal " + "Credit (Transitional Provisions) Regulations 2014 reg. 60. The model " + "applies these limbs: (b) entitlement to Attendance Allowance " + "(including Armed Forces Independence Payment, reg. 2), Disability " + "Living Allowance or PIP with limited capability for work " + "(uc_limited_capability_for_work); (c) responsibility for a child or " + "qualifying young person in the benefit unit; (d) a single foster " + "parent with whom a child is placed; (e)(ii) a member of a couple " + "who both receive education, whose partner is such a foster parent; " + "(f) the member of a mixed-age couple who has reached the qualifying " + "age for State Pension Credit; and reg. 60(1), a member of a family " + "that claims Universal Credit when DWP moves it off its legacy " + "benefits (claims_uc_at_legacy_closure) and is not a qualifying young " + "person. Limb (e)(i) needs no rule of its own: the model places " + "responsibility for a child on the benefit unit, so both members of " + "the couple meet (c). Not modelled: (a), a person under 21 in " + "non-advanced education without parental support, which the model " + "does not observe; under (b), whether the limited capability for " + "work was determined before the course began, the other benefits it " + "lists (Scottish disability assistance, constant attendance " + "allowance and war pension attendance payments), which the model " + "has no variables for; and reg. 60(2), which ends the transitional " + "protection. A foster parent is one flagged by either input, " + "uc_is_responsible_foster_parent_of_child_under_one or " + "uc_is_foster_parent_or_new_friend_or_family_carer. The second also " + "holds a foster parent between placements, with no child placed, " + "and a friend or family carer, who is responsible for the child and " + "so meets (c) under the law. The " + "Universal Credit Regulations (Northern Ireland) 2016 reg. 14(1) " + "lists the same exceptions." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/14", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", + ) + + def formula(person, period, parameters): + benunit = person.benunit + claimant_or_partner = person("is_uc_assessed_claimant", period) + in_couple = benunit.sum(claimant_or_partner) == 2 + # Reg. 14(1)(b): "entitled to attendance allowance, ... disability + # living allowance, ... or personal independence payment" and + # determined to have limited capability for work. + disability_benefit = ( + (person("attendance_allowance", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + | (person("dla", period) > 0) + | (person("pip", period) > 0) + ) + disabled_with_limited_capability_for_work = disability_benefit & person( + "uc_limited_capability_for_work", period + ) + # Reg. 14(1)(c): "responsible for a child or a qualifying young + # person". + responsible_for_child = claimant_or_partner & benunit( + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + period, + ) + foster_parent = claimant_or_partner & ( + person("uc_is_responsible_foster_parent_of_child_under_one", period) + | person("uc_is_foster_parent_or_new_friend_or_family_carer", period) + ) + # Reg. 14(1)(d): "a single person and a foster parent with whom a + # child is placed". + single_foster_parent = foster_parent & ~in_couple + # Reg. 14(1)(e)(ii): "a member of a couple, both of whom are + # receiving education, and the other member is ... a foster parent + # with whom a child is placed". + both_receiving_education = ( + benunit.sum(claimant_or_partner & person("uc_receiving_education", period)) + == 2 + ) + partner_is_foster_parent = benunit.sum(foster_parent) - foster_parent > 0 + student_couple_with_foster_partner = ( + claimant_or_partner + & in_couple + & both_receiving_education + & partner_is_foster_parent + ) + # Reg. 14(1)(f): "has reached the qualifying age for state pension + # credit" and "is a member of a couple the other member of which has + # not reached that age". + older_member_of_mixed_age_couple = ( + claimant_or_partner + & person("has_attained_state_pension_credit_qualifying_age", period) + & benunit("is_mixed_age_couple", period) + ) + # SI 2014/1230 reg. 60(1): where a person who has made a qualifying + # claim does not meet the condition "because the person is + # undertaking a full-time course (see regulation 12(2) and 13 ...), + # that condition is not to apply ... while they are continuing to + # undertake that course". A qualifying young person receives + # education under reg. 12(1A), not reg. 12(2). + migrated_student = ( + claimant_or_partner + & benunit("claims_uc_at_legacy_closure", period) + & ~person("is_qualifying_young_person_for_universal_credit", period) + ) + return ( + disabled_with_limited_capability_for_work + | responsible_for_child + | single_foster_parent + | student_couple_with_foster_partner + | older_member_of_mixed_age_couple + | migrated_student + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py new file mode 100644 index 0000000000..e737fd2cab --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -0,0 +1,62 @@ +from policyengine_uk.model_api import * + + +class uc_receiving_education(Variable): + value_type = bool + entity = Person + label = "Receiving education for Universal Credit" + documentation = ( + "Whether this person is receiving education under the Universal " + "Credit Regulations 2013 reg. 12, and so does not meet the basic " + "condition in the Welfare Reform Act 2012 s. 4(1)(d) unless an " + "exception applies (uc_is_excepted_from_education_condition). A " + "person on a full-time course (is_in_full_time_education) receives " + "education where the course is advanced education (reg. 12(2)(a)), " + "which the model reads from tertiary current_education or in_HE; " + "where a student loan or grant is provided for their maintenance " + "(reg. 12(2)(b)), which it reads from student_loans or " + "education_grants; or where they are a qualifying young person " + "(reg. 12(1A)). education_grants can also hold grants that are not " + "for maintenance, such as Disabled Students' Allowance. A person " + "with is_in_full_time_education false is not receiving education in " + "the model, so it does not apply reg. 12(1A) to a qualifying young " + "person outside full-time education, reg. 12(4) to a course that is " + "incompatible with a work-related requirement, or the exception in " + "reg. 12(1A) for a relevant training scheme. Under reg. 13 a person " + "undertakes a course from its first day to its last, vacations " + "included, unless they abandon it or are dismissed; the model " + "applies the input to the whole year. The Universal Credit " + "Regulations (Northern Ireland) 2016 reg. 12 is the same, except that " + "its reg. 12(1) has no exception for a training scheme." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/12", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/13", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/68", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/12", + ) + + def formula(person, period, parameters): + full_time = person("is_in_full_time_education", period) + # Reg. 12(3): a course leading to a degree, DipHE or HND, or "of a + # standard above a general certificate of education (advanced + # level), or above a Scottish national qualification (higher or + # advanced higher)". + education = person("current_education", period) + advanced = (education == education.possible_values.TERTIARY) | person( + "in_HE", period + ) + # Reg. 12(2)(b): "any other full-time course of study or training at + # an educational establishment for which a student loan or grant is + # provided for the person's maintenance" (both defined in reg. 68(7)). + maintenance_support = (person("student_loans", period) > 0) | ( + person("education_grants", period) > 0 + ) + # Reg. 12(1A): "A qualifying young person is to be treated as + # receiving education", whatever the course. + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + return full_time & (advanced | maintenance_support | qualifying_young_person) diff --git a/policyengine_uk/variables/household/demographic/is_in_full_time_education.py b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py new file mode 100644 index 0000000000..838935365e --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_in_full_time_education(Variable): + value_type = bool + entity = Person + label = "On a full-time course of education or training" + documentation = ( + "Whether this person is undertaking a full-time course of study or " + "training, including the vacations within the course. An input that " + "is false unless set: no formula derives it, and a dataset that " + "does not supply it leaves it false. It says only that the course is " + "full-time; current_education and in_HE give its level. Universal " + "Credit reads it for the condition that a claimant is not receiving " + "education (uc_receiving_education)." + ) + definition_period = YEAR + set_input = set_input_dispatch_by_period + default_value = False + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/12", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/13", + ) From 05eeb912b93cb9dd50faf37f79ab69ccf3dc93a5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:18:12 -0400 Subject: [PATCH 02/27] Test the Universal Credit education condition with hand-worked cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-two cases: the issue's Plymouth student with the input true (nil) and false (£6,348.91), a single parent student, couples with one or two students, a student couple with a child or a foster parent, mixed-age couples, PIP with and without limited capability for work, a qualifying young person, loans and grants on non-advanced courses, a reg 3(3) single claim, a legacy closure claim, Northern Ireland and 2016. Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 575 ++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml new file mode 100644 index 0000000000..d039855b71 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -0,0 +1,575 @@ +# Universal Credit's basic condition that a claimant "is not receiving +# education" (Welfare Reform Act 2012 s. 4(1)(d)), with the meaning in the +# Universal Credit Regulations 2013 reg. 12, the exceptions in reg. 14(1) and +# the couple rule in reg. 3(2)(b). +# +# Expected amounts use DWP's monthly rates for 2026 to 2027 (Benefit and +# pension rates 2026 to 2027, gov.uk): standard allowance £338.58 for a single +# claimant under 25, £424.90 for one aged 25 or over, £528.34 for joint +# claimants both under 25 and £666.97 where one or both are 25 or over; child +# element £351.88 for a first child born before 6 April 2017 and £303.94 for +# any other child. The award is 12 times the monthly amount, less 55% of +# earnings (reg. 22(1)(b)); no case has housing costs except cases 1 and 2. + +- name: Case 1, issue 2346, a full-time university student aged 19 gets no Universal Credit. + # Reg. 12(2)(a): a full-time course of advanced education (a first degree, + # reg. 12(3)(a)(ii)). No reg. 14(1) exception applies. + period: 2026 + input: + people: + person1: + age: 19 + current_education: TERTIARY + is_in_full_time_education: true + employment_income: 4_571 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: SOUTH_WEST + rent: 5_857 + tenure_type: RENT_PRIVATELY + brma: PLYMOUTH + output: + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [false] + meets_uc_education_condition: [false] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 2, issue 2346, the same student not on a full-time course keeps the award. + # 12 x £338.58 = £4,062.96 standard allowance, plus the housing costs + # element at the Universal Credit LHA rate for Plymouth, £400 a month, so + # £4,800, less 55% of £4,571 of pay = £2,514.05 (no work allowance without + # a child or limited capability for work): £8,862.96 - £2,514.05 = + # £6,348.91, as on main at 2da212abc. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 19 + current_education: TERTIARY + is_in_full_time_education: false + employment_income: 4_571 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: SOUTH_WEST + rent: 5_857 + tenure_type: RENT_PRIVATELY + brma: PLYMOUTH + output: + uc_receiving_education: [false] + is_uc_eligible: true + uc_standard_allowance: 4_062.96 + uc_housing_costs_element: 4_800 + uc_income_reduction: 2_514.05 + universal_credit: 6_348.91 + +- name: Case 3, a single parent on a full-time degree keeps the award. + # Reg. 14(1)(c): responsible for a child. 12 x (£338.58 + £303.94) = + # 12 x £642.52 = £7,710.24. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 22 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 3 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_receiving_education: [true, false] + uc_is_excepted_from_education_condition: [true, false] + meets_uc_education_condition: [true, true] + is_uc_eligible: true + universal_credit: 7_710.24 + +- name: Case 4, a couple where one member is a full-time student and the other is not claim jointly. + # Reg. 3(2)(b): one member does not meet the condition and is not + # excepted, and the other meets it, so they are joint claimants at the + # couple rate: 12 x £666.97 = £8,003.64. The model does not count student + # income (reg. 68), and this couple has none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 30 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + meets_uc_education_condition: [false, true] + is_uc_eligible: true + uc_standard_allowance: 8_003.64 + universal_credit: 8_003.64 + +- name: Case 5, a couple who are both full-time students with no exception get no Universal Credit. + # Reg. 3(2)(b) needs the other member to meet the condition or be + # excepted from it; neither does. + period: 2026 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + meets_uc_education_condition: [false, false] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 6, a student couple where the other member is responsible for a child keeps the award. + # Reg. 14(1)(e)(i): both members receive education and the other member + # is responsible for a child. The model places responsibility on the + # benefit unit, so each member is excepted under reg. 14(1)(c) too. + # 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 22 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 22 + current_education: TERTIARY + is_in_full_time_education: true + person3: + age: 5 + benunits: + benunit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + region: NORTH_EAST + output: + uc_receiving_education: [true, true, false] + uc_is_excepted_from_education_condition: [true, true, false] + is_uc_eligible: true + universal_credit: 9_987.36 + +- name: Case 7, a mixed-age couple who are both full-time students keep the award. + # Reg. 14(1)(f) excepts the member who has reached the qualifying age for + # State Pension Credit; reg. 3(2)(a) relieves them of the age condition + # and reg. 3(2)(b) the younger member of this one. Since 15 May 2019 the + # couple cannot get Pension Credit (SPCA 2002 s. 4(1A)). 12 x £666.97 = + # £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 60 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_receiving_education: [true, true] + uc_is_excepted_from_education_condition: [true, false] + meets_uc_education_condition: [true, false] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 8, a mixed-age couple whose younger member alone is a full-time student keep the award. + # The older member meets the education condition (reg. 3(2)(b)) and the + # younger the age condition (reg. 3(2)(a)). 12 x £666.97 = £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + person2: + age: 60 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + meets_uc_education_condition: [true, false] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 9, a student with PIP and limited capability for work keeps the award. + # Reg. 14(1)(b): entitled to PIP, with limited capability for work. + # 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + pip_dl_category: STANDARD + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 10, a student with PIP but not limited capability for work gets no Universal Credit. + # Reg. 14(1)(b) needs both. + period: 2026 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + pip_dl_category: STANDARD + uc_limited_capability_for_work: false + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [false] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 11, a single foster parent on a full-time course keeps the award. + # Reg. 14(1)(d). A foster child is not a member of the benefit unit, so + # the award is the standard allowance alone: 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + uc_is_responsible_foster_parent_of_child_under_one: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 12, a student couple whose other member is a foster parent keeps the award. + # Reg. 14(1)(e)(ii) excepts the member whose partner is the foster + # parent; reg. 3(2)(b) then lets the couple claim jointly. 12 x £666.97 = + # £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + uc_is_foster_parent_or_new_friend_or_family_carer: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true, false] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 13, a 17-year-old qualifying young person on a full-time course gets no Universal Credit. + # Reg. 8(1)(a) lets them claim at 16 with limited capability for work, but + # reg. 12(1A) treats a qualifying young person as receiving education, + # and without a disability benefit reg. 14(1)(b) does not apply. + period: 2026 + input: + people: + person1: + age: 17 + current_education: POST_SECONDARY + is_in_full_time_education: true + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + is_qualifying_young_person_for_universal_credit: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 14, the same 17-year-old not on a full-time course keeps the award. + # 12 x £338.58 = £4,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 17 + current_education: POST_SECONDARY + is_in_full_time_education: false + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 4_062.96 + +- name: Case 15, a non-advanced full-time course with a grant is receiving education. + # Reg. 12(2)(b): "any other full-time course ... for which a student loan + # or grant is provided for the person's maintenance". + period: 2026 + input: + people: + person1: + age: 30 + current_education: POST_SECONDARY + is_in_full_time_education: true + education_grants: 2_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 16, a non-advanced full-time course with no loan or grant is not receiving education. + # Neither limb of reg. 12(2) applies, and at 30 the person is not a + # qualifying young person. 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: POST_SECONDARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 17, a full-time course in higher education is advanced education. + # Reg. 12(2)(a) and (3), read from in_HE where current_education is unset. + period: 2026 + input: + people: + person1: + age: 30 + in_HE: true + is_in_full_time_education: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [true] + is_uc_eligible: false + +- name: Case 18, a member of a couple claiming as a single person must meet the condition. + # The other member cannot be a joint claimant (reg. 3(3)), so reg. 3(2)(b) + # does not apply and the student's claim fails. + period: 2026 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 30 + uc_is_ineligible_partner: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + meets_uc_education_condition: [false, true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 19, a student whose family claims when DWP moves it off legacy benefits keeps the award. + # SI 2014/1230 reg. 60(1): the condition does not apply while the person + # continues the course. 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1] + claims_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 20, a 17-year-old in full-time education does not change a parent's award. + # The condition applies to claimants, not to the qualifying young person + # they are responsible for. 12 x (£424.90 + £351.88) = 12 x £776.78 = + # £9,321.36: a child born in 2009 is a first child born before 6 April + # 2017. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + person2: + age: 17 + current_education: POST_SECONDARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_receiving_education: [false, true] + is_uc_eligible: true + universal_credit: 9_321.36 + +- name: Case 21, Northern Ireland applies the same condition. + # Welfare Reform (Northern Ireland) Order 2015 art. 9(1)(d) and the + # Universal Credit Regulations (Northern Ireland) 2016 reg. 12(2)(a). + period: 2026 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + output: + is_uc_eligible: false + universal_credit: 0 + +- name: Case 22, the condition applies in earlier years too. + # Reg. 12 and 14 have applied since 29 April 2013, and no parameter dates + # the condition. + period: 2016 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + is_uc_eligible: false From e2dc59a445f67aaf4a46253fa8bb2181491a725b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:30:48 -0400 Subject: [PATCH 03/27] Add property tests for the Universal Credit education condition Drawn families, each in a household of its own, check that the input never raises Universal Credit, that unflagged families match main's eligibility formula (kept verbatim), that eligibility matches a reference implementation of regs 3(2)(b), 12 and 14, that excepted claimants keep their award, and that only the couple's flags count. A further test pins the intended effect through non-dependants. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_receiving_education_properties.py | 524 ++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 policyengine_uk/tests/test_uc_receiving_education_properties.py diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py new file mode 100644 index 0000000000..6fec510689 --- /dev/null +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -0,0 +1,524 @@ +"""Universal Credit's condition that a claimant is not receiving education. + +The Welfare Reform Act 2012 s. 4(1)(d) makes it a basic condition that a +claimant "is not receiving education". The Universal Credit Regulations 2013 +reg. 12 says who is receiving education, reg. 14(1) lists exceptions, and reg. +3(2)(b) lets a couple claim jointly where "one member does not meet the basic +condition in section 4(1)(d) ... and is not excepted from that condition if +the other member does meet that condition or is excepted from it". Only a +person flagged is_in_full_time_education can receive education in the model. + +Each drawn case is a year and up to four families, each in a household of its +own: a single adult or a couple, up to two children or young people, and +inputs that reach every limb the model applies (course level, loans and +grants, PIP with or without limited capability for work, foster flags, an +ineligible partner, a legacy closure claim, earnings, rent and savings). The +same people are calculated four ways: as drawn; with the eligibility formula +on main before this change (BEFORE, kept verbatim below); with no one flagged; +and with the flags of members who are neither claimant nor partner reversed. +Invariants: + +1. Never raises: each family's Universal Credit is either what it is under + BEFORE or nil, so setting the input never raises it. +2. Unflagged families are unchanged: with no one flagged, every output listed + in OUTPUTS equals its value under BEFORE. +3. Differential: is_uc_eligible equals BEFORE's eligibility and a reference + implementation of regs. 3(2)(b), 12 and 14 that reads the drawn inputs. +4. Exceptions: a claimant who meets a modelled reg. 14 exception, or the SI + 2014/1230 reg. 60 protection, meets the condition, and their family gets + the Universal Credit it gets under BEFORE. +5. Only the couple counts: reversing the flags of members who are neither + claimant nor partner (children and qualifying young people) changes no + family's Universal Credit. A partner who cannot be a joint claimant (reg. + 3(3)) is still a member of the couple, so whether they receive education + can decide reg. 14(1)(e) for the claimant. + +A family in a household with other families can lose Universal Credit +because of another family's student: when the household head's family cannot +claim, its non-dependants count in the next claim by a family liable for the +rent (UC Regs 2013 Sch 4 para 9(2)(e) and (f)). That is intended, and +test_student_head_moves_non_dependant_to_joint_tenant pins it; the invariants +above hold for families with households of their own. +""" + +import json + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import * + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +PROPERTY_SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2019: before the mixed-age couple exclusion of 15 May 2019 (parameters are +# read at 30 April). 2021, 2024 and 2026 span the reg. 14(1)(b) versions. +YEARS = [2019, 2021, 2024, 2026] +REGIONS = ["NORTH_EAST", "LONDON", "SCOTLAND", "NORTHERN_IRELAND"] +EDUCATION = ["TERTIARY", "POST_SECONDARY", "NOT_IN_EDUCATION"] +# The Universal Credit outputs that read is_uc_eligible. A differential run +# against main on hand-built datasets covers every other output. +OUTPUTS = [ + "universal_credit", + "is_uc_eligible", + "uc_maximum_amount", + "uc_income_reduction", +] +rarely = st.integers(0, 4).map(lambda n: n == 0) +sometimes = st.integers(0, 2).map(lambda n: n == 0) + + +class is_uc_eligible(Variable): + value_type = bool + entity = BenUnit + label = "Eligible for Universal Credit (before the education condition)" + definition_period = YEAR + + def formula(benunit, period, parameters): + # main at 2da212abc, without its comments. + capital = benunit("uc_assessable_capital", period) + limit = parameters(period).gov.dwp.universal_credit.means_test.capital.limit + claimant = benunit.members("is_uc_single_or_joint_claimant", period) + meets_minimum_age = benunit.members("meets_uc_minimum_age_condition", period) + pension_age = benunit.members( + "has_attained_state_pension_credit_qualifying_age", period + ) + has_qualifying_claimant = benunit.any( + claimant & meets_minimum_age & ~pension_age + ) + pension_credit_route = benunit("meets_pension_credit_age_conditions", period) + closure_claim = benunit("claims_uc_at_legacy_closure", period) + on_working_tax_credit = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) + reg_60a = on_working_tax_credit & benunit.any(claimant & meets_minimum_age) + route = (has_qualifying_claimant & ~pension_credit_route) | ( + closure_claim & (has_qualifying_claimant | reg_60a) + ) + return route & (capital <= limit) + + +class before_education_condition(Reform): + def apply(self): + self.update_variable(is_uc_eligible) + + +@st.composite +def adult(draw, ages): + full_time = draw(sometimes) + return dict( + age=draw(ages), + is_in_full_time_education=full_time, + current_education=draw(st.sampled_from(EDUCATION)), + in_HE=draw(rarely), + student_loans=draw(st.sampled_from([0, 0, 0, 3_000])), + education_grants=draw(st.sampled_from([0, 0, 0, 1_500])), + pip_dl_category=draw(st.sampled_from(["NONE", "NONE", "STANDARD"])), + uc_limited_capability_for_work=draw(rarely), + uc_is_responsible_foster_parent_of_child_under_one=draw(rarely), + uc_is_foster_parent_or_new_friend_or_family_carer=draw(rarely), + employment_income=draw(st.sampled_from([0, 0, 4_000, 15_000])), + is_claimant_or_partner=True, + ) + + +@st.composite +def young_person(draw): + return dict( + age=draw(st.sampled_from([2, 10, 16, 17, 18])), + is_in_full_time_education=draw(st.booleans()), + current_education=draw(st.sampled_from(EDUCATION)), + is_claimant_or_partner=False, + ) + + +@st.composite +def family(draw): + ages = st.sampled_from([16, 17, 18, 19, 22, 30, 45, 61, 70, 80]) + people = [draw(adult(ages))] + if draw(st.booleans()): + partner = draw(adult(ages)) + partner["uc_is_ineligible_partner"] = draw(rarely) + people.append(partner) + people += draw(st.lists(young_person(), max_size=2)) + if len(people) > 1 and any(not p["is_claimant_or_partner"] for p in people): + for person in people: + if person["is_claimant_or_partner"]: + person["is_parent"] = True + tenure = draw(st.sampled_from(["RENT_FROM_COUNCIL", "OWNED_OUTRIGHT"])) + return dict( + people=people, + benunit=dict(claims_uc_at_legacy_closure=draw(rarely)), + household=dict( + region=draw(st.sampled_from(REGIONS)), + tenure_type=tenure, + rent=0 if tenure == "OWNED_OUTRIGHT" else 6_000, + savings=draw(st.sampled_from([0, 0, 20_000])), + ), + ) + + +@st.composite +def cases(draw): + return dict( + year=draw(st.sampled_from(YEARS)), + families=draw(st.lists(family(), min_size=1, max_size=4)), + ) + + +def situation(case, variants): + """Each family in a household of its own, once for each variant. A variant + is None for the drawn is_in_full_time_education, or a list giving each + person's value in order.""" + year = case["year"] + people, benunits, households = {}, {}, {} + for v, flags in enumerate(variants): + index = 0 + for f, fam in enumerate(case["families"]): + members = [] + for person in fam["people"]: + name = f"p{v}_{index}" + values = dict(person) + if flags is not None: + values["is_in_full_time_education"] = flags[index] + people[name] = {k: {year: x} for k, x in values.items()} + members.append(name) + index += 1 + benunits[f"b{v}_{f}"] = { + "members": members, + **{k: {year: x} for k, x in fam["benunit"].items()}, + } + households[f"h{v}_{f}"] = { + "members": members, + **{k: {year: x} for k, x in fam["household"].items()}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def all_flags(case): + return [ + p["is_in_full_time_education"] for f in case["families"] for p in f["people"] + ] + + +VARIABLES = OUTPUTS + [ + "is_uc_single_or_joint_claimant", + "is_uc_assessed_claimant", + "is_qualifying_young_person_for_universal_credit", + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + "has_attained_state_pension_credit_qualifying_age", + "is_mixed_age_couple", + "uc_limited_capability_for_work", + "pip", + "uc_receiving_education", + "uc_is_excepted_from_education_condition", + "meets_uc_education_condition", +] + + +def run(case, variants=(None,), before=False): + """One simulation of every variant; returns one result per variant.""" + simulation = Simulation(situation=situation(case, variants)) + if before: + simulation.apply_reform(before_education_condition) + year = case["year"] + values = {name: simulation.calculate(name, year) for name in VARIABLES} + sizes = { + "person": sum(len(f["people"]) for f in case["families"]), + "benunit": len(case["families"]), + "household": len(case["families"]), + } + results = [] + for v in range(len(variants)): + result = {} + for name, array in values.items(): + entity = simulation.tax_benefit_system.variables[name].entity.key + size = sizes[entity] + result[name] = array[v * size : (v + 1) * size] + results.append(result) + return results + + +def reference(case, result): + """Regs. 3(2)(b), 12 and 14 from the drawn inputs. + + Returns, per person, receiving education and excepted, and per family, + whether the family meets the education condition. Reads from the model + only the roles (who is a claimant), qualifying young person and pension + age status, responsibility for a child, limited capability for work and + the PIP amount, which other rules settle. + """ + receiving, excepted, family_meets = [], [], [] + index = 0 + for f, fam in enumerate(case["families"]): + start = index + members = range(start, start + len(fam["people"])) + couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + recv = {} + for i, person in zip(members, fam["people"]): + qyp = bool(result["is_qualifying_young_person_for_universal_credit"][i]) + advanced = person["current_education"] == "TERTIARY" or person.get( + "in_HE", False + ) + loan_or_grant = ( + person.get("student_loans", 0) > 0 + or person.get("education_grants", 0) > 0 + ) + recv[i] = person["is_in_full_time_education"] and ( + advanced or loan_or_grant or qyp + ) + foster = { + i: person.get("uc_is_responsible_foster_parent_of_child_under_one", False) + or person.get("uc_is_foster_parent_or_new_friend_or_family_carer", False) + for i, person in zip(members, fam["people"]) + } + exc = {} + for i, person in zip(members, fam["people"]): + in_couple_role = i in couple + partner = [j for j in couple if j != i] + limb_b = bool(result["pip"][i] > 0) and bool( + result["uc_limited_capability_for_work"][i] + ) + limb_c = in_couple_role and bool( + result[ + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit" + ][f] + ) + limb_d = in_couple_role and len(couple) == 1 and foster[i] + limb_e = ( + in_couple_role + and len(couple) == 2 + and all(recv[j] for j in couple) + and any(foster[j] for j in partner) + ) + limb_f = ( + in_couple_role + and bool(result["has_attained_state_pension_credit_qualifying_age"][i]) + and bool(result["is_mixed_age_couple"][f]) + ) + reg_60 = ( + in_couple_role + and fam["benunit"]["claims_uc_at_legacy_closure"] + and not result["is_qualifying_young_person_for_universal_credit"][i] + ) + exc[i] = limb_b or limb_c or limb_d or limb_e or limb_f or reg_60 + claimants = [i for i in members if result["is_uc_single_or_joint_claimant"][i]] + family_meets.append(any(not recv[i] or exc[i] for i in claimants)) + receiving += [recv[i] for i in members] + excepted += [exc[i] for i in members] + index += len(fam["people"]) + return np.array(receiving), np.array(excepted), np.array(family_meets) + + +EXAMPLES = [ + # Issue 2346: a 19-year-old on a full-time degree, alone. + dict( + year=2026, + families=[ + dict( + people=[ + dict( + age=19, + is_in_full_time_education=True, + current_education="TERTIARY", + employment_income=4_571, + is_claimant_or_partner=True, + ) + ], + benunit=dict(claims_uc_at_legacy_closure=False), + household=dict( + region="SOUTH_WEST", + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, + savings=0, + ), + ) + ], + ), + # A mixed-age couple who are both students (reg. 14(1)(f)), and a + # student couple with a foster parent (reg. 14(1)(e)(ii)). + dict( + year=2026, + families=[ + dict( + people=[ + dict( + age=70, + is_in_full_time_education=True, + current_education="TERTIARY", + is_claimant_or_partner=True, + ), + dict( + age=61, + is_in_full_time_education=True, + current_education="TERTIARY", + is_claimant_or_partner=True, + ), + ], + benunit=dict(claims_uc_at_legacy_closure=False), + household=dict( + region="NORTH_EAST", + tenure_type="OWNED_OUTRIGHT", + rent=0, + savings=0, + ), + ), + dict( + people=[ + dict( + age=30, + is_in_full_time_education=True, + current_education="TERTIARY", + is_claimant_or_partner=True, + ), + dict( + age=30, + is_in_full_time_education=True, + current_education="TERTIARY", + uc_is_foster_parent_or_new_friend_or_family_carer=True, + is_claimant_or_partner=True, + ), + ], + benunit=dict(claims_uc_at_legacy_closure=False), + household=dict( + region="NORTHERN_IRELAND", + tenure_type="OWNED_OUTRIGHT", + rent=0, + savings=0, + ), + ), + ], + ), +] + + +def check(case): + context = json.dumps(case) + flags = all_flags(case) + # Members who are neither claimant nor partner: is_claimant_or_partner is + # supplied, and a benefit unit has at most two adults. + couple = [ + p["is_claimant_or_partner"] for f in case["families"] for p in f["people"] + ] + reversed_flags = [flag if couple[i] else not flag for i, flag in enumerate(flags)] + drawn, unflagged, others_reversed = run( + case, variants=[None, [False] * len(flags), reversed_flags] + ) + (before,) = run(case, before=True) + claimant = drawn["is_uc_single_or_joint_claimant"] + np.testing.assert_array_equal(drawn["is_uc_assessed_claimant"], couple, context) + + uc = drawn["universal_credit"] + uc_before = before["universal_credit"] + # 1. Never raises: either BEFORE's amount or nil. + unchanged = np.isclose(uc, uc_before, atol=0.005) + assert np.all(unchanged | (uc == 0)), context + assert np.all(uc <= uc_before + 0.005), context + # 2. Unflagged families are unchanged. + for name in OUTPUTS: + np.testing.assert_allclose( + unflagged[name].astype(float), + before[name].astype(float), + atol=0.005, + err_msg=f"{name}: {context}", + ) + # 3. Differential against the reference implementation. + receiving, excepted, family_meets = reference(case, drawn) + np.testing.assert_array_equal(drawn["uc_receiving_education"], receiving, context) + np.testing.assert_array_equal( + drawn["uc_is_excepted_from_education_condition"], excepted, context + ) + np.testing.assert_array_equal( + drawn["is_uc_eligible"], before["is_uc_eligible"] & family_meets, context + ) + # 4. Exceptions keep the claimant, and so the family, in. + assert np.all(drawn["meets_uc_education_condition"][excepted]), context + index = 0 + for f, fam in enumerate(case["families"]): + members = range(index, index + len(fam["people"])) + if any(claimant[i] and excepted[i] for i in members): + assert np.isclose(uc[f], uc_before[f], atol=0.005), context + index += len(fam["people"]) + # 5. Only the couple counts. + np.testing.assert_allclose( + others_reversed["universal_credit"], uc, atol=0.005, err_msg=context + ) + np.testing.assert_array_equal( + others_reversed["is_uc_eligible"], drawn["is_uc_eligible"], context + ) + + +@PROPERTY_SETTINGS +@example(EXAMPLES[0]) +@example(EXAMPLES[1]) +@given(cases()) +def test_education_condition_invariants(case): + check(case) + + +def test_examples_reach_the_cases(): + """The examples exclude the issue's student and keep the excepted + couples, so the invariants run on each outcome.""" + (issue,) = run(EXAMPLES[0]) + assert not issue["is_uc_eligible"][0] + assert issue["universal_credit"][0] == 0 + assert run(EXAMPLES[0], before=True)[0]["universal_credit"][0] > 0 + (couples,) = run(EXAMPLES[1]) + np.testing.assert_array_equal(couples["is_uc_eligible"], [True, True]) + np.testing.assert_array_equal( + couples["uc_is_excepted_from_education_condition"], + [True, False, True, False], + ) + + +def test_student_head_moves_non_dependant_to_joint_tenant(): + """Intended: a family with no one flagged loses Universal Credit when the + household head's family is a full-time student's. + + The head's family cannot claim, so the non-dependant counts in the joint + tenant's claim instead (UC Regs 2013 Sch 4 para 9(2)(e) and (f)), which + then has the housing cost contribution of £96.55 a month in 2026 (Sch 4 + para 14(1), as SI 2026/148 art. 32(3) sets it): 12 x £96.55 = £1,158.60. + Two copies of the household, the head unflagged and then flagged. + """ + year = 2026 + people, benunits, households = {}, {}, {} + for copy, head_full_time in enumerate([False, True]): + head, tenant, other = (f"{role}{copy}" for role in ("head", "tenant", "nd")) + people[head] = { + "age": {year: 30}, + "current_education": {year: "TERTIARY"}, + "is_in_full_time_education": {year: head_full_time}, + } + people[tenant] = {"age": {year: 30}} + people[other] = {"age": {year: 30}, "employment_income": {year: 20_000}} + benunits[f"b_{head}"] = {"members": [head]} + benunits[f"b_{tenant}"] = { + "members": [tenant], + "liable_for_share_of_household_rent": {year: True}, + } + benunits[f"b_{other}"] = {"members": [other]} + households[f"h{copy}"] = { + "members": [head, tenant, other], + "region": {year: "NORTH_EAST"}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: 12_000}, + } + simulation = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + np.testing.assert_array_equal( + simulation.calculate("uc_non_dependants_counted", year), + [True, False, False, False, True, False], + ) + universal_credit = simulation.calculate("universal_credit", year) + assert np.isclose(universal_credit[1] - universal_credit[4], 12 * 96.55, atol=0.01) From 7334947f20d5e92c5ed37509a9a30a71ae6f9ebd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:43:03 -0400 Subject: [PATCH 04/27] Use active voice in the education condition documentation Rewords docstrings and test comments; no formula or expected value changes. Co-Authored-By: Claude Opus 5.5 --- .../universal_credit/uc_receiving_education.yaml | 5 +++-- .../test_uc_receiving_education_properties.py | 8 ++++---- .../gov/dwp/universal_credit/is_uc_eligible.py | 9 +++++---- .../uc_is_excepted_from_education_condition.py | 2 +- .../universal_credit/uc_receiving_education.py | 15 ++++++++------- .../demographic/is_in_full_time_education.py | 6 +++--- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index d039855b71..f44b02debe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -156,7 +156,7 @@ - name: Case 6, a student couple where the other member is responsible for a child keeps the award. # Reg. 14(1)(e)(i): both members receive education and the other member # is responsible for a child. The model places responsibility on the - # benefit unit, so each member is excepted under reg. 14(1)(c) too. + # benefit unit, so reg. 14(1)(c) excepts each member too. # 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. period: 2026 absolute_error_margin: 0.01 @@ -438,7 +438,8 @@ universal_credit: 5_098.80 - name: Case 17, a full-time course in higher education is advanced education. - # Reg. 12(2)(a) and (3), read from in_HE where current_education is unset. + # Reg. 12(2)(a) and (3): the model reads in_HE when current_education has + # no input. period: 2026 input: people: diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 6fec510689..22fedd66ae 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -13,14 +13,14 @@ inputs that reach every limb the model applies (course level, loans and grants, PIP with or without limited capability for work, foster flags, an ineligible partner, a legacy closure claim, earnings, rent and savings). The -same people are calculated four ways: as drawn; with the eligibility formula +test calculates the same people four ways: as drawn; with the eligibility formula on main before this change (BEFORE, kept verbatim below); with no one flagged; and with the flags of members who are neither claimant nor partner reversed. Invariants: 1. Never raises: each family's Universal Credit is either what it is under BEFORE or nil, so setting the input never raises it. -2. Unflagged families are unchanged: with no one flagged, every output listed +2. Unflagged families don't change: with no one flagged, every output listed in OUTPUTS equals its value under BEFORE. 3. Differential: is_uc_eligible equals BEFORE's eligibility and a reference implementation of regs. 3(2)(b), 12 and 14 that reads the drawn inputs. @@ -36,7 +36,7 @@ A family in a household with other families can lose Universal Credit because of another family's student: when the household head's family cannot claim, its non-dependants count in the next claim by a family liable for the -rent (UC Regs 2013 Sch 4 para 9(2)(e) and (f)). That is intended, and +rent (UC Regs 2013 Sch 4 para 9(2)(e) and (f)). The law intends that, and test_student_head_moves_non_dependant_to_joint_tenant pins it; the invariants above hold for families with households of their own. """ @@ -423,7 +423,7 @@ def check(case): unchanged = np.isclose(uc, uc_before, atol=0.005) assert np.all(unchanged | (uc == 0)), context assert np.all(uc <= uc_before + 0.005), context - # 2. Unflagged families are unchanged. + # 2. Unflagged families don't change. for name in OUTPUTS: np.testing.assert_allclose( unflagged[name].astype(float), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index e77a662661..ef60163e53 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -13,10 +13,11 @@ class is_uc_eligible(Variable): "nor can a partner who cannot be a joint claimant: where a member of " "a couple claims as a single person (regulation 3(3)), that member " "must meet the conditions, and the capital tested includes the other " - "member's (regulation 18(2)). A claimant must also meet, or be " - "excepted from, the condition that they are not receiving education " - "(meets_uc_education_condition); for joint claimants, one who does " - "suffices (regulation 3(2)(b)). A family on the Pension Credit route " + "member's (regulation 18(2)). A claimant must also meet, or fall " + "within an exception to, the condition that they are not receiving " + "education (meets_uc_education_condition); for joint claimants, one " + "who does suffices (regulation 3(2)(b)). A family on the Pension " + "Credit route " "(meets_pension_credit_age_conditions) is not eligible unless it " "claims when DWP moves it off its legacy benefits " "(claims_uc_at_legacy_closure)." diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index c422b18944..d1d2f6b921 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -30,7 +30,7 @@ class uc_is_excepted_from_education_condition(Variable): "lists (Scottish disability assistance, constant attendance " "allowance and war pension attendance payments), which the model " "has no variables for; and reg. 60(2), which ends the transitional " - "protection. A foster parent is one flagged by either input, " + "protection. A foster parent is a person either input flags, " "uc_is_responsible_foster_parent_of_child_under_one or " "uc_is_foster_parent_or_new_friend_or_family_carer. The second also " "holds a foster parent between placements, with no child placed, " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py index e737fd2cab..3b49cd91b5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -13,19 +13,20 @@ class uc_receiving_education(Variable): "person on a full-time course (is_in_full_time_education) receives " "education where the course is advanced education (reg. 12(2)(a)), " "which the model reads from tertiary current_education or in_HE; " - "where a student loan or grant is provided for their maintenance " + "where they have a student loan or grant for their maintenance " "(reg. 12(2)(b)), which it reads from student_loans or " "education_grants; or where they are a qualifying young person " "(reg. 12(1A)). education_grants can also hold grants that are not " "for maintenance, such as Disabled Students' Allowance. A person " "with is_in_full_time_education false is not receiving education in " "the model, so it does not apply reg. 12(1A) to a qualifying young " - "person outside full-time education, reg. 12(4) to a course that is " - "incompatible with a work-related requirement, or the exception in " - "reg. 12(1A) for a relevant training scheme. Under reg. 13 a person " - "undertakes a course from its first day to its last, vacations " - "included, unless they abandon it or are dismissed; the model " - "applies the input to the whole year. The Universal Credit " + "person outside full-time education, or reg. 12(4) to a course that " + "is incompatible with a work-related requirement. Nor does it apply " + "the exception in reg. 12(1A) for a relevant training scheme. Under " + "reg. 13 a person undertakes a course from its first day to its " + "last, vacations included, unless they abandon it or the " + "establishment dismisses them; the model applies the input to the " + "whole year. The Universal Credit " "Regulations (Northern Ireland) 2016 reg. 12 is the same, except that " "its reg. 12(1) has no exception for a training scheme." ) diff --git a/policyengine_uk/variables/household/demographic/is_in_full_time_education.py b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py index 838935365e..0061026a6d 100644 --- a/policyengine_uk/variables/household/demographic/is_in_full_time_education.py +++ b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py @@ -8,9 +8,9 @@ class is_in_full_time_education(Variable): documentation = ( "Whether this person is undertaking a full-time course of study or " "training, including the vacations within the course. An input that " - "is false unless set: no formula derives it, and a dataset that " - "does not supply it leaves it false. It says only that the course is " - "full-time; current_education and in_HE give its level. Universal " + "stays false until a user or dataset sets it: no formula derives it. " + "It says only that the course is full-time; current_education and " + "in_HE give its level. Universal " "Credit reads it for the condition that a claimant is not receiving " "education (uc_receiving_education)." ) From 3a29af45be057ff351480b71c2a2cc9005563867 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:13:24 -0400 Subject: [PATCH 05/27] Apply the Universal Credit couple rule, maintenance funding input and reg 28 - is_uc_eligible: one claimant must meet the age condition and the education condition together, as DWP's ADM E2016 reads reg 3(2), on the ordinary, closure-claim and reg 60A routes. - has_education_maintenance_funding: a person input for reg 12(2)(b), read in place of student_loans and education_grants. - is_qualifying_young_person_for_uc_education: applies SI 2014/1230 reg 28 before regs 12(1A) and 60 read qualifying young person status. - uc_is_excepted_from_education_condition: states the limited capability for work approximation and corrects the limb (a) and (b) wording. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2346.fixed.md | 2 +- ...ualifying_young_person_for_uc_education.py | 61 +++++++++++++++++++ .../dwp/universal_credit/is_uc_eligible.py | 58 +++++++++++------- .../meets_uc_education_condition.py | 5 +- ...uc_is_excepted_from_education_condition.py | 47 ++++++++------ .../uc_receiving_education.py | 35 ++++++----- .../has_education_maintenance_funding.py | 31 ++++++++++ 7 files changed, 181 insertions(+), 58 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py create mode 100644 policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py diff --git a/changelog.d/2346.fixed.md b/changelog.d/2346.fixed.md index 9d89ed80f0..f2bb285ef5 100644 --- a/changelog.d/2346.fixed.md +++ b/changelog.d/2346.fixed.md @@ -1 +1 @@ -Universal Credit now applies the condition that a claimant is not receiving education, with its exceptions, to people flagged with the new `is_in_full_time_education` input. +The model now applies the Universal Credit condition that a claimant is not receiving education, with its exceptions, to people the new `is_in_full_time_education` input flags, and reads maintenance funding for non-advanced courses from the new `has_education_maintenance_funding` input. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py new file mode 100644 index 0000000000..0ef2be76b4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -0,0 +1,61 @@ +from policyengine_uk.model_api import * + +# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). +EXISTING_BENEFITS = [ + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +] +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] + + +class is_qualifying_young_person_for_uc_education(Variable): + value_type = bool + entity = Person + label = "Qualifying young person for the Universal Credit education condition" + documentation = ( + "Whether the condition that a claimant is not receiving education " + "treats this person as a qualifying young person: they meet " + "is_qualifying_young_person_for_universal_credit and are not " + "entitled to an existing benefit. The Universal Credit (Transitional " + "Provisions) Regulations 2014 reg. 28 takes a person entitled to an " + "existing benefit out of the definition, and reg. 2(1) lists the " + "existing benefits: income-based Jobseeker's Allowance, " + "income-related Employment and Support Allowance, Income Support, " + "Housing Benefit, Child Tax Credit and Working Tax Credit. The model " + "reads entitlement from the year's reported receipt: the person's " + "own report of any of them, or, for a claimant or partner, either " + "member's report of tax credits, which a couple claims jointly. A " + "person reg. 28 excludes takes the ordinary route under the " + "Universal Credit Regulations 2013 reg. 12(2). " + "is_qualifying_young_person_for_universal_credit does not apply " + "reg. 28, and the model's other rules read it." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ) + + def formula(person, period, parameters): + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + # SI 2014/1230 reg. 28: where a person who would be a qualifying + # young person "is entitled to an existing benefit", "that person is + # not a qualifying young person for the purposes of the Universal + # Credit Regulations". + own_award = add(person, period, EXISTING_BENEFITS) > 0 + # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a + # tax credit jointly, so both are entitled to the award. + claimant_or_partner = person("is_claimant_or_partner", period) + joint_tax_credits = claimant_or_partner & ( + person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) + > 0 + ) + return qualifying_young_person & ~(own_award | joint_tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index ef60163e53..3ac98da735 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -13,11 +13,12 @@ class is_uc_eligible(Variable): "nor can a partner who cannot be a joint claimant: where a member of " "a couple claims as a single person (regulation 3(3)), that member " "must meet the conditions, and the capital tested includes the other " - "member's (regulation 18(2)). A claimant must also meet, or fall " - "within an exception to, the condition that they are not receiving " - "education (meets_uc_education_condition); for joint claimants, one " - "who does suffices (regulation 3(2)(b)). A family on the Pension " - "Credit route " + "member's (regulation 18(2)). The qualifying claimant must also meet, " + "or fall within an exception to, the condition that they are not " + "receiving education (meets_uc_education_condition): DWP's Advice " + "for Decision Making E2016 reads regulation 3(2) as needing one " + "member of a couple to meet all the basic conditions. A family on the " + "Pension Credit route " "(meets_pension_credit_age_conditions) is not eligible unless it " "claims when DWP moves it off its legacy benefits " "(claims_uc_at_legacy_closure)." @@ -30,6 +31,7 @@ class is_uc_eligible(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/14", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", + "https://assets.publishing.service.gov.uk/media/6a0474b6ee62840dba48a1eb/adme2.pdf", ) def formula(benunit, period, parameters): @@ -42,8 +44,30 @@ def formula(benunit, period, parameters): pension_age = benunit.members( "has_attained_state_pension_credit_qualifying_age", period ) + # WRA 2012 s.4(1)(d): "is not receiving education", or excepted from + # it. UC Regs 2013 reg 3(2) lets a couple claim jointly where (a) one + # member has reached the qualifying age for State Pension Credit "if + # the other member does meet that condition", "or" (b) one member + # receives education and is not excepted "if the other member does + # meet that condition or is excepted from it". DWP's Advice for + # Decision Making E2016 applies both "where one member of a couple + # meets all the basic UC conditions", so one claimant must meet the + # age and education conditions together. For a mixed-age couple: + # - neither studies: the younger member meets both (reg 3(2)(a)); + # - the older member studies: reg 14(1)(f) excepts them, and the + # younger member meets both (regs 14(1)(f) and 3(2)(a)); + # - the younger member studies: the older member meets the education + # condition and the younger the age condition, but neither meets + # both, so they can't claim (ADM E2016); + # - both study: reg 14(1)(f) excepts the older member, who still + # doesn't meet the age condition, so again neither meets both. + # A member of a couple who claims as a single person (reg 3(3)) must + # meet both themselves. + meets_education_condition = benunit.members( + "meets_uc_education_condition", period + ) has_qualifying_claimant = benunit.any( - claimant & meets_minimum_age & ~pension_age + claimant & meets_minimum_age & ~pension_age & meets_education_condition ) # No family gets both Pension Credit and Universal Credit (UC # (Transitional Provisions) Regs 2014 reg 5(1)(d)). A mixed-age couple @@ -85,22 +109,12 @@ def formula(benunit, period, parameters): on_working_tax_credit = ( add(benunit, period, ["working_tax_credit_reported"]) > 0 ) - reg_60a = on_working_tax_credit & benunit.any(claimant & meets_minimum_age) + # Reg 60A waives only the upper age condition, so the claimant who + # meets the minimum age must meet the education condition too. + reg_60a = on_working_tax_credit & benunit.any( + claimant & meets_minimum_age & meets_education_condition + ) route = (has_qualifying_claimant & ~pension_credit_route) | ( closure_claim & (has_qualifying_claimant | reg_60a) ) - # WRA 2012 s.4(1)(d): "is not receiving education". A couple can be - # joint claimants where "one member does not meet the basic condition - # in section 4(1)(d) (not receiving education) and is not excepted - # from that condition if the other member does meet that condition or - # is excepted from it" (reg 3(2)(b)), so one claimant meeting it - # suffices, as one claimant under the qualifying age does under reg - # 3(2)(a). The two need not be the same member: reg 14(1)(f) excepts - # the older member of a mixed-age couple, which matters only if the - # younger member can meet the age condition while the older meets - # this one. A member of a couple who claims as a single person (reg - # 3(3)) must meet it themselves. - meets_education_condition = benunit.any( - claimant & benunit.members("meets_uc_education_condition", period) - ) - return route & meets_education_condition & (capital <= limit) + return route & (capital <= limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py index 773c88ccd8..fc794f2f5b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py @@ -12,8 +12,9 @@ class meets_uc_education_condition(Variable): "(uc_receiving_education), or an exception applies " "(uc_is_excepted_from_education_condition). The Welfare Reform " "(Northern Ireland) Order 2015 art. 9(1)(d) sets the same condition. " - "is_uc_eligible applies it to each claimant, as the Universal Credit " - "Regulations 2013 reg. 3(2)(b) provides for a couple." + "is_uc_eligible needs one claimant to meet it and the age condition " + "together, as DWP's Advice for Decision Making E2016 reads the " + "Universal Credit Regulations 2013 reg. 3(2) for a couple." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index d1d2f6b921..f0435b0847 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -20,29 +20,40 @@ class uc_is_excepted_from_education_condition(Variable): "(f) the member of a mixed-age couple who has reached the qualifying " "age for State Pension Credit; and reg. 60(1), a member of a family " "that claims Universal Credit when DWP moves it off its legacy " - "benefits (claims_uc_at_legacy_closure) and is not a qualifying young " - "person. Limb (e)(i) needs no rule of its own: the model places " - "responsibility for a child on the benefit unit, so both members of " - "the couple meet (c). Not modelled: (a), a person under 21 in " + "benefits (claims_uc_at_legacy_closure) and whom the education " + "condition does not treat as a qualifying young person " + "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " + "of the 2014 Regulations). Limb (e)(i) needs no rule of its own: the " + "model places responsibility for a child on the benefit unit, so " + "both members of the couple meet (c). Under (b), the decision maker " + "must determine limited capability for work before the course " + "starts. uc_limited_capability_for_work can derive from disability " + "status (is_disabled_for_benefits) without an assessment. The model " + "can therefore retain students who have never received a limited " + "capability for work determination, as well as students assessed " + "after their course began. The model does not infer the " + "determination from an ESA award, so an ESA assessment reaches this " + "limb only where a user sets uc_limited_capability_for_work or a " + "dataset sets is_disabled_for_benefits. Not modelled: (a), a person " + "under 21, or 21 having reached that age during the course, in " "non-advanced education without parental support, which the model " - "does not observe; under (b), whether the limited capability for " - "work was determined before the course began, the other benefits it " - "lists (Scottish disability assistance, constant attendance " - "allowance and war pension attendance payments), which the model " - "has no variables for; and reg. 60(2), which ends the transitional " - "protection. A foster parent is a person either input flags, " + "does not observe; under (b), the other benefits it lists (Scottish " + "disability assistance, constant attendance allowance and war " + "pension attendance payments), which the model has no variables " + "for; and reg. 60(2), which ends the transitional protection. The " + "model identifies foster parents through either input, " "uc_is_responsible_foster_parent_of_child_under_one or " "uc_is_foster_parent_or_new_friend_or_family_carer. The second also " "holds a foster parent between placements, with no child placed, " "and a friend or family carer, who is responsible for the child and " - "so meets (c) under the law. The " - "Universal Credit Regulations (Northern Ireland) 2016 reg. 14(1) " - "lists the same exceptions." + "so meets (c) under the law. The Universal Credit Regulations " + "(Northern Ireland) 2016 reg. 14(1) lists the same exceptions." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/14", "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", ) @@ -52,8 +63,8 @@ def formula(person, period, parameters): claimant_or_partner = person("is_uc_assessed_claimant", period) in_couple = benunit.sum(claimant_or_partner) == 2 # Reg. 14(1)(b): "entitled to attendance allowance, ... disability - # living allowance, ... or personal independence payment" and - # determined to have limited capability for work. + # living allowance, ... or personal independence payment", and the + # decision maker has determined limited capability for work. disability_benefit = ( (person("attendance_allowance", period) > 0) | (person("armed_forces_independence_payment", period) > 0) @@ -103,11 +114,13 @@ def formula(person, period, parameters): # undertaking a full-time course (see regulation 12(2) and 13 ...), # that condition is not to apply ... while they are continuing to # undertake that course". A qualifying young person receives - # education under reg. 12(1A), not reg. 12(2). + # education under reg. 12(1A), not reg. 12(2); reg. 28 applies + # first, so a person entitled to an existing benefit takes reg. + # 12(2) and keeps the protection. migrated_student = ( claimant_or_partner & benunit("claims_uc_at_legacy_closure", period) - & ~person("is_qualifying_young_person_for_universal_credit", period) + & ~person("is_qualifying_young_person_for_uc_education", period) ) return ( disabled_with_limited_capability_for_work diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py index 3b49cd91b5..563fb4a27b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -14,21 +14,22 @@ class uc_receiving_education(Variable): "education where the course is advanced education (reg. 12(2)(a)), " "which the model reads from tertiary current_education or in_HE; " "where they have a student loan or grant for their maintenance " - "(reg. 12(2)(b)), which it reads from student_loans or " - "education_grants; or where they are a qualifying young person " - "(reg. 12(1A)). education_grants can also hold grants that are not " - "for maintenance, such as Disabled Students' Allowance. A person " - "with is_in_full_time_education false is not receiving education in " - "the model, so it does not apply reg. 12(1A) to a qualifying young " + "(reg. 12(2)(b)), which it reads from " + "has_education_maintenance_funding; or where they are a qualifying " + "young person (reg. 12(1A)), after the Universal Credit " + "(Transitional Provisions) Regulations 2014 reg. 28 " + "(is_qualifying_young_person_for_uc_education). A person with " + "is_in_full_time_education false is not receiving education in the " + "model, so it does not apply reg. 12(1A) to a qualifying young " "person outside full-time education, or reg. 12(4) to a course that " "is incompatible with a work-related requirement. Nor does it apply " "the exception in reg. 12(1A) for a relevant training scheme. Under " "reg. 13 a person undertakes a course from its first day to its " "last, vacations included, unless they abandon it or the " "establishment dismisses them; the model applies the input to the " - "whole year. The Universal Credit " - "Regulations (Northern Ireland) 2016 reg. 12 is the same, except that " - "its reg. 12(1) has no exception for a training scheme." + "whole year. The Universal Credit Regulations (Northern Ireland) " + "2016 reg. 12 sets the same test, except that its reg. 12(1) has no " + "exception for a training scheme." ) definition_period = YEAR reference = ( @@ -36,6 +37,7 @@ class uc_receiving_education(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/12", "https://www.legislation.gov.uk/uksi/2013/376/regulation/13", "https://www.legislation.gov.uk/uksi/2013/376/regulation/68", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/nisr/2016/216/regulation/12", ) @@ -51,13 +53,14 @@ def formula(person, period, parameters): ) # Reg. 12(2)(b): "any other full-time course of study or training at # an educational establishment for which a student loan or grant is - # provided for the person's maintenance" (both defined in reg. 68(7)). - maintenance_support = (person("student_loans", period) > 0) | ( - person("education_grants", period) > 0 - ) + # provided for the person's maintenance" (reg. 2 takes both terms + # from reg. 68(7)). + maintenance_funding = person("has_education_maintenance_funding", period) # Reg. 12(1A): "A qualifying young person is to be treated as - # receiving education", whatever the course. + # receiving education", whatever the course. SI 2014/1230 reg. 28 + # applies first, so a person entitled to an existing benefit takes + # the route in reg. 12(2). qualifying_young_person = person( - "is_qualifying_young_person_for_universal_credit", period + "is_qualifying_young_person_for_uc_education", period ) - return full_time & (advanced | maintenance_support | qualifying_young_person) + return full_time & (advanced | maintenance_funding | qualifying_young_person) diff --git a/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py new file mode 100644 index 0000000000..958e799a03 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class has_education_maintenance_funding(Variable): + value_type = bool + entity = Person + label = "Has a student loan or grant for maintenance on their course" + documentation = ( + "Whether this person has a student loan or grant for their " + "maintenance on their course of study or training. A person on a " + "full-time course that is not advanced education receives education " + "for Universal Credit when they have one (Universal Credit " + "Regulations 2013 reg. 12(2)(b)). Reg. 2 takes both terms from reg. " + "68(7): a student loan is a loan towards a student's maintenance " + "under the student support legislation, and a grant is any " + "educational grant or award, except a student loan or a payment " + "under a scheme that helps people under 21 complete courses that " + "are not advanced education. A grant only for fees, equipment or " + "travel does not provide for maintenance. An input that stays false " + "until a user or dataset sets it. The model does not derive it from " + "student_loans or education_grants: neither says what the support " + "pays for, and education_grants can hold grants for childcare or " + "disability costs." + ) + definition_period = YEAR + set_input = set_input_dispatch_by_period + default_value = False + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/12", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/68", + ) From 27a97e472114200b7ed9cd2b421620daf2f72f4e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:13:31 -0400 Subject: [PATCH 06/27] Test the couple rule, each reg 14(1)(b) benefit, maintenance funding and reg 28 Cases 7 and 8 now expect no award under ADM E2016, cases 15 and 16 use has_education_maintenance_funding, and cases 23 to 33 cover the other two mixed-age patterns, the closure-claim and reg 60A routes, Attendance Allowance, Armed Forces Independence Payment and DLA alone, an equipment grant without maintenance funding, and reg 28. Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 330 +++++++++++++++++- 1 file changed, 312 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index f44b02debe..a0e03f8c5b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1,7 +1,8 @@ # Universal Credit's basic condition that a claimant "is not receiving # education" (Welfare Reform Act 2012 s. 4(1)(d)), with the meaning in the # Universal Credit Regulations 2013 reg. 12, the exceptions in reg. 14(1) and -# the couple rule in reg. 3(2)(b). +# the couple rule in reg. 3(2), which DWP's Advice for Decision Making E2016 +# applies where one member of a couple meets all the basic conditions. # # Expected amounts use DWP's monthly rates for 2026 to 2027 (Benefit and # pension rates 2026 to 2027, gov.uk): standard allowance £338.58 for a single @@ -185,14 +186,14 @@ is_uc_eligible: true universal_credit: 9_987.36 -- name: Case 7, a mixed-age couple who are both full-time students keep the award. +- name: Case 7, a mixed-age couple who are both full-time students get no Universal Credit under ADM E2016. # Reg. 14(1)(f) excepts the member who has reached the qualifying age for - # State Pension Credit; reg. 3(2)(a) relieves them of the age condition - # and reg. 3(2)(b) the younger member of this one. Since 15 May 2019 the - # couple cannot get Pension Credit (SPCA 2002 s. 4(1A)). 12 x £666.97 = - # £8,003.64. + # State Pension Credit, but that member doesn't meet the age condition. + # The younger member meets the age condition but receives education with + # no exception. ADM E2016 needs one member to meet all the basic + # conditions, and neither does. Since 15 May 2019 the couple cannot get + # Pension Credit either (SPCA 2002 s. 4(1A)). period: 2026 - absolute_error_margin: 0.01 input: people: person1: @@ -214,14 +215,13 @@ uc_receiving_education: [true, true] uc_is_excepted_from_education_condition: [true, false] meets_uc_education_condition: [true, false] - is_uc_eligible: true - universal_credit: 8_003.64 + is_uc_eligible: false + universal_credit: 0 -- name: Case 8, a mixed-age couple whose younger member alone is a full-time student keep the award. - # The older member meets the education condition (reg. 3(2)(b)) and the - # younger the age condition (reg. 3(2)(a)). 12 x £666.97 = £8,003.64. +- name: Case 8, a mixed-age couple whose younger member alone is a full-time student get no Universal Credit under ADM E2016. + # The older member meets the education condition and the younger the age + # condition, but neither meets both, as ADM E2016 needs one member to do. period: 2026 - absolute_error_margin: 0.01 input: people: person1: @@ -239,8 +239,8 @@ region: NORTH_EAST output: meets_uc_education_condition: [true, false] - is_uc_eligible: true - universal_credit: 8_003.64 + is_uc_eligible: false + universal_credit: 0 - name: Case 9, a student with PIP and limited capability for work keeps the award. # Reg. 14(1)(b): entitled to PIP, with limited capability for work. @@ -391,7 +391,7 @@ is_uc_eligible: true universal_credit: 4_062.96 -- name: Case 15, a non-advanced full-time course with a grant is receiving education. +- name: Case 15, a claimant on a non-advanced full-time course with a maintenance grant receives education. # Reg. 12(2)(b): "any other full-time course ... for which a student loan # or grant is provided for the person's maintenance". period: 2026 @@ -401,7 +401,7 @@ age: 30 current_education: POST_SECONDARY is_in_full_time_education: true - education_grants: 2_000 + has_education_maintenance_funding: true benunits: benunit: members: [person1] @@ -414,7 +414,7 @@ is_uc_eligible: false universal_credit: 0 -- name: Case 16, a non-advanced full-time course with no loan or grant is not receiving education. +- name: Case 16, a claimant on a non-advanced full-time course with no loan or grant does not receive education. # Neither limb of reg. 12(2) applies, and at 30 the person is not a # qualifying young person. 12 x £424.90 = £5,098.80. period: 2026 @@ -425,6 +425,7 @@ age: 30 current_education: POST_SECONDARY is_in_full_time_education: true + has_education_maintenance_funding: false benunits: benunit: members: [person1] @@ -574,3 +575,296 @@ region: NORTH_EAST output: is_uc_eligible: false + +- name: Case 23, a mixed-age couple whose older member alone is a full-time student keep the award. + # Reg. 14(1)(f) excepts the older member, and the younger member meets + # the age condition and the education condition, so reg. 3(2)(a) lets the + # couple claim jointly (ADM E2016). 12 x £666.97 = £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + current_education: TERTIARY + is_in_full_time_education: true + person2: + age: 60 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true, false] + meets_uc_education_condition: [true, true] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 24, a mixed-age couple where neither member studies keep the award. + # The younger member meets every basic condition (reg. 3(2)(a)). + # 12 x £666.97 = £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + person2: + age: 60 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + meets_uc_education_condition: [true, true] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 25, a mixed-age couple whose younger student claims when DWP moves them off legacy benefits keep the award. + # SI 2014/1230 reg. 60(1) lifts the condition from the younger member + # while they continue the course, so that member meets both conditions. + # 12 x £666.97 = £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + person2: + age: 60 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + claims_uc_at_legacy_closure: true + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true, true] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 26, a pension-age student couple on Working Tax Credit claim under reg. 60A when DWP moves them. + # SI 2014/1230 reg. 60A waives the upper age condition, and reg. 60(1) + # lifts the education condition from both members. 12 x £666.97 = + # £8,003.64. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + person2: + age: 70 + current_education: TERTIARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + claims_uc_at_legacy_closure: true + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + uc_receiving_education: [true, true] + meets_uc_education_condition: [true, true] + is_uc_eligible: true + universal_credit: 8_003.64 + +- name: Case 27, a student with Attendance Allowance and limited capability for work keeps the award. + # Reg. 14(1)(b) names attendance allowance. The case gives a working-age + # claimant Attendance Allowance and no other listed benefit, to test that + # benefit on its own. 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + aa_category: LOWER + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 28, a student with Armed Forces Independence Payment and limited capability for work keeps the award. + # Reg. 2 counts Armed Forces Independence Payment as attendance allowance + # for reg. 14(1)(b), and reg. 66 doesn't count it as unearned income. + # 12 x £424.90 = £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + armed_forces_independence_payment: 8_000 + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 29, a student with Disability Living Allowance and limited capability for work keeps the award. + # Reg. 14(1)(b) names disability living allowance. 12 x £424.90 = + # £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + dla_sc_category: MIDDLE + uc_limited_capability_for_work: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 30, a claimant on a non-advanced full-time course with an equipment grant and no maintenance funding keeps the award. + # Reg. 12(2)(b) needs a loan or grant for the person's maintenance. + # Learner Support can help with materials and equipment (gov.uk/learner- + # support), and the claimant's student loan doesn't fund maintenance + # here, so the claimant doesn't receive education. 12 x £424.90 = + # £5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: POST_SECONDARY + is_in_full_time_education: true + education_grants: 100 + student_loans: 3_000 + has_education_maintenance_funding: false + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 31, an 18-year-old on Housing Benefit and a non-advanced full-time course takes the ordinary route. + # SI 2014/1230 reg. 28: a person entitled to an existing benefit, here + # Housing Benefit (reg. 2(1)), is not a qualifying young person for the + # Universal Credit Regulations. With no maintenance funding, reg. 12(2) + # doesn't apply either, so the claimant doesn't receive education. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 32, the same 18-year-old with a maintenance grant receives education under reg. 12(2)(b). + # Reg. 28 sends the claimant to reg. 12(2), and the grant for their + # maintenance meets reg. 12(2)(b). No exception applies. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + has_education_maintenance_funding: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + output: + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 33, a couple of 18-year-olds on a joint Working Tax Credit award both leave the qualifying young person route. + # Tax Credits Act 2002 s. 3(3)(a): a couple claims jointly, so both + # members are entitled to the award, and SI 2014/1230 reg. 28 applies to + # each, whichever member reports it. + period: 2024 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + person2: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + region: NORTH_EAST + output: + is_qualifying_young_person_for_universal_credit: [true, true] + is_qualifying_young_person_for_uc_education: [false, false] + uc_receiving_education: [false, false] From 5dd23587fd8ff6a4aba09f0b74587c39c494a42a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:13:32 -0400 Subject: [PATCH 07/27] Extend the education condition properties to the couple rule and reg 28 The reference implementation now applies ADM E2016's common-member rule, reg 28 and the maintenance funding input, and the generator draws Attendance Allowance, AFIP, DLA, the new input and reported Housing Benefit and Working Tax Credit. New invariants: one member meets both conditions, loans and grants don't decide receiving education, and no one reporting an existing benefit counts as a qualifying young person. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_receiving_education_properties.py | 451 +++++++++++++----- 1 file changed, 322 insertions(+), 129 deletions(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 22fedd66ae..3c10306290 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -2,43 +2,63 @@ The Welfare Reform Act 2012 s. 4(1)(d) makes it a basic condition that a claimant "is not receiving education". The Universal Credit Regulations 2013 -reg. 12 says who is receiving education, reg. 14(1) lists exceptions, and reg. -3(2)(b) lets a couple claim jointly where "one member does not meet the basic -condition in section 4(1)(d) ... and is not excepted from that condition if -the other member does meet that condition or is excepted from it". Only a -person flagged is_in_full_time_education can receive education in the model. +reg. 12 says who receives education, and reg. 14(1) lists exceptions. Reg. 3(2) +lets a couple claim jointly where one member doesn't meet the age condition +or this one, and DWP's Advice for Decision Making E2016 applies it "where one +member of a couple meets all the basic UC conditions". The Universal Credit +(Transitional Provisions) Regulations 2014 reg. 28 takes a person entitled to +an existing benefit out of the definition of a qualifying young person. Only +a person flagged is_in_full_time_education can receive education in the +model, and only has_education_maintenance_funding meets the funding test in +reg. 12(2)(b). Each drawn case is a year and up to four families, each in a household of its own: a single adult or a couple, up to two children or young people, and -inputs that reach every limb the model applies (course level, loans and -grants, PIP with or without limited capability for work, foster flags, an -ineligible partner, a legacy closure claim, earnings, rent and savings). The -test calculates the same people four ways: as drawn; with the eligibility formula +inputs that reach every limb the model applies (course level, maintenance +funding, student loans and grants, Attendance Allowance, Armed Forces +Independence Payment, DLA and PIP with or without limited capability for work, +foster flags, an ineligible partner, reported Housing Benefit and Working Tax +Credit, a legacy closure claim, earnings, rent and savings). The test +calculates the same people five ways: as drawn; with the eligibility formula on main before this change (BEFORE, kept verbatim below); with no one flagged; -and with the flags of members who are neither claimant nor partner reversed. +with the flags of members who are neither claimant nor partner reversed; and +with every student loan and education grant removed. Invariants: 1. Never raises: each family's Universal Credit is either what it is under BEFORE or nil, so setting the input never raises it. 2. Unflagged families don't change: with no one flagged, every output listed in OUTPUTS equals its value under BEFORE. -3. Differential: is_uc_eligible equals BEFORE's eligibility and a reference - implementation of regs. 3(2)(b), 12 and 14 that reads the drawn inputs. +3. Differential: receiving education, the exceptions, the qualifying young + person status the condition reads, and is_uc_eligible equal a reference + implementation that reads the drawn inputs. The reference applies regs. + 3(2), 12 and 14 as ADM E2016 reads them, SI 2014/1230 regs. 28 and 60, and + BEFORE's routes; without the education condition it gives BEFORE's + eligibility. 4. Exceptions: a claimant who meets a modelled reg. 14 exception, or the SI - 2014/1230 reg. 60 protection, meets the condition, and their family gets - the Universal Credit it gets under BEFORE. + 2014/1230 reg. 60 protection, meets the condition, and a family whose + claimants all meet it gets the Universal Credit it gets under BEFORE. 5. Only the couple counts: reversing the flags of members who are neither claimant nor partner (children and qualifying young people) changes no family's Universal Credit. A partner who cannot be a joint claimant (reg. 3(3)) is still a member of the couple, so whether they receive education can decide reg. 14(1)(e) for the claimant. +6. One member meets both: every eligible family that makes no closure claim + has a claimant who meets the minimum age, hasn't reached the qualifying + age for State Pension Credit and meets the education condition. +7. Maintenance funding comes from the input: removing every student loan and + education grant changes no one's receiving education and no output in + OUTPUTS. +8. Reg. 28: a person who reports an existing benefit never counts as a + qualifying young person for the condition. A family in a household with other families can lose Universal Credit because of another family's student: when the household head's family cannot claim, its non-dependants count in the next claim by a family liable for the -rent (UC Regs 2013 Sch 4 para 9(2)(e) and (f)). The law intends that, and -test_student_head_moves_non_dependant_to_joint_tenant pins it; the invariants -above hold for families with households of their own. +rent. Paragraph 9(2)(f) of Schedule 4 prevents duplicate non-dependant +treatment. The model assigns non-dependants to the household head's eligible +claim first. test_student_head_moves_non_dependant_to_joint_tenant pins it; +the invariants above hold for families with households of their own. """ import json @@ -65,8 +85,18 @@ YEARS = [2019, 2021, 2024, 2026] REGIONS = ["NORTH_EAST", "LONDON", "SCOTLAND", "NORTHERN_IRELAND"] EDUCATION = ["TERTIARY", "POST_SECONDARY", "NOT_IN_EDUCATION"] -# The Universal Credit outputs that read is_uc_eligible. A differential run -# against main on hand-built datasets covers every other output. +# SI 2014/1230 reg. 2(1), as reported receipt. +EXISTING_BENEFITS = [ + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +] +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# Compare eligibility, maximum entitlement, income reduction and Universal +# Credit awards. OUTPUTS = [ "universal_credit", "is_uc_eligible", @@ -120,12 +150,18 @@ def adult(draw, ages): is_in_full_time_education=full_time, current_education=draw(st.sampled_from(EDUCATION)), in_HE=draw(rarely), + has_education_maintenance_funding=draw(rarely), student_loans=draw(st.sampled_from([0, 0, 0, 3_000])), education_grants=draw(st.sampled_from([0, 0, 0, 1_500])), + aa_category=draw(st.sampled_from(["NONE", "NONE", "NONE", "LOWER"])), + armed_forces_independence_payment=draw(st.sampled_from([0, 0, 0, 8_000])), + dla_sc_category=draw(st.sampled_from(["NONE", "NONE", "NONE", "MIDDLE"])), pip_dl_category=draw(st.sampled_from(["NONE", "NONE", "STANDARD"])), uc_limited_capability_for_work=draw(rarely), uc_is_responsible_foster_parent_of_child_under_one=draw(rarely), uc_is_foster_parent_or_new_friend_or_family_carer=draw(rarely), + housing_benefit_reported=draw(st.sampled_from([0, 0, 0, 0, 6_000])), + working_tax_credit_reported=draw(st.sampled_from([0, 0, 0, 0, 1_000])), employment_income=draw(st.sampled_from([0, 0, 4_000, 15_000])), is_claimant_or_partner=True, ) @@ -137,6 +173,7 @@ def young_person(draw): age=draw(st.sampled_from([2, 10, 16, 17, 18])), is_in_full_time_education=draw(st.booleans()), current_education=draw(st.sampled_from(EDUCATION)), + has_education_maintenance_funding=draw(rarely), is_claimant_or_partner=False, ) @@ -177,19 +214,19 @@ def cases(draw): def situation(case, variants): """Each family in a household of its own, once for each variant. A variant - is None for the drawn is_in_full_time_education, or a list giving each - person's value in order.""" + is None for the drawn inputs, or a function that takes a person's index + and inputs and returns the inputs to use.""" year = case["year"] people, benunits, households = {}, {}, {} - for v, flags in enumerate(variants): + for v, change in enumerate(variants): index = 0 for f, fam in enumerate(case["families"]): members = [] for person in fam["people"]: name = f"p{v}_{index}" values = dict(person) - if flags is not None: - values["is_in_full_time_education"] = flags[index] + if change is not None: + values = change(index, values) people[name] = {k: {year: x} for k, x in values.items()} members.append(name) index += 1 @@ -213,11 +250,17 @@ def all_flags(case): VARIABLES = OUTPUTS + [ "is_uc_single_or_joint_claimant", "is_uc_assessed_claimant", + "meets_uc_minimum_age_condition", + "meets_pension_credit_age_conditions", + "uc_assessable_capital", "is_qualifying_young_person_for_universal_credit", + "is_qualifying_young_person_for_uc_education", "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", "has_attained_state_pension_credit_qualifying_age", "is_mixed_age_couple", "uc_limited_capability_for_work", + "attendance_allowance", + "dla", "pip", "uc_receiving_education", "uc_is_excepted_from_education_condition", @@ -232,6 +275,9 @@ def run(case, variants=(None,), before=False): simulation.apply_reform(before_education_condition) year = case["year"] values = {name: simulation.calculate(name, year) for name in VARIABLES} + limit = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.dwp.universal_credit.means_test.capital.limit sizes = { "person": sum(len(f["people"]) for f in case["families"]), "benunit": len(case["families"]), @@ -239,7 +285,7 @@ def run(case, variants=(None,), before=False): } results = [] for v in range(len(variants)): - result = {} + result = {"capital_limit": limit} for name, array in values.items(): entity = simulation.tax_benefit_system.variables[name].entity.key size = sizes[entity] @@ -249,43 +295,60 @@ def run(case, variants=(None,), before=False): def reference(case, result): - """Regs. 3(2)(b), 12 and 14 from the drawn inputs. - - Returns, per person, receiving education and excepted, and per family, - whether the family meets the education condition. Reads from the model - only the roles (who is a claimant), qualifying young person and pension - age status, responsibility for a child, limited capability for work and - the PIP amount, which other rules settle. + """Regs. 3(2), 12 and 14, SI 2014/1230 regs. 28 and 60 and ADM E2016 from + the drawn inputs. + + Returns, per person, receiving education, excepted and qualifying young + person for the condition, and per family, eligibility with the education + condition and without it. Reads from the model only the roles (who is a + claimant), the age conditions, qualifying young person status before reg. + 28, the Pension Credit route, capital, responsibility for a child, limited + capability for work and the disability benefit amounts, which other rules + settle. """ - receiving, excepted, family_meets = [], [], [] + receiving, excepted, qyp_for_education = [], [], [] + eligible, eligible_before = [], [] index = 0 for f, fam in enumerate(case["families"]): start = index members = range(start, start + len(fam["people"])) + person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] - recv = {} - for i, person in zip(members, fam["people"]): - qyp = bool(result["is_qualifying_young_person_for_universal_credit"][i]) + couple_tax_credits = any( + person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS + ) + qyp, recv = {}, {} + for i, person in person_of.items(): + existing_benefit = any( + person.get(name, 0) > 0 for name in EXISTING_BENEFITS + ) or (i in couple and couple_tax_credits) + qyp[i] = ( + bool(result["is_qualifying_young_person_for_universal_credit"][i]) + and not existing_benefit + ) advanced = person["current_education"] == "TERTIARY" or person.get( "in_HE", False ) - loan_or_grant = ( - person.get("student_loans", 0) > 0 - or person.get("education_grants", 0) > 0 - ) + maintenance = person.get("has_education_maintenance_funding", False) recv[i] = person["is_in_full_time_education"] and ( - advanced or loan_or_grant or qyp + advanced or maintenance or qyp[i] ) foster = { i: person.get("uc_is_responsible_foster_parent_of_child_under_one", False) or person.get("uc_is_foster_parent_or_new_friend_or_family_carer", False) - for i, person in zip(members, fam["people"]) + for i, person in person_of.items() } exc = {} - for i, person in zip(members, fam["people"]): + for i, person in person_of.items(): in_couple_role = i in couple partner = [j for j in couple if j != i] - limb_b = bool(result["pip"][i] > 0) and bool( + disability_benefit = ( + result["attendance_allowance"][i] > 0 + or person.get("armed_forces_independence_payment", 0) > 0 + or result["dla"][i] > 0 + or result["pip"][i] > 0 + ) + limb_b = bool(disability_benefit) and bool( result["uc_limited_capability_for_work"][i] ) limb_c = in_couple_role and bool( @@ -308,15 +371,80 @@ def reference(case, result): reg_60 = ( in_couple_role and fam["benunit"]["claims_uc_at_legacy_closure"] - and not result["is_qualifying_young_person_for_universal_credit"][i] + and not qyp[i] ) exc[i] = limb_b or limb_c or limb_d or limb_e or limb_f or reg_60 claimants = [i for i in members if result["is_uc_single_or_joint_claimant"][i]] - family_meets.append(any(not recv[i] or exc[i] for i in claimants)) + minimum_age = { + i: bool(result["meets_uc_minimum_age_condition"][i]) for i in claimants + } + pension_age = { + i: bool(result["has_attained_state_pension_credit_qualifying_age"][i]) + for i in claimants + } + on_working_tax_credit = any( + person.get("working_tax_credit_reported", 0) > 0 for person in fam["people"] + ) + pension_credit_route = bool(result["meets_pension_credit_age_conditions"][f]) + closure = fam["benunit"]["claims_uc_at_legacy_closure"] + capital_ok = result["uc_assessable_capital"][f] <= result["capital_limit"] + + def eligibility(meets): + # ADM E2016: one claimant meets the age and education conditions. + qualifying = any( + minimum_age[i] and not pension_age[i] and meets[i] for i in claimants + ) + reg_60a = on_working_tax_credit and any( + minimum_age[i] and meets[i] for i in claimants + ) + route = (qualifying and not pension_credit_route) or ( + closure and (qualifying or reg_60a) + ) + return bool(route and capital_ok) + + eligible.append(eligibility({i: not recv[i] or exc[i] for i in claimants})) + eligible_before.append(eligibility({i: True for i in claimants})) receiving += [recv[i] for i in members] excepted += [exc[i] for i in members] + qyp_for_education += [qyp[i] for i in members] index += len(fam["people"]) - return np.array(receiving), np.array(excepted), np.array(family_meets) + return ( + np.array(receiving), + np.array(excepted), + np.array(qyp_for_education), + np.array(eligible), + np.array(eligible_before), + ) + + +def person(age, **inputs): + return dict( + age=age, + is_in_full_time_education=False, + current_education="NOT_IN_EDUCATION", + is_claimant_or_partner=True, + **inputs, + ) + + +def student(age, **inputs): + return person( + age, is_in_full_time_education=True, current_education="TERTIARY", **inputs + ) + + +def household(region="NORTH_EAST", **inputs): + values = dict(region=region, tenure_type="OWNED_OUTRIGHT", rent=0, savings=0) + values.update(inputs) + return values + + +def single_family(people, closure=False, **household_inputs): + return dict( + people=people, + benunit=dict(claims_uc_at_legacy_closure=closure), + household=household(**household_inputs), + ) EXAMPLES = [ @@ -324,77 +452,69 @@ def reference(case, result): dict( year=2026, families=[ - dict( - people=[ - dict( - age=19, - is_in_full_time_education=True, - current_education="TERTIARY", - employment_income=4_571, - is_claimant_or_partner=True, - ) - ], - benunit=dict(claims_uc_at_legacy_closure=False), - household=dict( - region="SOUTH_WEST", - tenure_type="RENT_FROM_COUNCIL", - rent=6_000, - savings=0, - ), + single_family( + [student(19, employment_income=4_571)], + region="SOUTH_WEST", + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, ) ], ), - # A mixed-age couple who are both students (reg. 14(1)(f)), and a - # student couple with a foster parent (reg. 14(1)(e)(ii)). + # A mixed-age couple who are both students (reg. 14(1)(f) excepts the + # older member, and no member meets both conditions), and a student + # couple with a foster parent (reg. 14(1)(e)(ii)). dict( year=2026, families=[ - dict( - people=[ - dict( - age=70, - is_in_full_time_education=True, - current_education="TERTIARY", - is_claimant_or_partner=True, - ), - dict( - age=61, - is_in_full_time_education=True, - current_education="TERTIARY", - is_claimant_or_partner=True, - ), + single_family([student(70), student(61)]), + single_family( + [ + student(30), + student(30, uc_is_foster_parent_or_new_friend_or_family_carer=True), ], - benunit=dict(claims_uc_at_legacy_closure=False), - household=dict( - region="NORTH_EAST", - tenure_type="OWNED_OUTRIGHT", - rent=0, - savings=0, - ), + region="NORTHERN_IRELAND", ), - dict( - people=[ - dict( - age=30, + ], + ), + # The other mixed-age couples: the older member alone studies (eligible), + # and the younger member alone studies (no member meets both). + dict( + year=2026, + families=[ + single_family([student(70), person(61)]), + single_family([person(70), student(61)]), + ], + ), + # SI 2014/1230 reg. 28: an 18-year-old on a non-advanced full-time course + # who reports Housing Benefit, without and with a closure claim. + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, is_in_full_time_education=True, - current_education="TERTIARY", - is_claimant_or_partner=True, - ), - dict( - age=30, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, + ), + single_family( + [ + person( + 18, is_in_full_time_education=True, - current_education="TERTIARY", - uc_is_foster_parent_or_new_friend_or_family_carer=True, - is_claimant_or_partner=True, - ), + current_education="POST_SECONDARY", + has_education_maintenance_funding=True, + housing_benefit_reported=6_000, + ) ], - benunit=dict(claims_uc_at_legacy_closure=False), - household=dict( - region="NORTHERN_IRELAND", - tenure_type="OWNED_OUTRIGHT", - rent=0, - savings=0, - ), + closure=True, + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, ), ], ), @@ -409,9 +529,20 @@ def check(case): couple = [ p["is_claimant_or_partner"] for f in case["families"] for p in f["people"] ] - reversed_flags = [flag if couple[i] else not flag for i, flag in enumerate(flags)] - drawn, unflagged, others_reversed = run( - case, variants=[None, [False] * len(flags), reversed_flags] + + def unflagged(i, values): + return {**values, "is_in_full_time_education": False} + + def others_reversed(i, values): + if couple[i]: + return values + return {**values, "is_in_full_time_education": not flags[i]} + + def no_loans_or_grants(i, values): + return {**values, "student_loans": 0, "education_grants": 0} + + drawn, no_flags, reversed_others, no_loans = run( + case, variants=[None, unflagged, others_reversed, no_loans_or_grants] ) (before,) = run(case, before=True) claimant = drawn["is_uc_single_or_joint_claimant"] @@ -426,69 +557,131 @@ def check(case): # 2. Unflagged families don't change. for name in OUTPUTS: np.testing.assert_allclose( - unflagged[name].astype(float), + no_flags[name].astype(float), before[name].astype(float), atol=0.005, err_msg=f"{name}: {context}", ) # 3. Differential against the reference implementation. - receiving, excepted, family_meets = reference(case, drawn) + receiving, excepted, qyp, eligible, eligible_before = reference(case, drawn) np.testing.assert_array_equal(drawn["uc_receiving_education"], receiving, context) np.testing.assert_array_equal( drawn["uc_is_excepted_from_education_condition"], excepted, context ) np.testing.assert_array_equal( - drawn["is_uc_eligible"], before["is_uc_eligible"] & family_meets, context + drawn["is_qualifying_young_person_for_uc_education"], qyp, context ) - # 4. Exceptions keep the claimant, and so the family, in. - assert np.all(drawn["meets_uc_education_condition"][excepted]), context + np.testing.assert_array_equal(drawn["is_uc_eligible"], eligible, context) + np.testing.assert_array_equal(before["is_uc_eligible"], eligible_before, context) + # 4. Exceptions keep the claimant in, and a family whose claimants all + # meet the condition keeps BEFORE's award. + meets = drawn["meets_uc_education_condition"] + assert np.all(meets[excepted]), context index = 0 for f, fam in enumerate(case["families"]): members = range(index, index + len(fam["people"])) - if any(claimant[i] and excepted[i] for i in members): + if all(meets[i] for i in members if claimant[i]): assert np.isclose(uc[f], uc_before[f], atol=0.005), context index += len(fam["people"]) # 5. Only the couple counts. np.testing.assert_allclose( - others_reversed["universal_credit"], uc, atol=0.005, err_msg=context + reversed_others["universal_credit"], uc, atol=0.005, err_msg=context ) np.testing.assert_array_equal( - others_reversed["is_uc_eligible"], drawn["is_uc_eligible"], context + reversed_others["is_uc_eligible"], drawn["is_uc_eligible"], context ) + # 6. One member meets both conditions on the ordinary route. + index = 0 + for f, fam in enumerate(case["families"]): + members = range(index, index + len(fam["people"])) + if ( + drawn["is_uc_eligible"][f] + and not fam["benunit"]["claims_uc_at_legacy_closure"] + ): + assert any( + claimant[i] + and drawn["meets_uc_minimum_age_condition"][i] + and not drawn["has_attained_state_pension_credit_qualifying_age"][i] + and meets[i] + for i in members + ), context + index += len(fam["people"]) + # 7. Student loans and education grants don't decide receiving education. + np.testing.assert_array_equal( + no_loans["uc_receiving_education"], drawn["uc_receiving_education"], context + ) + for name in OUTPUTS: + np.testing.assert_allclose( + no_loans[name].astype(float), + drawn[name].astype(float), + atol=0.005, + err_msg=f"{name}: {context}", + ) + # 8. Reg. 28: no one who reports an existing benefit counts as a + # qualifying young person for the condition. + reports = np.array( + [ + any(p.get(name, 0) > 0 for name in EXISTING_BENEFITS) + for fam in case["families"] + for p in fam["people"] + ] + ) + assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][reports]) @PROPERTY_SETTINGS @example(EXAMPLES[0]) @example(EXAMPLES[1]) +@example(EXAMPLES[2]) +@example(EXAMPLES[3]) @given(cases()) def test_education_condition_invariants(case): check(case) def test_examples_reach_the_cases(): - """The examples exclude the issue's student and keep the excepted - couples, so the invariants run on each outcome.""" + """The examples exclude the issue's student, apply the couple rule both + ways and send the young Housing Benefit claimant down the ordinary route, + so the invariants run on each outcome.""" (issue,) = run(EXAMPLES[0]) assert not issue["is_uc_eligible"][0] assert issue["universal_credit"][0] == 0 assert run(EXAMPLES[0], before=True)[0]["universal_credit"][0] > 0 (couples,) = run(EXAMPLES[1]) - np.testing.assert_array_equal(couples["is_uc_eligible"], [True, True]) + np.testing.assert_array_equal(couples["is_uc_eligible"], [False, True]) np.testing.assert_array_equal( couples["uc_is_excepted_from_education_condition"], [True, False, True, False], ) + (mixed_age,) = run(EXAMPLES[2]) + np.testing.assert_array_equal(mixed_age["is_uc_eligible"], [True, False]) + (young,) = run(EXAMPLES[3]) + np.testing.assert_array_equal( + young["is_qualifying_young_person_for_universal_credit"], [True, True] + ) + np.testing.assert_array_equal( + young["is_qualifying_young_person_for_uc_education"], [False, False] + ) + np.testing.assert_array_equal(young["uc_receiving_education"], [False, True]) + np.testing.assert_array_equal( + young["uc_is_excepted_from_education_condition"], [False, True] + ) + np.testing.assert_array_equal(young["is_uc_eligible"], [True, True]) + assert np.all(young["universal_credit"] > 0) def test_student_head_moves_non_dependant_to_joint_tenant(): - """Intended: a family with no one flagged loses Universal Credit when the - household head's family is a full-time student's. - - The head's family cannot claim, so the non-dependant counts in the joint - tenant's claim instead (UC Regs 2013 Sch 4 para 9(2)(e) and (f)), which - then has the housing cost contribution of £96.55 a month in 2026 (Sch 4 - para 14(1), as SI 2026/148 art. 32(3) sets it): 12 x £96.55 = £1,158.60. - Two copies of the household, the head unflagged and then flagged. + """A family with no one flagged loses Universal Credit when the household + head's family is a full-time student's. + + The head's family cannot claim, so the model counts the non-dependant in + the joint tenant's claim instead. Paragraph 9(2)(f) of Schedule 4 to the + UC Regs 2013 prevents duplicate non-dependant treatment, and the model + assigns non-dependants to the household head's eligible claim first. The + joint tenant's claim then has the housing cost contribution of £96.55 a + month in 2026 (Sch 4 para 14(1), as SI 2026/148 art. 32(3) sets it): 12 x + £96.55 = £1,158.60. Two copies of the household, the head unflagged and + then flagged. """ year = 2026 people, benunits, households = {}, {}, {} From 296b014d8b96bf30fdec7f69c1a4035dfa716d0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:39:37 -0400 Subject: [PATCH 08/27] Build the explicit property examples without duplicate keywords Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_receiving_education_properties.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 3c10306290..d70924d2aa 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -418,13 +418,14 @@ def eligibility(meets): def person(age, **inputs): - return dict( + values = dict( age=age, is_in_full_time_education=False, current_education="NOT_IN_EDUCATION", is_claimant_or_partner=True, - **inputs, ) + values.update(inputs) + return values def student(age, **inputs): From 8d89ea86892432d2302cc144e5175beef47a8412 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:01:38 -0400 Subject: [PATCH 09/27] Name the actors in the education condition documentation Co-Authored-By: Claude Opus 5.5 --- ...ualifying_young_person_for_uc_education.py | 19 ++++++++++--------- .../dwp/universal_credit/is_uc_eligible.py | 8 ++++---- ...uc_is_excepted_from_education_condition.py | 18 +++++++++--------- .../uc_receiving_education.py | 15 ++++++++------- .../has_education_maintenance_funding.py | 17 +++++++++-------- 5 files changed, 40 insertions(+), 37 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 0ef2be76b4..fe6dc5971b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -17,20 +17,21 @@ class is_qualifying_young_person_for_uc_education(Variable): entity = Person label = "Qualifying young person for the Universal Credit education condition" documentation = ( - "Whether the condition that a claimant is not receiving education " - "treats this person as a qualifying young person: they meet " - "is_qualifying_young_person_for_universal_credit and are not " - "entitled to an existing benefit. The Universal Credit (Transitional " - "Provisions) Regulations 2014 reg. 28 takes a person entitled to an " - "existing benefit out of the definition, and reg. 2(1) lists the " - "existing benefits: income-based Jobseeker's Allowance, " + "Whether this person counts as a qualifying young person for the " + "Universal Credit condition that a claimant is not receiving " + "education: they meet is_qualifying_young_person_for_universal_credit " + "and have no entitlement to an existing benefit. Under the Universal " + "Credit (Transitional Provisions) Regulations 2014 reg. 28, a person " + "entitled to an existing benefit doesn't count as a qualifying young " + "person, and reg. 2(1) lists the existing benefits: income-based " + "Jobseeker's Allowance, " "income-related Employment and Support Allowance, Income Support, " "Housing Benefit, Child Tax Credit and Working Tax Credit. The model " "reads entitlement from the year's reported receipt: the person's " "own report of any of them, or, for a claimant or partner, either " "member's report of tax credits, which a couple claims jointly. A " - "person reg. 28 excludes takes the ordinary route under the " - "Universal Credit Regulations 2013 reg. 12(2). " + "person outside the definition under reg. 28 takes the ordinary route " + "under the Universal Credit Regulations 2013 reg. 12(2). " "is_qualifying_young_person_for_universal_credit does not apply " "reg. 28, and the model's other rules read it." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index 3ac98da735..4cc3dfe897 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -45,10 +45,10 @@ def formula(benunit, period, parameters): "has_attained_state_pension_credit_qualifying_age", period ) # WRA 2012 s.4(1)(d): "is not receiving education", or excepted from - # it. UC Regs 2013 reg 3(2) lets a couple claim jointly where (a) one - # member has reached the qualifying age for State Pension Credit "if - # the other member does meet that condition", "or" (b) one member - # receives education and is not excepted "if the other member does + # it. Under UC Regs 2013 reg 3(2), a couple can claim jointly where (a) + # one member has reached the qualifying age for State Pension Credit + # "if the other member does meet that condition", "or" (b) one member + # receives education with no exception "if the other member does # meet that condition or is excepted from it". DWP's Advice for # Decision Making E2016 applies both "where one member of a couple # meets all the basic UC conditions", so one claimant must meet the diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index f0435b0847..27cb647edd 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -20,8 +20,8 @@ class uc_is_excepted_from_education_condition(Variable): "(f) the member of a mixed-age couple who has reached the qualifying " "age for State Pension Credit; and reg. 60(1), a member of a family " "that claims Universal Credit when DWP moves it off its legacy " - "benefits (claims_uc_at_legacy_closure) and whom the education " - "condition does not treat as a qualifying young person " + "benefits (claims_uc_at_legacy_closure) and who doesn't count as a " + "qualifying young person for the condition " "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " "of the 2014 Regulations). Limb (e)(i) needs no rule of its own: the " "model places responsibility for a child on the benefit unit, so " @@ -34,13 +34,13 @@ class uc_is_excepted_from_education_condition(Variable): "after their course began. The model does not infer the " "determination from an ESA award, so an ESA assessment reaches this " "limb only where a user sets uc_limited_capability_for_work or a " - "dataset sets is_disabled_for_benefits. Not modelled: (a), a person " - "under 21, or 21 having reached that age during the course, in " - "non-advanced education without parental support, which the model " - "does not observe; under (b), the other benefits it lists (Scottish " - "disability assistance, constant attendance allowance and war " - "pension attendance payments), which the model has no variables " - "for; and reg. 60(2), which ends the transitional protection. The " + "dataset sets is_disabled_for_benefits. The model doesn't apply (a), " + "for a person under 21, or 21 having reached that age during the " + "course, in non-advanced education without parental support, since " + "it doesn't observe parental support; the other benefits (b) lists " + "(Scottish disability assistance, constant attendance allowance and " + "war pension attendance payments), which it has no variables for; " + "or reg. 60(2), which ends the transitional protection. The " "model identifies foster parents through either input, " "uc_is_responsible_foster_parent_of_child_under_one or " "uc_is_foster_parent_or_new_friend_or_family_carer. The second also " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py index 563fb4a27b..45cd2e875b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -11,16 +11,17 @@ class uc_receiving_education(Variable): "condition in the Welfare Reform Act 2012 s. 4(1)(d) unless an " "exception applies (uc_is_excepted_from_education_condition). A " "person on a full-time course (is_in_full_time_education) receives " - "education where the course is advanced education (reg. 12(2)(a)), " - "which the model reads from tertiary current_education or in_HE; " + "education where they take a course of advanced education (reg. " + "12(2)(a)), which the model reads from tertiary current_education or " + "in_HE; " "where they have a student loan or grant for their maintenance " "(reg. 12(2)(b)), which it reads from " - "has_education_maintenance_funding; or where they are a qualifying " - "young person (reg. 12(1A)), after the Universal Credit " + "has_education_maintenance_funding; or where they count as a " + "qualifying young person (reg. 12(1A)) after the Universal Credit " "(Transitional Provisions) Regulations 2014 reg. 28 " - "(is_qualifying_young_person_for_uc_education). A person with " - "is_in_full_time_education false is not receiving education in the " - "model, so it does not apply reg. 12(1A) to a qualifying young " + "(is_qualifying_young_person_for_uc_education). The model treats a " + "person with is_in_full_time_education false as not receiving " + "education, so it does not apply reg. 12(1A) to a qualifying young " "person outside full-time education, or reg. 12(4) to a course that " "is incompatible with a work-related requirement. Nor does it apply " "the exception in reg. 12(1A) for a relevant training scheme. Under " diff --git a/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py index 958e799a03..ad1482dc00 100644 --- a/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py +++ b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py @@ -8,15 +8,16 @@ class has_education_maintenance_funding(Variable): documentation = ( "Whether this person has a student loan or grant for their " "maintenance on their course of study or training. A person on a " - "full-time course that is not advanced education receives education " - "for Universal Credit when they have one (Universal Credit " + "full-time course other than a course of advanced education receives " + "education for Universal Credit when they have one (Universal Credit " "Regulations 2013 reg. 12(2)(b)). Reg. 2 takes both terms from reg. " - "68(7): a student loan is a loan towards a student's maintenance " - "under the student support legislation, and a grant is any " - "educational grant or award, except a student loan or a payment " - "under a scheme that helps people under 21 complete courses that " - "are not advanced education. A grant only for fees, equipment or " - "travel does not provide for maintenance. An input that stays false " + "68(7), which defines a student loan as a loan towards a student's " + "maintenance under the student support legislation, and a grant as " + "any educational grant or award, except a student loan or a payment " + "under a scheme that helps people under 21 complete courses other " + "than advanced education. The test needs funding for maintenance, so " + "a grant for fees, equipment or travel alone doesn't count. An input " + "that stays false " "until a user or dataset sets it. The model does not derive it from " "student_loans or education_grants: neither says what the support " "pays for, and education_grants can hold grants for childcare or " From 9dd9c10dfdbef19c5ed8b5f5d6fca5e81ff5f653 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:59:42 -0400 Subject: [PATCH 10/27] Calculate each property example and its main copy in one simulation A reform keeps this branch's is_uc_eligible and gives benefit units flagged uses_main_eligibility main's formula, so each drawn case needs one simulation instead of two, and the explicit examples share one. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_receiving_education_properties.py | 137 +++++++++++------- 1 file changed, 84 insertions(+), 53 deletions(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index d70924d2aa..c5940fa85b 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -18,7 +18,7 @@ funding, student loans and grants, Attendance Allowance, Armed Forces Independence Payment, DLA and PIP with or without limited capability for work, foster flags, an ineligible partner, reported Housing Benefit and Working Tax -Credit, a legacy closure claim, earnings, rent and savings). The test +Credit, a legacy closure claim, earnings, rent and savings). One simulation calculates the same people five ways: as drawn; with the eligibility formula on main before this change (BEFORE, kept verbatim below); with no one flagged; with the flags of members who are neither claimant nor partner reversed; and @@ -107,38 +107,51 @@ sometimes = st.integers(0, 2).map(lambda n: n == 0) -class is_uc_eligible(Variable): +def main_eligibility(benunit, period, parameters): + """is_uc_eligible on main at 2da212abc, without its comments.""" + capital = benunit("uc_assessable_capital", period) + limit = parameters(period).gov.dwp.universal_credit.means_test.capital.limit + claimant = benunit.members("is_uc_single_or_joint_claimant", period) + meets_minimum_age = benunit.members("meets_uc_minimum_age_condition", period) + pension_age = benunit.members( + "has_attained_state_pension_credit_qualifying_age", period + ) + has_qualifying_claimant = benunit.any(claimant & meets_minimum_age & ~pension_age) + pension_credit_route = benunit("meets_pension_credit_age_conditions", period) + closure_claim = benunit("claims_uc_at_legacy_closure", period) + on_working_tax_credit = add(benunit, period, ["working_tax_credit_reported"]) > 0 + reg_60a = on_working_tax_credit & benunit.any(claimant & meets_minimum_age) + route = (has_qualifying_claimant & ~pension_credit_route) | ( + closure_claim & (has_qualifying_claimant | reg_60a) + ) + return route & (capital <= limit) + + +class uses_main_eligibility(Variable): value_type = bool entity = BenUnit - label = "Eligible for Universal Credit (before the education condition)" + label = "Takes is_uc_eligible from main's formula (test only)" definition_period = YEAR + default_value = False - def formula(benunit, period, parameters): - # main at 2da212abc, without its comments. - capital = benunit("uc_assessable_capital", period) - limit = parameters(period).gov.dwp.universal_credit.means_test.capital.limit - claimant = benunit.members("is_uc_single_or_joint_claimant", period) - meets_minimum_age = benunit.members("meets_uc_minimum_age_condition", period) - pension_age = benunit.members( - "has_attained_state_pension_credit_qualifying_age", period - ) - has_qualifying_claimant = benunit.any( - claimant & meets_minimum_age & ~pension_age - ) - pension_credit_route = benunit("meets_pension_credit_age_conditions", period) - closure_claim = benunit("claims_uc_at_legacy_closure", period) - on_working_tax_credit = ( - add(benunit, period, ["working_tax_credit_reported"]) > 0 - ) - reg_60a = on_working_tax_credit & benunit.any(claimant & meets_minimum_age) - route = (has_qualifying_claimant & ~pension_credit_route) | ( - closure_claim & (has_qualifying_claimant | reg_60a) - ) - return route & (capital <= limit) +class with_main_eligibility(Reform): + """Keeps this branch's is_uc_eligible, except for benefit units flagged + uses_main_eligibility, which take main's formula. One simulation then + holds the families as drawn and their copies under BEFORE.""" -class before_education_condition(Reform): def apply(self): + head = self.variables["is_uc_eligible"].get_formula() + + class is_uc_eligible(Variable): + def formula(benunit, period, parameters): + return where( + benunit("uses_main_eligibility", period), + main_eligibility(benunit, period, parameters), + head(benunit, period, parameters), + ) + + self.add_variable(uses_main_eligibility) self.update_variable(is_uc_eligible) @@ -214,11 +227,12 @@ def cases(draw): def situation(case, variants): """Each family in a household of its own, once for each variant. A variant - is None for the drawn inputs, or a function that takes a person's index - and inputs and returns the inputs to use.""" + is a pair: None for the drawn inputs, or a function that takes a person's + index and inputs and returns the inputs to use; and whether the family + takes main's eligibility formula (BEFORE).""" year = case["year"] people, benunits, households = {}, {}, {} - for v, change in enumerate(variants): + for v, (change, uses_main) in enumerate(variants): index = 0 for f, fam in enumerate(case["families"]): members = [] @@ -232,6 +246,7 @@ def situation(case, variants): index += 1 benunits[f"b{v}_{f}"] = { "members": members, + "uses_main_eligibility": {year: uses_main}, **{k: {year: x} for k, x in fam["benunit"].items()}, } households[f"h{v}_{f}"] = { @@ -268,11 +283,14 @@ def all_flags(case): ] -def run(case, variants=(None,), before=False): +DRAWN = (None, False) +BEFORE = (None, True) + + +def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) - if before: - simulation.apply_reform(before_education_condition) + simulation.apply_reform(with_main_eligibility) year = case["year"] values = {name: simulation.calculate(name, year) for name in VARIABLES} limit = simulation.tax_benefit_system.parameters( @@ -542,10 +560,16 @@ def others_reversed(i, values): def no_loans_or_grants(i, values): return {**values, "student_loans": 0, "education_grants": 0} - drawn, no_flags, reversed_others, no_loans = run( - case, variants=[None, unflagged, others_reversed, no_loans_or_grants] + drawn, no_flags, reversed_others, no_loans, before = run( + case, + variants=[ + DRAWN, + (unflagged, False), + (others_reversed, False), + (no_loans_or_grants, False), + BEFORE, + ], ) - (before,) = run(case, before=True) claimant = drawn["is_uc_single_or_joint_claimant"] np.testing.assert_array_equal(drawn["is_uc_assessed_claimant"], couple, context) @@ -642,33 +666,40 @@ def test_education_condition_invariants(case): def test_examples_reach_the_cases(): """The examples exclude the issue's student, apply the couple rule both - ways and send the young Housing Benefit claimant down the ordinary route, - so the invariants run on each outcome.""" - (issue,) = run(EXAMPLES[0]) - assert not issue["is_uc_eligible"][0] - assert issue["universal_credit"][0] == 0 - assert run(EXAMPLES[0], before=True)[0]["universal_credit"][0] > 0 - (couples,) = run(EXAMPLES[1]) - np.testing.assert_array_equal(couples["is_uc_eligible"], [False, True]) + ways and send the young Housing Benefit claimants down the ordinary + route, so the invariants run on each outcome. One simulation holds the + four examples, as drawn and under BEFORE.""" + assert all(example["year"] == 2026 for example in EXAMPLES) + case = dict( + year=2026, + families=[family for example in EXAMPLES for family in example["families"]], + ) + drawn, before = run(case, variants=[DRAWN, BEFORE]) + # Families: the issue's student; the student couples of EXAMPLES[1]; the + # mixed-age couples of EXAMPLES[2]; the young Housing Benefit claimants. + np.testing.assert_array_equal( + drawn["is_uc_eligible"], [False, False, True, True, False, True, True] + ) + assert drawn["universal_credit"][0] == 0 + assert before["universal_credit"][0] > 0 + # People 1 to 4: the mixed-age student couple and the foster couple. np.testing.assert_array_equal( - couples["uc_is_excepted_from_education_condition"], + drawn["uc_is_excepted_from_education_condition"][1:5], [True, False, True, False], ) - (mixed_age,) = run(EXAMPLES[2]) - np.testing.assert_array_equal(mixed_age["is_uc_eligible"], [True, False]) - (young,) = run(EXAMPLES[3]) + # People 9 and 10: the young Housing Benefit claimants. + young = slice(9, 11) np.testing.assert_array_equal( - young["is_qualifying_young_person_for_universal_credit"], [True, True] + drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] ) np.testing.assert_array_equal( - young["is_qualifying_young_person_for_uc_education"], [False, False] + drawn["is_qualifying_young_person_for_uc_education"][young], [False, False] ) - np.testing.assert_array_equal(young["uc_receiving_education"], [False, True]) + np.testing.assert_array_equal(drawn["uc_receiving_education"][young], [False, True]) np.testing.assert_array_equal( - young["uc_is_excepted_from_education_condition"], [False, True] + drawn["uc_is_excepted_from_education_condition"][young], [False, True] ) - np.testing.assert_array_equal(young["is_uc_eligible"], [True, True]) - assert np.all(young["universal_credit"] > 0) + assert np.all(drawn["universal_credit"][5:] > 0) def test_student_head_moves_non_dependant_to_joint_tenant(): From 13bbf82bffe680158e883961787c96a9749a816b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:34:11 -0400 Subject: [PATCH 11/27] Set the main-formula flag after the reform adds it Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_receiving_education_properties.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index c5940fa85b..ec5705c9b8 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -246,7 +246,6 @@ def situation(case, variants): index += 1 benunits[f"b{v}_{f}"] = { "members": members, - "uses_main_eligibility": {year: uses_main}, **{k: {year: x} for k, x in fam["benunit"].items()}, } households[f"h{v}_{f}"] = { @@ -292,6 +291,12 @@ def run(case, variants=(DRAWN,)): simulation = Simulation(situation=situation(case, variants)) simulation.apply_reform(with_main_eligibility) year = case["year"] + # Benefit units follow the situation's order: each variant's families. + simulation.set_input( + "uses_main_eligibility", + year, + np.array([uses_main for _, uses_main in variants for _ in case["families"]]), + ) values = {name: simulation.calculate(name, year) for name in VARIABLES} limit = simulation.tax_benefit_system.parameters( f"{year}-01-01" From 27e004ace135899a9fa40eae658856f1d3055f63 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:13:58 -0500 Subject: [PATCH 12/27] Reword a test name and two comments in active voice Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/universal_credit/uc_receiving_education.yaml | 8 ++++---- .../is_qualifying_young_person_for_uc_education.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index a0e03f8c5b..7a0cd22b8c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -438,7 +438,7 @@ is_uc_eligible: true universal_credit: 5_098.80 -- name: Case 17, a full-time course in higher education is advanced education. +- name: Case 17, the model reads a full-time course in higher education as advanced education. # Reg. 12(2)(a) and (3): the model reads in_HE when current_education has # no input. period: 2026 @@ -842,9 +842,9 @@ universal_credit: 0 - name: Case 33, a couple of 18-year-olds on a joint Working Tax Credit award both leave the qualifying young person route. - # Tax Credits Act 2002 s. 3(3)(a): a couple claims jointly, so both - # members are entitled to the award, and SI 2014/1230 reg. 28 applies to - # each, whichever member reports it. + # Tax Credits Act 2002 s. 3(3)(a): a couple claims jointly, so the award + # belongs to both members, and SI 2014/1230 reg. 28 applies to each, + # whichever member reports it. period: 2024 input: people: diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index fe6dc5971b..9e19c0ebfb 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -53,7 +53,7 @@ def formula(person, period, parameters): # Credit Regulations". own_award = add(person, period, EXISTING_BENEFITS) > 0 # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a - # tax credit jointly, so both are entitled to the award. + # tax credit jointly, so the award belongs to both. claimant_or_partner = person("is_claimant_or_partner", period) joint_tax_credits = claimant_or_partner & ( person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) From 3efd637617305631d2d87f96c6a71d23ccc003f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:35:34 -0500 Subject: [PATCH 13/27] Test reg 60 for a reg 28 migrant and the reg 60A gate Case 34 gives case 32's 18-year-old a closure claim: reg 28 sends them to reg 12(2), so reg 60(1) lifts the condition. Case 35 puts Working Tax Credit on another adult in a qualifying young person's benefit unit: reg 60A waives only s.4(1)(b), so the claimant still fails s.4(1)(d). Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 7a0cd22b8c..b35ed7309c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -868,3 +868,75 @@ is_qualifying_young_person_for_universal_credit: [true, true] is_qualifying_young_person_for_uc_education: [false, false] uc_receiving_education: [false, false] + +- name: Case 34, the 18-year-old of case 32 claims when DWP moves them off legacy benefits and keeps the award. + # Reg. 28 takes the claimant out of the qualifying young person route, so + # they receive education under reg. 12(2)(b), not reg. 12(1A). SI 2014/1230 + # reg. 60(1) lifts the condition from a person who doesn't meet it on the + # migration day "because the person is undertaking a full-time course (see + # regulation 12(2) and 13 ...)". + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + has_education_maintenance_funding: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + claims_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [true] + meets_uc_education_condition: [true] + is_uc_eligible: true + +- name: Case 35, Working Tax Credit reported by another adult in the benefit unit doesn't lift the condition from a qualifying young person. + # The model's benefit unit can hold an adult who is neither claimant, + # partner, child nor qualifying young person, and the model sums reported + # Working Tax Credit over every member, so this family takes the reg. 60A + # route. Reg. 60A(1) waives only "the condition in section 4(1)(b) of the + # Act (claimant has not reached the qualifying age for state pension + # credit)". The claimant, an 18-year-old qualifying young person on a + # full-time non-advanced course, receives education under reg. 12(1A), so + # reg. 60(1), which covers reg. 12(2), doesn't apply, and no reg. 14(1) + # exception does: the other adult isn't a child or qualifying young + # person (reg. 14(1)(c)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + is_claimant_or_partner: true + person2: + age: 25 + working_tax_credit_reported: 1_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [person1, person2] + claims_uc_at_legacy_closure: true + households: + household: + members: [person1, person2] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_uc_education: [true, false] + uc_receiving_education: [true, false] + uc_is_excepted_from_education_condition: [false, false] + meets_uc_education_condition: [false, true] + is_uc_eligible: false + universal_credit: 0 From a93f1cbbe8e9ea4ff184e18b5c5e32a3d58cc230 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:01:54 -0500 Subject: [PATCH 14/27] Name every input that reaches the reg 14(1)(b) exception uc_limited_capability_for_work takes uc_limited_capability_for_WRA as well as is_disabled_for_benefits, so a user can set either assessment input for an ESA determination. The documentation also names the actor in each sentence. Co-Authored-By: Claude Opus 5.5 --- ...uc_is_excepted_from_education_condition.py | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 27cb647edd..97466013a6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -6,7 +6,7 @@ class uc_is_excepted_from_education_condition(Variable): entity = Person label = "Excepted from the Universal Credit condition of not receiving education" documentation = ( - "Whether this person does not have to meet the basic condition in the " + "Whether this person doesn't have to meet the basic condition in the " "Welfare Reform Act 2012 s. 4(1)(d) (not receiving education), under " "the Universal Credit Regulations 2013 reg. 14(1) or the Universal " "Credit (Transitional Provisions) Regulations 2014 reg. 60. The model " @@ -15,39 +15,41 @@ class uc_is_excepted_from_education_condition(Variable): "Living Allowance or PIP with limited capability for work " "(uc_limited_capability_for_work); (c) responsibility for a child or " "qualifying young person in the benefit unit; (d) a single foster " - "parent with whom a child is placed; (e)(ii) a member of a couple " - "who both receive education, whose partner is such a foster parent; " - "(f) the member of a mixed-age couple who has reached the qualifying " - "age for State Pension Credit; and reg. 60(1), a member of a family " - "that claims Universal Credit when DWP moves it off its legacy " - "benefits (claims_uc_at_legacy_closure) and who doesn't count as a " - "qualifying young person for the condition " + "parent with whom a child is placed; (e)(ii) a member of a couple, " + "both of whom receive education, whose partner is such a foster " + "parent; (f) the member of a mixed-age couple who has reached the " + "qualifying age for State Pension Credit; and reg. 60(1), a member of " + "a family that claims Universal Credit when DWP moves it off its " + "legacy benefits (claims_uc_at_legacy_closure) and who doesn't count " + "as a qualifying young person for the condition " "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " - "of the 2014 Regulations). Limb (e)(i) needs no rule of its own: the " - "model places responsibility for a child on the benefit unit, so " + "of the 2014 Regulations). The model needs no rule of its own for limb " + "(e)(i): it places responsibility for a child on the benefit unit, so " "both members of the couple meet (c). Under (b), the decision maker " - "must determine limited capability for work before the course " - "starts. uc_limited_capability_for_work can derive from disability " - "status (is_disabled_for_benefits) without an assessment. The model " - "can therefore retain students who have never received a limited " - "capability for work determination, as well as students assessed " - "after their course began. The model does not infer the " - "determination from an ESA award, so an ESA assessment reaches this " - "limb only where a user sets uc_limited_capability_for_work or a " - "dataset sets is_disabled_for_benefits. The model doesn't apply (a), " - "for a person under 21, or 21 having reached that age during the " - "course, in non-advanced education without parental support, since " - "it doesn't observe parental support; the other benefits (b) lists " - "(Scottish disability assistance, constant attendance allowance and " - "war pension attendance payments), which it has no variables for; " - "or reg. 60(2), which ends the transitional protection. The " - "model identifies foster parents through either input, " + "must determine limited capability for work before the course starts. " + "The model can derive uc_limited_capability_for_work from disability " + "status (is_disabled_for_benefits) without an assessment, so it can " + "keep students who have never had a limited capability for work " + "determination, as well as students assessed after their course began. " + "The model doesn't infer limited capability for work from an ESA " + "award: a user who wants an ESA assessment to count sets " + "uc_limited_capability_for_work or uc_limited_capability_for_WRA, or a " + "user or dataset sets is_disabled_for_benefits, from which the model " + "derives limited capability for work. The model doesn't apply (a), for " + "a person under 21, or 21 having reached that age during the course, " + "in non-advanced education without parental support, since it has no " + "input for parental support; the other benefits in (b) (Scottish " + "disability assistance, constant attendance allowance and war pension " + "attendance payments), since it has no variables for them; or reg. " + "60(2), so it keeps the protection after transitional protection would " + "have ended. The model identifies foster parents through either input, " "uc_is_responsible_foster_parent_of_child_under_one or " - "uc_is_foster_parent_or_new_friend_or_family_carer. The second also " - "holds a foster parent between placements, with no child placed, " - "and a friend or family carer, who is responsible for the child and " - "so meets (c) under the law. The Universal Credit Regulations " - "(Northern Ireland) 2016 reg. 14(1) lists the same exceptions." + "uc_is_foster_parent_or_new_friend_or_family_carer. A user can also " + "set the second for a foster parent between placements, with no child " + "placed, and for a friend or family carer, who is responsible for the " + "child and so meets (c) under the law. In Northern Ireland the " + "decision maker applies the same exceptions under reg. 14(1) of the " + "Universal Credit Regulations (Northern Ireland) 2016." ) definition_period = YEAR reference = ( From bff0c39c3adba2b5cb43ba45f9c3a1460c1e7d2f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:02:27 -0500 Subject: [PATCH 15/27] Describe the main formula copy as AST-identical in the property test The copy drops main's comments, and the formatter re-wrapped two of its statements, so "verbatim" overstated it. The module docstring also names the actor for regs 12 and 14 and for Schedule 4 para 9(2)(f). Co-Authored-By: Claude Opus 5.5 --- .../test_uc_receiving_education_properties.py | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index ec5705c9b8..bffe1b373d 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -1,16 +1,17 @@ """Universal Credit's condition that a claimant is not receiving education. -The Welfare Reform Act 2012 s. 4(1)(d) makes it a basic condition that a -claimant "is not receiving education". The Universal Credit Regulations 2013 -reg. 12 says who receives education, and reg. 14(1) lists exceptions. Reg. 3(2) -lets a couple claim jointly where one member doesn't meet the age condition -or this one, and DWP's Advice for Decision Making E2016 applies it "where one -member of a couple meets all the basic UC conditions". The Universal Credit -(Transitional Provisions) Regulations 2014 reg. 28 takes a person entitled to -an existing benefit out of the definition of a qualifying young person. Only -a person flagged is_in_full_time_education can receive education in the -model, and only has_education_maintenance_funding meets the funding test in -reg. 12(2)(b). +A Universal Credit claimant must meet the basic condition in the Welfare +Reform Act 2012 s. 4(1)(d) that they are "not receiving education". In the +Universal Credit Regulations 2013, the Secretary of State sets out who +receives education (reg. 12) and who doesn't have to meet the condition (reg. +14(1)). Under reg. 3(2), a couple can claim jointly where one member doesn't +meet the age condition or this one, and DWP's Advice for Decision Making +E2016 applies it "where one member of a couple meets all the basic UC +conditions". Under the Universal Credit (Transitional Provisions) +Regulations 2014 reg. 28, a person entitled to an existing benefit doesn't +count as a qualifying young person. In the model, only a person flagged +is_in_full_time_education can receive education, and only +has_education_maintenance_funding meets the funding test in reg. 12(2)(b). Each drawn case is a year and up to four families, each in a household of its own: a single adult or a couple, up to two children or young people, and @@ -20,9 +21,10 @@ foster flags, an ineligible partner, reported Housing Benefit and Working Tax Credit, a legacy closure claim, earnings, rent and savings). One simulation calculates the same people five ways: as drawn; with the eligibility formula -on main before this change (BEFORE, kept verbatim below); with no one flagged; -with the flags of members who are neither claimant nor partner reversed; and -with every student loan and education grant removed. +on main before this change (BEFORE, copied below with its comments removed; +the formatter re-wrapped two lines); with no one flagged; with the flags of +members who are neither claimant nor partner reversed; and with every student +loan and education grant removed. Invariants: 1. Never raises: each family's Universal Credit is either what it is under @@ -55,10 +57,11 @@ A family in a household with other families can lose Universal Credit because of another family's student: when the household head's family cannot claim, its non-dependants count in the next claim by a family liable for the -rent. Paragraph 9(2)(f) of Schedule 4 prevents duplicate non-dependant -treatment. The model assigns non-dependants to the household head's eligible -claim first. test_student_head_moves_non_dependant_to_joint_tenant pins it; -the invariants above hold for families with households of their own. +rent. Under Schedule 4 para. 9(2)(f), DWP doesn't count a person as a +non-dependant in a claim if it already counts them in another claim by someone +liable for the rent, and the model assigns non-dependants to the household +head's eligible claim first. test_student_head_moves_non_dependant_to_joint_tenant +pins it; the invariants above hold for families with households of their own. """ import json From 99dea0be99d7d46986411657b3fae687b114caff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:02:44 -0500 Subject: [PATCH 16/27] Name the actor in the education condition documentation Apply the voice review to the documentation of the condition, receiving education, the reg 28 status, both new inputs and the changelog fragment. The maintenance input no longer says education_grants holds childcare or disability grants: the FRS build allocates those to their own variables. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2346.fixed.md | 2 +- ...ualifying_young_person_for_uc_education.py | 22 +++++----- .../meets_uc_education_condition.py | 17 +++---- .../uc_receiving_education.py | 44 +++++++++---------- .../has_education_maintenance_funding.py | 29 ++++++------ .../demographic/is_in_full_time_education.py | 14 +++--- 6 files changed, 63 insertions(+), 65 deletions(-) diff --git a/changelog.d/2346.fixed.md b/changelog.d/2346.fixed.md index f2bb285ef5..045c8dc4b9 100644 --- a/changelog.d/2346.fixed.md +++ b/changelog.d/2346.fixed.md @@ -1 +1 @@ -The model now applies the Universal Credit condition that a claimant is not receiving education, with its exceptions, to people the new `is_in_full_time_education` input flags, and reads maintenance funding for non-advanced courses from the new `has_education_maintenance_funding` input. +The model now applies the Universal Credit condition that a claimant is not receiving education, with its exceptions, to people a user or dataset flags with the new `is_in_full_time_education` input, and reads maintenance funding for non-advanced courses from the new `has_education_maintenance_funding` input. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 9e19c0ebfb..4f124e8860 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -23,17 +23,17 @@ class is_qualifying_young_person_for_uc_education(Variable): "and have no entitlement to an existing benefit. Under the Universal " "Credit (Transitional Provisions) Regulations 2014 reg. 28, a person " "entitled to an existing benefit doesn't count as a qualifying young " - "person, and reg. 2(1) lists the existing benefits: income-based " - "Jobseeker's Allowance, " - "income-related Employment and Support Allowance, Income Support, " - "Housing Benefit, Child Tax Credit and Working Tax Credit. The model " - "reads entitlement from the year's reported receipt: the person's " - "own report of any of them, or, for a claimant or partner, either " - "member's report of tax credits, which a couple claims jointly. A " - "person outside the definition under reg. 28 takes the ordinary route " - "under the Universal Credit Regulations 2013 reg. 12(2). " - "is_qualifying_young_person_for_universal_credit does not apply " - "reg. 28, and the model's other rules read it." + "person, and the Secretary of State lists the existing benefits in " + "reg. 2(1): income-based Jobseeker's Allowance, income-related " + "Employment and Support Allowance, Income Support, Housing Benefit, " + "Child Tax Credit and Working Tax Credit. The model reads entitlement " + "from the year's reported receipt: the person's own report of any of " + "them, or, for a claimant or partner, either member's report of tax " + "credits, which a couple claims jointly. A person outside the " + "definition under reg. 28 takes the ordinary route under the Universal " + "Credit Regulations 2013 reg. 12(2). The model's other rules read " + "is_qualifying_young_person_for_universal_credit, which leaves reg. 28 " + "out." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py index fc794f2f5b..516fcc402c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_education_condition.py @@ -7,14 +7,15 @@ class meets_uc_education_condition(Variable): label = "Meets the Universal Credit condition of not receiving education" documentation = ( "Whether this person meets the basic condition in the Welfare Reform " - "Act 2012 s. 4(1)(d) that a claimant is not receiving education, or " - "is excepted from it: they are not receiving education " - "(uc_receiving_education), or an exception applies " - "(uc_is_excepted_from_education_condition). The Welfare Reform " - "(Northern Ireland) Order 2015 art. 9(1)(d) sets the same condition. " - "is_uc_eligible needs one claimant to meet it and the age condition " - "together, as DWP's Advice for Decision Making E2016 reads the " - "Universal Credit Regulations 2013 reg. 3(2) for a couple." + "Act 2012 s. 4(1)(d) that a claimant is not receiving education, or is " + "excepted from it: they don't receive education " + "(uc_receiving_education), or they fall within an exception " + "(uc_is_excepted_from_education_condition). Claimants in Northern " + "Ireland meet the same condition under the Welfare Reform (Northern " + "Ireland) Order 2015 art. 9(1)(d). The model (is_uc_eligible) needs " + "one claimant to meet it and the age condition together, as DWP reads " + "the Universal Credit Regulations 2013 reg. 3(2) in its Advice for " + "Decision Making E2016." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py index 45cd2e875b..ad61c56fea 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -6,31 +6,31 @@ class uc_receiving_education(Variable): entity = Person label = "Receiving education for Universal Credit" documentation = ( - "Whether this person is receiving education under the Universal " - "Credit Regulations 2013 reg. 12, and so does not meet the basic " - "condition in the Welfare Reform Act 2012 s. 4(1)(d) unless an " - "exception applies (uc_is_excepted_from_education_condition). A " - "person on a full-time course (is_in_full_time_education) receives " - "education where they take a course of advanced education (reg. " - "12(2)(a)), which the model reads from tertiary current_education or " - "in_HE; " - "where they have a student loan or grant for their maintenance " - "(reg. 12(2)(b)), which it reads from " - "has_education_maintenance_funding; or where they count as a " - "qualifying young person (reg. 12(1A)) after the Universal Credit " + "Whether this person receives education under the Universal Credit " + "Regulations 2013 reg. 12, and so fails the basic condition in the " + "Welfare Reform Act 2012 s. 4(1)(d) unless they fall within an " + "exception (uc_is_excepted_from_education_condition). A person on a " + "full-time course (is_in_full_time_education) receives education where " + "they take a course of advanced education (reg. 12(2)(a)), which the " + "model reads from tertiary current_education or in_HE; where they have " + "a student loan or grant for their maintenance (reg. 12(2)(b)), which " + "it reads from has_education_maintenance_funding; or where they count " + "as a qualifying young person (reg. 12(1A)) after the Universal Credit " "(Transitional Provisions) Regulations 2014 reg. 28 " "(is_qualifying_young_person_for_uc_education). The model treats a " "person with is_in_full_time_education false as not receiving " - "education, so it does not apply reg. 12(1A) to a qualifying young " - "person outside full-time education, or reg. 12(4) to a course that " - "is incompatible with a work-related requirement. Nor does it apply " - "the exception in reg. 12(1A) for a relevant training scheme. Under " - "reg. 13 a person undertakes a course from its first day to its " - "last, vacations included, unless they abandon it or the " - "establishment dismisses them; the model applies the input to the " - "whole year. The Universal Credit Regulations (Northern Ireland) " - "2016 reg. 12 sets the same test, except that its reg. 12(1) has no " - "exception for a training scheme." + "education, so it doesn't apply reg. 12(1A) to a qualifying young " + "person outside full-time education, or reg. 12(4) to a course " + "incompatible with a work-related requirement the Secretary of State " + "imposes. Nor does it apply the exception in reg. 12(1A) for a " + "relevant training scheme. Under reg. 13 a person undertakes a course " + "from its first day to its last, vacations included, unless they " + "abandon it or the establishment dismisses them; the model applies the " + "input to the whole year. In Northern Ireland the decision maker " + "applies the same test under reg. 12 of the Universal Credit " + "Regulations (Northern Ireland) 2016, and treats a qualifying young " + "person in a training scheme as receiving education too (NI reg. " + "12(1))." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py index ad1482dc00..613042744d 100644 --- a/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py +++ b/policyengine_uk/variables/household/demographic/has_education_maintenance_funding.py @@ -6,22 +6,19 @@ class has_education_maintenance_funding(Variable): entity = Person label = "Has a student loan or grant for maintenance on their course" documentation = ( - "Whether this person has a student loan or grant for their " - "maintenance on their course of study or training. A person on a " - "full-time course other than a course of advanced education receives " - "education for Universal Credit when they have one (Universal Credit " - "Regulations 2013 reg. 12(2)(b)). Reg. 2 takes both terms from reg. " - "68(7), which defines a student loan as a loan towards a student's " - "maintenance under the student support legislation, and a grant as " - "any educational grant or award, except a student loan or a payment " - "under a scheme that helps people under 21 complete courses other " - "than advanced education. The test needs funding for maintenance, so " - "a grant for fees, equipment or travel alone doesn't count. An input " - "that stays false " - "until a user or dataset sets it. The model does not derive it from " - "student_loans or education_grants: neither says what the support " - "pays for, and education_grants can hold grants for childcare or " - "disability costs." + "Whether this person has a student loan or grant for their maintenance " + "on their course of study or training. A person on a full-time course " + "other than a course of advanced education receives education for " + "Universal Credit when they have one (Universal Credit Regulations " + "2013 reg. 12(2)(b)). DWP counts as a student loan only a loan towards " + "a student's maintenance under the student support legislation, and as " + "a grant any educational grant or award other than a student loan or a " + "payment under a scheme that helps people under 21 finish non-advanced " + "courses (reg. 68(7), applied by reg. 2). A person whose grant pays " + "only for fees, equipment or travel doesn't meet reg. 12(2)(b). It " + "stays false until a user or dataset sets it. The model doesn't derive " + "it from student_loans or education_grants, since it can't tell from " + "either amount what the support pays for." ) definition_period = YEAR set_input = set_input_dispatch_by_period diff --git a/policyengine_uk/variables/household/demographic/is_in_full_time_education.py b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py index 0061026a6d..6bebaf04e0 100644 --- a/policyengine_uk/variables/household/demographic/is_in_full_time_education.py +++ b/policyengine_uk/variables/household/demographic/is_in_full_time_education.py @@ -6,13 +6,13 @@ class is_in_full_time_education(Variable): entity = Person label = "On a full-time course of education or training" documentation = ( - "Whether this person is undertaking a full-time course of study or " - "training, including the vacations within the course. An input that " - "stays false until a user or dataset sets it: no formula derives it. " - "It says only that the course is full-time; current_education and " - "in_HE give its level. Universal " - "Credit reads it for the condition that a claimant is not receiving " - "education (uc_receiving_education)." + "Whether this person undertakes a full-time course of study or " + "training, including the vacations within the course. It stays false " + "until a user or dataset sets it, and no formula derives it. It " + "records only that the course is full-time: the model reads the level " + "from current_education and in_HE, and reads this input for Universal " + "Credit's condition that a claimant is not receiving education " + "(uc_receiving_education)." ) definition_period = YEAR set_input = set_input_dispatch_by_period From db140e0cae9090006e3f1d34acd9384681987bb0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:46:05 -0500 Subject: [PATCH 17/27] Name the actor in the comments of YAML cases 34 and 35 Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index b35ed7309c..47a8de469f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -870,11 +870,12 @@ uc_receiving_education: [false, false] - name: Case 34, the 18-year-old of case 32 claims when DWP moves them off legacy benefits and keeps the award. - # Reg. 28 takes the claimant out of the qualifying young person route, so - # they receive education under reg. 12(2)(b), not reg. 12(1A). SI 2014/1230 - # reg. 60(1) lifts the condition from a person who doesn't meet it on the + # The claimant reports Housing Benefit, so under SI 2014/1230 reg. 28 they + # don't count as a qualifying young person, and they receive education + # under reg. 12(2)(b), not reg. 12(1A). They fail the condition on the # migration day "because the person is undertaking a full-time course (see - # regulation 12(2) and 13 ...)". + # regulation 12(2) and 13 ...)", so under reg. 60(1) it doesn't apply to + # them while they continue the course. period: 2026 input: people: @@ -904,13 +905,13 @@ # The model's benefit unit can hold an adult who is neither claimant, # partner, child nor qualifying young person, and the model sums reported # Working Tax Credit over every member, so this family takes the reg. 60A - # route. Reg. 60A(1) waives only "the condition in section 4(1)(b) of the - # Act (claimant has not reached the qualifying age for state pension - # credit)". The claimant, an 18-year-old qualifying young person on a - # full-time non-advanced course, receives education under reg. 12(1A), so - # reg. 60(1), which covers reg. 12(2), doesn't apply, and no reg. 14(1) - # exception does: the other adult isn't a child or qualifying young - # person (reg. 14(1)(c)). + # route. Under reg. 60A(1) the Secretary of State waives only "the + # condition in section 4(1)(b) of the Act (claimant has not reached the + # qualifying age for state pension credit)". The claimant, an 18-year-old + # qualifying young person on a full-time non-advanced course, receives + # education under reg. 12(1A). Reg. 60(1) covers only a person on a course + # under reg. 12(2), and the claimant has no reg. 14(1) exception: the + # other adult isn't a child or qualifying young person (reg. 14(1)(c)). period: 2026 absolute_error_margin: 0.01 input: From 5c833995f7bf6120d0e1b2c7d42974e44fe9daf9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:24:27 -0500 Subject: [PATCH 18/27] Name the actor in the education test comments and docstrings Rewords the YAML case comments and the property file's docstrings so that DWP, the decision maker or the Secretary of State acts under each provision, and replaces be-verb identities with verbs. No test input, expected value or executable line changes. Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 142 ++++++++++-------- .../test_uc_receiving_education_properties.py | 49 +++--- 2 files changed, 101 insertions(+), 90 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 47a8de469f..77584cce39 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1,16 +1,18 @@ # Universal Credit's basic condition that a claimant "is not receiving # education" (Welfare Reform Act 2012 s. 4(1)(d)), with the meaning in the # Universal Credit Regulations 2013 reg. 12, the exceptions in reg. 14(1) and -# the couple rule in reg. 3(2), which DWP's Advice for Decision Making E2016 -# applies where one member of a couple meets all the basic conditions. +# the couple rule in reg. 3(2), which DWP applies under its Advice for +# Decision Making E2016 where one member of a couple meets all the basic +# conditions. # -# Expected amounts use DWP's monthly rates for 2026 to 2027 (Benefit and -# pension rates 2026 to 2027, gov.uk): standard allowance £338.58 for a single -# claimant under 25, £424.90 for one aged 25 or over, £528.34 for joint -# claimants both under 25 and £666.97 where one or both are 25 or over; child -# element £351.88 for a first child born before 6 April 2017 and £303.94 for -# any other child. The award is 12 times the monthly amount, less 55% of -# earnings (reg. 22(1)(b)); no case has housing costs except cases 1 and 2. +# We calculate expected amounts from DWP's monthly rates for 2026 to 2027 +# (Benefit and pension rates 2026 to 2027, gov.uk): standard allowance £338.58 +# for a single claimant under 25, £424.90 for one aged 25 or over, £528.34 for +# joint claimants both under 25 and £666.97 where one or both are 25 or over; +# child element £351.88 for a first child born before 6 April 2017 and £303.94 +# for any other child. Each award comes to 12 times the monthly amount, less +# 55% of earnings (reg. 22(1)(b)); no case has housing costs except cases 1 +# and 2. - name: Case 1, issue 2346, a full-time university student aged 19 gets no Universal Credit. # Reg. 12(2)(a): a full-time course of advanced education (a first degree, @@ -101,10 +103,10 @@ universal_credit: 7_710.24 - name: Case 4, a couple where one member is a full-time student and the other is not claim jointly. - # Reg. 3(2)(b): one member does not meet the condition and is not - # excepted, and the other meets it, so they are joint claimants at the - # couple rate: 12 x £666.97 = £8,003.64. The model does not count student - # income (reg. 68), and this couple has none. + # Reg. 3(2)(b): one member doesn't meet the condition and has no + # exception, and the other meets it, so they claim jointly at the couple + # rate: 12 x £666.97 = £8,003.64. The model does not count student income + # (reg. 68), and this couple has none. period: 2026 absolute_error_margin: 0.01 input: @@ -129,8 +131,8 @@ universal_credit: 8_003.64 - name: Case 5, a couple who are both full-time students with no exception get no Universal Credit. - # Reg. 3(2)(b) needs the other member to meet the condition or be - # excepted from it; neither does. + # Under reg. 3(2)(b), the other member must meet the condition or have an + # exception; neither does. period: 2026 input: people: @@ -157,8 +159,8 @@ - name: Case 6, a student couple where the other member is responsible for a child keeps the award. # Reg. 14(1)(e)(i): both members receive education and the other member # is responsible for a child. The model places responsibility on the - # benefit unit, so reg. 14(1)(c) excepts each member too. - # 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. + # benefit unit, so each member need not meet the condition under reg. + # 14(1)(c). 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. period: 2026 absolute_error_margin: 0.01 input: @@ -187,10 +189,11 @@ universal_credit: 9_987.36 - name: Case 7, a mixed-age couple who are both full-time students get no Universal Credit under ADM E2016. - # Reg. 14(1)(f) excepts the member who has reached the qualifying age for - # State Pension Credit, but that member doesn't meet the age condition. - # The younger member meets the age condition but receives education with - # no exception. ADM E2016 needs one member to meet all the basic + # Under reg. 14(1)(f), the member who has reached the qualifying age for + # State Pension Credit need not meet the education condition, but that + # member doesn't meet the age condition. The younger member meets the age + # condition but receives education with no exception. Under ADM E2016, DWP + # accepts a joint claim only where one member meets all the basic # conditions, and neither does. Since 15 May 2019 the couple cannot get # Pension Credit either (SPCA 2002 s. 4(1A)). period: 2026 @@ -220,7 +223,8 @@ - name: Case 8, a mixed-age couple whose younger member alone is a full-time student get no Universal Credit under ADM E2016. # The older member meets the education condition and the younger the age - # condition, but neither meets both, as ADM E2016 needs one member to do. + # condition, but neither meets both. Under ADM E2016, DWP needs one member + # to meet both. period: 2026 input: people: @@ -268,7 +272,8 @@ universal_credit: 5_098.80 - name: Case 10, a student with PIP but not limited capability for work gets no Universal Credit. - # Reg. 14(1)(b) needs both. + # Under reg. 14(1)(b), the claimant needs both entitlement to a listed + # disability benefit and limited capability for work. period: 2026 input: people: @@ -292,7 +297,8 @@ - name: Case 11, a single foster parent on a full-time course keeps the award. # Reg. 14(1)(d). A foster child is not a member of the benefit unit, so - # the award is the standard allowance alone: 12 x £424.90 = £5,098.80. + # the award comes to the standard allowance alone: 12 x £424.90 = + # £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -315,9 +321,9 @@ universal_credit: 5_098.80 - name: Case 12, a student couple whose other member is a foster parent keeps the award. - # Reg. 14(1)(e)(ii) excepts the member whose partner is the foster - # parent; reg. 3(2)(b) then lets the couple claim jointly. 12 x £666.97 = - # £8,003.64. + # Under reg. 14(1)(e)(ii), the member whose partner fosters the child need + # not meet the condition, so the couple can claim jointly under reg. + # 3(2)(b). 12 x £666.97 = £8,003.64. period: 2026 absolute_error_margin: 0.01 input: @@ -344,9 +350,10 @@ universal_credit: 8_003.64 - name: Case 13, a 17-year-old qualifying young person on a full-time course gets no Universal Credit. - # Reg. 8(1)(a) lets them claim at 16 with limited capability for work, but - # reg. 12(1A) treats a qualifying young person as receiving education, - # and without a disability benefit reg. 14(1)(b) does not apply. + # Under reg. 8(1)(a), they can claim at 16 with limited capability for + # work. The decision maker treats them as receiving education under reg. + # 12(1A), and without a disability benefit they don't have the reg. + # 14(1)(b) exception. period: 2026 input: people: @@ -415,8 +422,8 @@ universal_credit: 0 - name: Case 16, a claimant on a non-advanced full-time course with no loan or grant does not receive education. - # Neither limb of reg. 12(2) applies, and at 30 the person is not a - # qualifying young person. 12 x £424.90 = £5,098.80. + # Neither limb of reg. 12(2) applies, and at 30 the person doesn't count as + # a qualifying young person. 12 x £424.90 = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -460,8 +467,8 @@ is_uc_eligible: false - name: Case 18, a member of a couple claiming as a single person must meet the condition. - # The other member cannot be a joint claimant (reg. 3(3)), so reg. 3(2)(b) - # does not apply and the student's claim fails. + # The student claims as a single person under reg. 3(3), so reg. 3(2)(b) + # doesn't apply and the claim fails. period: 2026 input: people: @@ -511,8 +518,8 @@ - name: Case 20, a 17-year-old in full-time education does not change a parent's award. # The condition applies to claimants, not to the qualifying young person # they are responsible for. 12 x (£424.90 + £351.88) = 12 x £776.78 = - # £9,321.36: a child born in 2009 is a first child born before 6 April - # 2017. + # £9,321.36: a child born in 2009 counts as a first child born before 6 + # April 2017. period: 2026 absolute_error_margin: 0.01 input: @@ -577,9 +584,10 @@ is_uc_eligible: false - name: Case 23, a mixed-age couple whose older member alone is a full-time student keep the award. - # Reg. 14(1)(f) excepts the older member, and the younger member meets - # the age condition and the education condition, so reg. 3(2)(a) lets the - # couple claim jointly (ADM E2016). 12 x £666.97 = £8,003.64. + # Under reg. 14(1)(f), the older member need not meet the education + # condition, and the younger member meets the age and education + # conditions, so the couple can claim jointly under reg. 3(2)(a) (ADM + # E2016). 12 x £666.97 = £8,003.64. period: 2026 absolute_error_margin: 0.01 input: @@ -627,9 +635,9 @@ universal_credit: 8_003.64 - name: Case 25, a mixed-age couple whose younger student claims when DWP moves them off legacy benefits keep the award. - # SI 2014/1230 reg. 60(1) lifts the condition from the younger member - # while they continue the course, so that member meets both conditions. - # 12 x £666.97 = £8,003.64. + # Under SI 2014/1230 reg. 60(1), the younger member need not meet the + # education condition while they continue the course, so that member meets + # both conditions. 12 x £666.97 = £8,003.64. period: 2026 absolute_error_margin: 0.01 input: @@ -654,9 +662,9 @@ universal_credit: 8_003.64 - name: Case 26, a pension-age student couple on Working Tax Credit claim under reg. 60A when DWP moves them. - # SI 2014/1230 reg. 60A waives the upper age condition, and reg. 60(1) - # lifts the education condition from both members. 12 x £666.97 = - # £8,003.64. + # Under SI 2014/1230 reg. 60A, the Secretary of State waives the upper age + # condition. Neither member needs to meet the education condition under + # reg. 60(1). 12 x £666.97 = £8,003.64. period: 2026 absolute_error_margin: 0.01 input: @@ -685,9 +693,10 @@ universal_credit: 8_003.64 - name: Case 27, a student with Attendance Allowance and limited capability for work keeps the award. - # Reg. 14(1)(b) names attendance allowance. The case gives a working-age - # claimant Attendance Allowance and no other listed benefit, to test that - # benefit on its own. 12 x £424.90 = £5,098.80. + # Under reg. 14(1)(b), DWP counts Attendance Allowance as a listed + # disability benefit. We give a working-age claimant Attendance Allowance + # and no other listed benefit to test that benefit on its own. 12 x £424.90 + # = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -711,9 +720,9 @@ universal_credit: 5_098.80 - name: Case 28, a student with Armed Forces Independence Payment and limited capability for work keeps the award. - # Reg. 2 counts Armed Forces Independence Payment as attendance allowance - # for reg. 14(1)(b), and reg. 66 doesn't count it as unearned income. - # 12 x £424.90 = £5,098.80. + # Under reg. 2, DWP counts Armed Forces Independence Payment as attendance + # allowance for reg. 14(1)(b), and excludes it from unearned income under + # reg. 66. 12 x £424.90 = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -737,8 +746,8 @@ universal_credit: 5_098.80 - name: Case 29, a student with Disability Living Allowance and limited capability for work keeps the award. - # Reg. 14(1)(b) names disability living allowance. 12 x £424.90 = - # £5,098.80. + # Under reg. 14(1)(b), DWP counts Disability Living Allowance as a listed + # disability benefit. 12 x £424.90 = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -762,11 +771,11 @@ universal_credit: 5_098.80 - name: Case 30, a claimant on a non-advanced full-time course with an equipment grant and no maintenance funding keeps the award. - # Reg. 12(2)(b) needs a loan or grant for the person's maintenance. - # Learner Support can help with materials and equipment (gov.uk/learner- - # support), and the claimant's student loan doesn't fund maintenance - # here, so the claimant doesn't receive education. 12 x £424.90 = - # £5,098.80. + # Under reg. 12(2)(b), the person must have a loan or grant for their + # maintenance. The claimant can use Learner Support for materials and + # equipment (gov.uk/learner-support), and the claimant's student loan + # doesn't fund maintenance here, so the claimant doesn't receive education. + # 12 x £424.90 = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -792,9 +801,9 @@ - name: Case 31, an 18-year-old on Housing Benefit and a non-advanced full-time course takes the ordinary route. # SI 2014/1230 reg. 28: a person entitled to an existing benefit, here - # Housing Benefit (reg. 2(1)), is not a qualifying young person for the - # Universal Credit Regulations. With no maintenance funding, reg. 12(2) - # doesn't apply either, so the claimant doesn't receive education. + # Housing Benefit (reg. 2(1)), doesn't count as a qualifying young person + # for the Universal Credit Regulations. With no maintenance funding, reg. + # 12(2) doesn't apply either, so the claimant doesn't receive education. period: 2026 input: people: @@ -817,8 +826,9 @@ is_uc_eligible: true - name: Case 32, the same 18-year-old with a maintenance grant receives education under reg. 12(2)(b). - # Reg. 28 sends the claimant to reg. 12(2), and the grant for their - # maintenance meets reg. 12(2)(b). No exception applies. + # DWP no longer treats the claimant as a qualifying young person under reg. + # 28, so the claimant takes the ordinary route under reg. 12(2), and the + # grant for their maintenance meets reg. 12(2)(b). No exception applies. period: 2026 input: people: @@ -909,9 +919,9 @@ # condition in section 4(1)(b) of the Act (claimant has not reached the # qualifying age for state pension credit)". The claimant, an 18-year-old # qualifying young person on a full-time non-advanced course, receives - # education under reg. 12(1A). Reg. 60(1) covers only a person on a course - # under reg. 12(2), and the claimant has no reg. 14(1) exception: the - # other adult isn't a child or qualifying young person (reg. 14(1)(c)). + # education under reg. 12(1A). DWP applies reg. 60(1) only to a person on a + # course under reg. 12(2), and the claimant has no reg. 14(1) exception: + # the other adult isn't a child or qualifying young person (reg. 14(1)(c)). period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index bffe1b373d..df051277b9 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -5,21 +5,21 @@ Universal Credit Regulations 2013, the Secretary of State sets out who receives education (reg. 12) and who doesn't have to meet the condition (reg. 14(1)). Under reg. 3(2), a couple can claim jointly where one member doesn't -meet the age condition or this one, and DWP's Advice for Decision Making -E2016 applies it "where one member of a couple meets all the basic UC +meet the age condition or this one, and DWP applies it under its Advice for +Decision Making E2016 "where one member of a couple meets all the basic UC conditions". Under the Universal Credit (Transitional Provisions) Regulations 2014 reg. 28, a person entitled to an existing benefit doesn't count as a qualifying young person. In the model, only a person flagged is_in_full_time_education can receive education, and only has_education_maintenance_funding meets the funding test in reg. 12(2)(b). -Each drawn case is a year and up to four families, each in a household of its -own: a single adult or a couple, up to two children or young people, and -inputs that reach every limb the model applies (course level, maintenance -funding, student loans and grants, Attendance Allowance, Armed Forces -Independence Payment, DLA and PIP with or without limited capability for work, -foster flags, an ineligible partner, reported Housing Benefit and Working Tax -Credit, a legacy closure claim, earnings, rent and savings). One simulation +For each case, Hypothesis draws a year and up to four families, each in a +household of its own: a single adult or a couple, up to two children or young +people, and inputs that reach every limb the model applies (course level, +maintenance funding, student loans and grants, Attendance Allowance, Armed +Forces Independence Payment, DLA and PIP with or without limited capability for +work, foster flags, an ineligible partner, reported Housing Benefit and Working +Tax Credit, a legacy closure claim, earnings, rent and savings). One simulation calculates the same people five ways: as drawn; with the eligibility formula on main before this change (BEFORE, copied below with its comments removed; the formatter re-wrapped two lines); with no one flagged; with the flags of @@ -27,24 +27,24 @@ loan and education grant removed. Invariants: -1. Never raises: each family's Universal Credit is either what it is under - BEFORE or nil, so setting the input never raises it. +1. Never raises: each family gets either its Universal Credit under BEFORE or + nil, so setting the input never raises it. 2. Unflagged families don't change: with no one flagged, every output listed in OUTPUTS equals its value under BEFORE. 3. Differential: receiving education, the exceptions, the qualifying young person status the condition reads, and is_uc_eligible equal a reference implementation that reads the drawn inputs. The reference applies regs. - 3(2), 12 and 14 as ADM E2016 reads them, SI 2014/1230 regs. 28 and 60, and - BEFORE's routes; without the education condition it gives BEFORE's + 3(2), 12 and 14 as DWP reads them in ADM E2016, SI 2014/1230 regs. 28 and + 60, and BEFORE's routes; without the education condition it gives BEFORE's eligibility. 4. Exceptions: a claimant who meets a modelled reg. 14 exception, or the SI 2014/1230 reg. 60 protection, meets the condition, and a family whose claimants all meet it gets the Universal Credit it gets under BEFORE. 5. Only the couple counts: reversing the flags of members who are neither claimant nor partner (children and qualifying young people) changes no - family's Universal Credit. A partner who cannot be a joint claimant (reg. - 3(3)) is still a member of the couple, so whether they receive education - can decide reg. 14(1)(e) for the claimant. + family's Universal Credit. A partner who can't join the claim (reg. 3(3)) + still belongs to the couple, so whether they receive education can decide + reg. 14(1)(e) for the claimant. 6. One member meets both: every eligible family that makes no closure claim has a claimant who meets the minimum age, hasn't reached the qualifying age for State Pension Credit and meets the education condition. @@ -712,16 +712,17 @@ def test_examples_reach_the_cases(): def test_student_head_moves_non_dependant_to_joint_tenant(): """A family with no one flagged loses Universal Credit when the household - head's family is a full-time student's. + head studies full time. The head's family cannot claim, so the model counts the non-dependant in - the joint tenant's claim instead. Paragraph 9(2)(f) of Schedule 4 to the - UC Regs 2013 prevents duplicate non-dependant treatment, and the model - assigns non-dependants to the household head's eligible claim first. The - joint tenant's claim then has the housing cost contribution of £96.55 a - month in 2026 (Sch 4 para 14(1), as SI 2026/148 art. 32(3) sets it): 12 x - £96.55 = £1,158.60. Two copies of the household, the head unflagged and - then flagged. + the joint tenant's claim instead. Under paragraph 9(2)(f) of Schedule 4 to + the UC Regs 2013, DWP doesn't count a person as a non-dependant in a claim + if it already counts them in another claim by someone liable for the rent. + The model assigns non-dependants to the household head's eligible claim + first. The joint tenant's claim then has the housing cost contribution of + £96.55 a month in 2026 (Sch 4 para 14(1), as SI 2026/148 art. 32(3) sets + it): 12 x £96.55 = £1,158.60. Two copies of the household, the head + unflagged and then flagged. """ year = 2026 people, benunits, households = {}, {}, {} From 94356e749b0e13f6d1dabd257c2de3eeba9546ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:38:12 -0500 Subject: [PATCH 19/27] Use a pronoun for the claimant in case 30's comment Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/universal_credit/uc_receiving_education.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 77584cce39..548f054967 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -773,9 +773,9 @@ - name: Case 30, a claimant on a non-advanced full-time course with an equipment grant and no maintenance funding keeps the award. # Under reg. 12(2)(b), the person must have a loan or grant for their # maintenance. The claimant can use Learner Support for materials and - # equipment (gov.uk/learner-support), and the claimant's student loan - # doesn't fund maintenance here, so the claimant doesn't receive education. - # 12 x £424.90 = £5,098.80. + # equipment (gov.uk/learner-support), and their student loan doesn't fund + # maintenance here, so they don't receive education. 12 x £424.90 = + # £5,098.80. period: 2026 absolute_error_margin: 0.01 input: From a5d1662666253304de5d3245903f097791b91950 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:57:38 -0500 Subject: [PATCH 20/27] Tighten four case comments in the education YAML Case 6 names the reg. 14(1)(c) exception, case 26 puts reg. 60(1) first, case 28 says reg. 66 doesn't list Armed Forces Independence Payment, case 30 states when reg. 12(2)(b) treats a claimant as receiving education, and case 32 drops "no longer". Comments only. Co-Authored-By: Claude Opus 5.5 --- .../uc_receiving_education.yaml | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 548f054967..d97b8b7145 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -159,8 +159,8 @@ - name: Case 6, a student couple where the other member is responsible for a child keeps the award. # Reg. 14(1)(e)(i): both members receive education and the other member # is responsible for a child. The model places responsibility on the - # benefit unit, so each member need not meet the condition under reg. - # 14(1)(c). 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. + # benefit unit, so each member also has the reg. 14(1)(c) exception. + # 12 x (£528.34 + £303.94) = 12 x £832.28 = £9,987.36. period: 2026 absolute_error_margin: 0.01 input: @@ -663,8 +663,8 @@ - name: Case 26, a pension-age student couple on Working Tax Credit claim under reg. 60A when DWP moves them. # Under SI 2014/1230 reg. 60A, the Secretary of State waives the upper age - # condition. Neither member needs to meet the education condition under - # reg. 60(1). 12 x £666.97 = £8,003.64. + # condition. Under reg. 60(1), neither member needs to meet the education + # condition. 12 x £666.97 = £8,003.64. period: 2026 absolute_error_margin: 0.01 input: @@ -721,8 +721,8 @@ - name: Case 28, a student with Armed Forces Independence Payment and limited capability for work keeps the award. # Under reg. 2, DWP counts Armed Forces Independence Payment as attendance - # allowance for reg. 14(1)(b), and excludes it from unearned income under - # reg. 66. 12 x £424.90 = £5,098.80. + # allowance for reg. 14(1)(b), and doesn't count it as unearned income, + # since reg. 66 doesn't list it. 12 x £424.90 = £5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -771,7 +771,8 @@ universal_credit: 5_098.80 - name: Case 30, a claimant on a non-advanced full-time course with an equipment grant and no maintenance funding keeps the award. - # Under reg. 12(2)(b), the person must have a loan or grant for their + # Under reg. 12(2)(b), DWP treats a claimant on a non-advanced full-time + # course as receiving education only where a loan or grant pays for their # maintenance. The claimant can use Learner Support for materials and # equipment (gov.uk/learner-support), and their student loan doesn't fund # maintenance here, so they don't receive education. 12 x £424.90 = @@ -826,9 +827,9 @@ is_uc_eligible: true - name: Case 32, the same 18-year-old with a maintenance grant receives education under reg. 12(2)(b). - # DWP no longer treats the claimant as a qualifying young person under reg. - # 28, so the claimant takes the ordinary route under reg. 12(2), and the - # grant for their maintenance meets reg. 12(2)(b). No exception applies. + # Under reg. 28, DWP doesn't treat the claimant as a qualifying young + # person, so they take the ordinary route under reg. 12(2), and the grant + # for their maintenance meets reg. 12(2)(b). No exception applies. period: 2026 input: people: From 55eae0b5c5f265ee57f6606801fa5123981ec35c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:40:44 +0000 Subject: [PATCH 21/27] Cite the Northern Ireland transitional provisions beside regs 28, 48 and 60 Add SR 2016/226 regs 29, 49 and 61 to the references of the education condition variables, with GB reg 48, and name the Northern Ireland regulation beside each GB one in their documentation. No formula changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHfDXcSTTbY2JgCMXBk --- .../is_qualifying_young_person_for_uc_education.py | 6 +++++- .../uc_is_excepted_from_education_condition.py | 13 +++++++++++-- .../dwp/universal_credit/uc_receiving_education.py | 8 +++++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 4f124e8860..30e64fbb80 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -33,7 +33,10 @@ class is_qualifying_young_person_for_uc_education(Variable): "definition under reg. 28 takes the ordinary route under the Universal " "Credit Regulations 2013 reg. 12(2). The model's other rules read " "is_qualifying_young_person_for_universal_credit, which leaves reg. 28 " - "out." + "out. In Northern Ireland the decision maker applies the same rule " + "under reg. 29 of the Universal Credit (Transitional Provisions) " + "Regulations (Northern Ireland) 2016, with reg. 6 of the Universal " + "Credit Regulations (Northern Ireland) 2016 in place of reg. 5." ) definition_period = YEAR reference = ( @@ -41,6 +44,7 @@ class is_qualifying_young_person_for_uc_education(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 97466013a6..94c86607ba 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -20,7 +20,8 @@ class uc_is_excepted_from_education_condition(Variable): "parent; (f) the member of a mixed-age couple who has reached the " "qualifying age for State Pension Credit; and reg. 60(1), a member of " "a family that claims Universal Credit when DWP moves it off its " - "legacy benefits (claims_uc_at_legacy_closure) and who doesn't count " + "legacy benefits (claims_uc_at_legacy_closure), which the model takes " + "as the qualifying claim under reg. 48, and who doesn't count " "as a qualifying young person for the condition " "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " "of the 2014 Regulations). The model needs no rule of its own for limb " @@ -49,15 +50,23 @@ class uc_is_excepted_from_education_condition(Variable): "placed, and for a friend or family carer, who is responsible for the " "child and so meets (c) under the law. In Northern Ireland the " "decision maker applies the same exceptions under reg. 14(1) of the " - "Universal Credit Regulations (Northern Ireland) 2016." + "Universal Credit Regulations (Northern Ireland) 2016, and the same " + "protection under reg. 61 of the Universal Credit (Transitional " + "Provisions) Regulations (Northern Ireland) 2016, with regs. 29 and 49 " + "of those Regulations in place of regs. 28 and 48; the model doesn't " + "apply reg. 61(2) either." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/14", "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/48", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/49", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/61", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py index ad61c56fea..c6c546a1e4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_receiving_education.py @@ -28,9 +28,10 @@ class uc_receiving_education(Variable): "abandon it or the establishment dismisses them; the model applies the " "input to the whole year. In Northern Ireland the decision maker " "applies the same test under reg. 12 of the Universal Credit " - "Regulations (Northern Ireland) 2016, and treats a qualifying young " - "person in a training scheme as receiving education too (NI reg. " - "12(1))." + "Regulations (Northern Ireland) 2016, with reg. 29 of the Universal " + "Credit (Transitional Provisions) Regulations (Northern Ireland) 2016 " + "in place of reg. 28, and treats a qualifying young person in a " + "training scheme as receiving education too (NI reg. 12(1))." ) definition_period = YEAR reference = ( @@ -40,6 +41,7 @@ class uc_receiving_education(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/68", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/nisr/2016/216/regulation/12", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", ) def formula(person, period, parameters): From 05bef8008f62bcb9e2878ec0301a642e2fae666a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:43:46 +0000 Subject: [PATCH 22/27] Test the reg 60 student protection across the tax credit closure Cases 36 to 41 follow one full-time student through the closure of tax credits: the claim at closure in 2025, the same course in 2026, the end of the course in 2027, a claim outside the migration, a claim before the closure in 2024, and the Northern Ireland protection under SR 2016/226 reg 61. Each case works its result from SI 2014/1230 regs 44 to 60 and DWP's rates, and none sets claims_uc_at_legacy_closure, so the closure runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHfDXcSTTbY2JgCMXBk --- .../uc_receiving_education.yaml | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index d97b8b7145..cc03a70881 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -13,6 +13,15 @@ # for any other child. Each award comes to 12 times the monthly amount, less # 55% of earnings (reg. 22(1)(b)); no case has housing costs except cases 1 # and 2. +# +# Cases 36 to 41 follow one student through the protection in the Universal +# Credit (Transitional Provisions) Regulations 2014 reg. 60 from 2024 to 2027. +# For 2025 to 2026 we take DWP's monthly standard allowance for a single +# claimant aged 25 or over, £400.14, from Benefit and pension rates 2025 to +# 2026 (gov.uk). The Department for Communities set the same amount in +# Northern Ireland (Social Security Benefits Up-rating Order (Northern +# Ireland) 2025, SR 2025/64, art. 32(2) and Schedule 13). DWP hasn't published +# rates for 2027 to 2028, so case 38 asserts no amount. - name: Case 1, issue 2346, a full-time university student aged 19 gets no Universal Credit. # Reg. 12(2)(a): a full-time course of advanced education (a first degree, @@ -952,3 +961,225 @@ meets_uc_education_condition: [false, true] is_uc_eligible: false universal_credit: 0 + +- name: Case 36, a full-time student whose family claims when its tax credits close in 2025 keeps the award. + # We give one claimant to cases 36 to 41: aged 30 and single, with no child, + # no listed disability benefit and no earnings, so no reg. 14(1) exception + # applies. They undertake a full-time degree, so they receive education under + # reg. 12(2)(a). We set Working Tax Credit of £1,000 to stand in for the + # award that closed, and the model closes tax credits from 6 April 2025. The + # family claims only because its tax credits close (would_claim_uc false, + # would_claim_uc_at_legacy_closure true). Under SI 2014/1230, DWP issues a + # migration notice to a person entitled to an existing benefit (reg. 44(1)), + # who becomes a notified person (reg. 44(6)). By claiming on or before the + # final deadline, the last day of the first assessment period of an award + # starting on the deadline day (reg. 46(4)), they make a qualifying claim + # (reg. 48). On the migration day, the day before their entitlement starts + # (reg. 49), they undertake the course, so DWP doesn't apply the condition in + # s. 4(1)(d) to them while they continue it (reg. 60(1)). 12 x £400.14 = + # £4,801.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 4_801.68 + +- name: Case 37, the student of case 36 continues the same course in 2026 and keeps the award. + # The claimant continues the course they undertook on the migration day, so + # DWP still doesn't apply the condition (SI 2014/1230 reg. 60(1)). Under reg. + # 60(2), DWP stops the protection only in an assessment period in which a + # transitional element would have stopped under reg. 56 or 57, and none of + # those events has happened. The claimant has no earned income, so in the + # first assessment period it didn't reach the single administrative + # threshold, and reg. 56(2) can't apply (reg. 56(2)(a)(ii)); reg. 56(3A) + # covers only reg. 60A awards. They haven't joined a couple (reg. 56(4)(b)), + # and their award continues, so reg. 57 doesn't apply. 12 x £424.90 = + # £5,098.80, as in case 19. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 5_098.80 + +- name: Case 38, the student of case 36 finishes the course and meets the condition in 2027. + # The claimant no longer undertakes the course they undertook on the + # migration day, so the protection, which lasts "while they are continuing + # to undertake that course" (SI 2014/1230 reg. 60(1)), has stopped. Off the + # course, they don't receive education under reg. 12(2), so they meet s. + # 4(1)(d) without an exception. DWP hasn't published rates for 2027 to 2028, + # so we assert no amount. The model keeps + # uc_is_excepted_from_education_condition true here although reg. 60(1) has + # stopped, so we don't assert it. + period: 2027 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: false + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + uc_receiving_education: [false] + meets_uc_education_condition: [true] + is_uc_eligible: true + +- name: Case 39, the student of case 36 claims outside the migration and gets no Universal Credit. + # The family claims Universal Credit (would_claim_uc true), but not when its + # tax credits close (would_claim_uc_at_legacy_closure false). A claim after + # the final deadline (SI 2014/1230 reg. 46(4)) doesn't count as a qualifying + # claim (reg. 48), so DWP doesn't apply reg. 60(1). The claimant receives + # education under reg. 12(2)(a) and has no reg. 14(1) exception, so they + # don't meet s. 4(1)(d), and DWP pays nothing. + period: 2025 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: true + would_claim_uc_at_legacy_closure: false + households: + household: + members: [person1] + region: NORTH_EAST + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: false + claims_universal_credit: true + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [false] + meets_uc_education_condition: [false] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 40, the student of case 36 claims before the closure and gets no Universal Credit in 2024. + # The model keeps tax credits open in 2024, so legacy_benefits_closed reads + # false, and the family claims Universal Credit (would_claim_uc true) with + # no closure. A person who claims before any migration notice reaches them + # never becomes a notified person (SI 2014/1230 reg. 44(6)), so their claim + # doesn't count as a qualifying claim (reg. 48), and DWP doesn't apply reg. + # 60(1). Before 27 January 2025, DWP applied reg. 60(1) only to a notified + # person (SI 2025/3 reg. 6(9)(a) and (c) replaced those words), so the + # claimant gets no protection under either text. They receive education + # under reg. 12(2)(a) with no reg. 14(1) exception, and DWP pays nothing. A + # family that claimed by the final deadline after DWP issued it a migration + # notice in 2024-25 did make a qualifying claim, and a full-time student on + # its migration day kept the protection from then. The model, which takes + # the closure from the active parameters here, protects such a student only + # from 2025-26. + period: 2024 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: true + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + legacy_benefits_closed: false + claims_uc_at_legacy_closure: false + claims_universal_credit: true + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [false] + meets_uc_education_condition: [false] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 41, the student of case 36 in Northern Ireland keeps the award under SR 2016/226 reg. 61. + # Under the Universal Credit (Transitional Provisions) Regulations (Northern + # Ireland) 2016, the Department for Communities issues the migration notice + # that makes the claimant a notified person (reg. 45(6)). By claiming on or + # before the final deadline (reg. 47(4)), they make a qualifying claim (reg. + # 49). On the migration day (reg. 50) they undertake the course, so the + # Department doesn't apply the condition in the Welfare Reform (Northern + # Ireland) Order 2015 art. 9(1)(d) to them while they continue it (reg. + # 61(1)). 12 x £400.14 = £4,801.68, the Northern Ireland amount in SR 2025/64 + # Schedule 13. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + current_education: TERTIARY + is_in_full_time_education: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTHERN_IRELAND + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [true] + is_uc_eligible: true + universal_credit: 4_801.68 From c8767e3c525990df33f17541d8c4c3ef1769a94b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:56:02 +0000 Subject: [PATCH 23/27] Check that the reg 60 exception follows the legacy closure dates For single people aged 25 to 59 with no other exception, in model years 2020 to 2028, uc_is_excepted_from_education_condition holds exactly when they report a legacy benefit whose active parameter reads false and their family claims at closure. The test reads the closure dates from the parameters and batches each example into one simulation, with explicit grids for 2024, 2025 and 2026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHfDXcSTTbY2JgCMXBk --- .../test_uc_receiving_education_properties.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index df051277b9..01057bec3d 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -62,6 +62,13 @@ liable for the rent, and the model assigns non-dependants to the household head's eligible claim first. test_student_head_moves_non_dependant_to_joint_tenant pins it; the invariants above hold for families with households of their own. + +test_reg_60_protection_follows_the_closure_dates checks one more invariant, +in model years 2020 to 2028, on single people aged 25 to 59 with no child, no +listed disability benefit and no foster role: uc_is_excepted_from_education_condition +holds exactly when, that year, they report a legacy benefit whose active +parameter reads false and their family claims at closure. It reads the +closure dates from the parameters. """ import json @@ -756,3 +763,145 @@ def test_student_head_moves_non_dependant_to_joint_tenant(): ) universal_credit = simulation.calculate("universal_credit", year) assert np.isclose(universal_credit[1] - universal_credit[4], 12 * 96.55, atol=0.01) + + +# SI 2014/1230 reg. 60(1) in the model: the legacy benefits whose closure the +# model dates with an `active` parameter under gov.dwp, keyed by the report +# that stands for each. +CLOSING_REPORTS = { + "child_tax_credit_reported": "tax_credits.active", + "working_tax_credit_reported": "tax_credits.active", + "income_support_reported": "income_support.active", + "jsa_income_reported": "JSA.income.active", +} +CLOSURE_YEARS = list(range(2020, 2029)) +CLOSURE_SETTINGS = settings( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def single_people_at_closure(draw): + """A model year from 2020 to 2028 and single people aged 25 to 59 with no + child, no listed disability benefit and no foster role, each in a + household of their own. Each draws the four reports, both claim flags, + a region and whether they take a full-time degree.""" + year = draw(st.sampled_from(CLOSURE_YEARS)) + people = draw( + st.lists( + st.fixed_dictionaries( + dict( + age=st.integers(25, 59), + is_in_full_time_education=st.booleans(), + region=st.sampled_from(REGIONS), + would_claim_uc=st.booleans(), + would_claim_uc_at_legacy_closure=st.booleans(), + **{ + report: st.sampled_from([0, 0, 1, 3_000]) + for report in CLOSING_REPORTS + }, + ) + ), + min_size=1, + max_size=24, + ) + ) + return dict(year=year, people=people) + + +def closure_grid(year): + """Each report alone, and none, with each closure claim flag, on a + full-time degree or not: 20 people, every fourth in Northern Ireland.""" + people = [] + for report in [None, *CLOSING_REPORTS]: + for at_closure in (False, True): + for full_time in (False, True): + people.append( + dict( + age=25 + len(people), + is_in_full_time_education=full_time, + region=REGIONS[len(people) % len(REGIONS)], + would_claim_uc=not at_closure, + would_claim_uc_at_legacy_closure=at_closure, + **{ + name: 1_000 if name == report else 0 + for name in CLOSING_REPORTS + }, + ) + ) + return dict(year=year, people=people) + + +def closure_situation(case): + year = case["year"] + people, benunits, households = {}, {}, {} + for i, drawn in enumerate(case["people"]): + name = f"p{i}" + people[name] = { + "age": {year: drawn["age"]}, + "current_education": {year: "TERTIARY"}, + "is_in_full_time_education": {year: drawn["is_in_full_time_education"]}, + **{report: {year: drawn[report]} for report in CLOSING_REPORTS}, + } + benunits[f"b{i}"] = { + "members": [name], + "would_claim_uc": {year: drawn["would_claim_uc"]}, + "would_claim_uc_at_legacy_closure": { + year: drawn["would_claim_uc_at_legacy_closure"] + }, + } + households[f"h{i}"] = {"members": [name], "region": {year: drawn["region"]}} + return {"people": people, "benunits": benunits, "households": households} + + +@CLOSURE_SETTINGS +# 2024: every legacy benefit open; 2025: tax credits closed; 2026: all closed. +@example(closure_grid(2024)) +@example(closure_grid(2025)) +@example(closure_grid(2026)) +@given(single_people_at_closure()) +def test_reg_60_protection_follows_the_closure_dates(case): + """For single people aged 25 to 59 with no child, no listed disability + benefit and no foster role, uc_is_excepted_from_education_condition holds + exactly when, in that model year, they report a legacy benefit whose + active parameter reads false and their family claims at closure + (would_claim_uc_at_legacy_closure). No reg. 14(1) limb reaches them, so + the reg. 60(1) protection decides the exception, and the model takes the + closure from the active parameters. The model then finds a full-time + degree student among them eligible for Universal Credit exactly when the + exception holds, and anyone else eligible either way.""" + year = case["year"] + simulation = Simulation(situation=closure_situation(case)) + # The formulas read parameters at the start of the model year, and the + # model moves each value to the fiscal year it starts in. + dwp = simulation.tax_benefit_system.parameters(f"{year}-01-01").gov.dwp + + def closed(path): + node = dwp + for key in path.split("."): + node = getattr(node, key) + return not bool(node) + + expected = np.array( + [ + drawn["would_claim_uc_at_legacy_closure"] + and any( + drawn[report] > 0 and closed(path) + for report, path in CLOSING_REPORTS.items() + ) + for drawn in case["people"] + ] + ) + context = json.dumps(case) + np.testing.assert_array_equal( + simulation.calculate("uc_is_excepted_from_education_condition", year), + expected, + context, + ) + full_time = np.array([p["is_in_full_time_education"] for p in case["people"]]) + np.testing.assert_array_equal( + simulation.calculate("is_uc_eligible", year), ~full_time | expected, context + ) From c960b384fd368193b0f6c7f8e03f8ac4f3875d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:46:53 +0000 Subject: [PATCH 24/27] Tighten the reg 60 case comments and property after review Ground each case comment in the regulation text: the notice DWP issued, the claim after the final deadline in case 39, the text in force before 27 January 2025 in case 40, the minimum income floor in case 37, and the end of the course in case 38. Name the Universal Credit Regulations 2013 reg 5 and the 2014 Regulations' reg 48 in the documentation. Put every grid combination in North East England and in Northern Ireland, scope the property's invariants to closures the model derives, describe the 30 April parameter snapshot, and check that the grid years straddle each closure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHfDXcSTTbY2JgCMXBk --- .../uc_receiving_education.yaml | 130 ++++++++++-------- .../test_uc_receiving_education_properties.py | 106 +++++++++----- ...ualifying_young_person_for_uc_education.py | 3 +- ...uc_is_excepted_from_education_condition.py | 3 +- 4 files changed, 147 insertions(+), 95 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index cc03a70881..c4e00b80fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -14,14 +14,15 @@ # 55% of earnings (reg. 22(1)(b)); no case has housing costs except cases 1 # and 2. # -# Cases 36 to 41 follow one student through the protection in the Universal -# Credit (Transitional Provisions) Regulations 2014 reg. 60 from 2024 to 2027. -# For 2025 to 2026 we take DWP's monthly standard allowance for a single -# claimant aged 25 or over, £400.14, from Benefit and pension rates 2025 to -# 2026 (gov.uk). The Department for Communities set the same amount in -# Northern Ireland (Social Security Benefits Up-rating Order (Northern -# Ireland) 2025, SR 2025/64, art. 32(2) and Schedule 13). DWP hasn't published -# rates for 2027 to 2028, so case 38 asserts no amount. +# In cases 36 to 41 we test, on one student in years from 2024 to 2027, the +# protection in the Universal Credit (Transitional Provisions) Regulations +# 2014 reg. 60, and in case 41 its Northern Ireland counterpart, reg. 61 of +# SR 2016/226. For 2025 to 2026 we take DWP's monthly standard allowance for a +# single claimant aged 25 or over, £400.14, from Benefit and pension rates +# 2025 to 2026 (gov.uk). The Department for Communities set the same amount +# in Northern Ireland (Social Security Benefits Up-rating Order (Northern +# Ireland) 2025, SR 2025/64, art. 32(2) and Schedule 13). DWP had published no +# rates for 2027 to 2028 by November 2026, so we assert no amount in case 38. - name: Case 1, issue 2346, a full-time university student aged 19 gets no Universal Credit. # Reg. 12(2)(a): a full-time course of advanced education (a first degree, @@ -963,21 +964,22 @@ universal_credit: 0 - name: Case 36, a full-time student whose family claims when its tax credits close in 2025 keeps the award. - # We give one claimant to cases 36 to 41: aged 30 and single, with no child, - # no listed disability benefit and no earnings, so no reg. 14(1) exception - # applies. They undertake a full-time degree, so they receive education under - # reg. 12(2)(a). We set Working Tax Credit of £1,000 to stand in for the - # award that closed, and the model closes tax credits from 6 April 2025. The - # family claims only because its tax credits close (would_claim_uc false, - # would_claim_uc_at_legacy_closure true). Under SI 2014/1230, DWP issues a - # migration notice to a person entitled to an existing benefit (reg. 44(1)), - # who becomes a notified person (reg. 44(6)). By claiming on or before the - # final deadline, the last day of the first assessment period of an award - # starting on the deadline day (reg. 46(4)), they make a qualifying claim - # (reg. 48). On the migration day, the day before their entitlement starts - # (reg. 49), they undertake the course, so DWP doesn't apply the condition in - # s. 4(1)(d) to them while they continue it (reg. 60(1)). 12 x £400.14 = - # £4,801.68. + # We use one claimant in cases 36 to 41: aged 30 and single, with no child, + # no listed disability benefit and no earnings, so DWP applies no reg. 14(1) + # exception to them. They undertake a full-time degree, so they receive + # education under reg. 12(2)(a). We set Working Tax Credit of £1,000 to stand + # in for the award that closed, and the model closes tax credits from 6 April + # 2025. The family claims only because its tax credits close (would_claim_uc + # false, would_claim_uc_at_legacy_closure true). Under SI 2014/1230, DWP + # issued the claimant a migration notice while they had an award of Working + # Tax Credit, an existing benefit (reg. 44(1)), so they became a notified + # person (reg. 44(6)). By claiming on or before the final deadline, the last + # day of the first assessment period of an award starting on the deadline day + # (reg. 46(4)), they make a qualifying claim (reg. 48). On the migration day, + # the day before their entitlement starts (reg. 49), they undertake the + # course, so DWP doesn't apply the condition in s. 4(1)(d) to them while they + # continue it (reg. 60(1)). We assume 12 assessment periods at the 2025 to + # 2026 rate and no transitional element (reg. 55): 12 x £400.14 = £4,801.68. period: 2025 absolute_error_margin: 0.01 input: @@ -1009,12 +1011,15 @@ # DWP still doesn't apply the condition (SI 2014/1230 reg. 60(1)). Under reg. # 60(2), DWP stops the protection only in an assessment period in which a # transitional element would have stopped under reg. 56 or 57, and none of - # those events has happened. The claimant has no earned income, so in the - # first assessment period it didn't reach the single administrative - # threshold, and reg. 56(2) can't apply (reg. 56(2)(a)(ii)); reg. 56(3A) - # covers only reg. 60A awards. They haven't joined a couple (reg. 56(4)(b)), - # and their award continues, so reg. 57 doesn't apply. 12 x £424.90 = - # £5,098.80, as in case 19. + # those events has happened. The claimant has no earned income, and DWP + # applies no minimum income floor to them, so even under reg. 56(3)(b) their + # earned income didn't reach the single administrative threshold in the first + # assessment period, and DWP can't apply reg. 56(2) (reg. 56(2)(a)(ii)). DWP + # applies reg. 56(3A) only to an award mentioned in reg. 60A(1), and the + # claimant has no such award. They haven't joined a couple (reg. 56(4)(b)), + # and their award continues, so DWP doesn't apply reg. 57 either. With no + # transitional element, as in case 36, 12 x £424.90 = £5,098.80, as in case + # 19. period: 2026 absolute_error_margin: 0.01 input: @@ -1042,14 +1047,17 @@ universal_credit: 5_098.80 - name: Case 38, the student of case 36 finishes the course and meets the condition in 2027. - # The claimant no longer undertakes the course they undertook on the - # migration day, so the protection, which lasts "while they are continuing - # to undertake that course" (SI 2014/1230 reg. 60(1)), has stopped. Off the - # course, they don't receive education under reg. 12(2), so they meet s. - # 4(1)(d) without an exception. DWP hasn't published rates for 2027 to 2028, - # so we assert no amount. The model keeps - # uc_is_excepted_from_education_condition true here although reg. 60(1) has - # stopped, so we don't assert it. + # We keep current_education TERTIARY from case 36 and set + # is_in_full_time_education false for the end of the course: the model reads + # only that input to decide whether the claimant undertakes a full-time + # course. The claimant no longer undertakes the course they undertook on the + # migration day, and DWP lifts the condition only "while they are continuing + # to undertake that course" (SI 2014/1230 reg. 60(1)), so DWP now applies it + # to them. Off the course, they don't receive education under reg. 12(2), so + # they meet s. 4(1)(d) without an exception. DWP had published no rates for + # 2027 to 2028 by November 2026, so we assert no amount. The model keeps + # uc_is_excepted_from_education_condition true here although DWP no longer + # applies reg. 60(1) to the claimant, so we don't assert it. period: 2027 input: people: @@ -1072,13 +1080,14 @@ meets_uc_education_condition: [true] is_uc_eligible: true -- name: Case 39, the student of case 36 claims outside the migration and gets no Universal Credit. - # The family claims Universal Credit (would_claim_uc true), but not when its - # tax credits close (would_claim_uc_at_legacy_closure false). A claim after - # the final deadline (SI 2014/1230 reg. 46(4)) doesn't count as a qualifying - # claim (reg. 48), so DWP doesn't apply reg. 60(1). The claimant receives - # education under reg. 12(2)(a) and has no reg. 14(1) exception, so they - # don't meet s. 4(1)(d), and DWP pays nothing. +- name: Case 39, the student of case 36 claims after the final deadline and gets no Universal Credit. + # The family claims Universal Credit after the final deadline (SI 2014/1230 + # reg. 46(4)): it claims (would_claim_uc true), but not in answer to its + # migration notice (would_claim_uc_at_legacy_closure false). A claim after the + # final deadline doesn't count as a qualifying claim (reg. 48), so DWP doesn't + # apply reg. 60(1). The claimant receives education under reg. 12(2)(a) and + # has no reg. 14(1) exception, so they don't meet s. 4(1)(d), and DWP pays + # nothing. period: 2025 input: people: @@ -1109,18 +1118,20 @@ - name: Case 40, the student of case 36 claims before the closure and gets no Universal Credit in 2024. # The model keeps tax credits open in 2024, so legacy_benefits_closed reads # false, and the family claims Universal Credit (would_claim_uc true) with - # no closure. A person who claims before any migration notice reaches them - # never becomes a notified person (SI 2014/1230 reg. 44(6)), so their claim + # no closure. The claimant claims before DWP issues them a migration notice, + # so they don't claim as a notified person (SI 2014/1230 reg. 44(6)) and have + # no deadline day or final deadline (regs. 44(1)(b) and 46(4)). Their claim # doesn't count as a qualifying claim (reg. 48), and DWP doesn't apply reg. - # 60(1). Before 27 January 2025, DWP applied reg. 60(1) only to a notified - # person (SI 2025/3 reg. 6(9)(a) and (c) replaced those words), so the - # claimant gets no protection under either text. They receive education - # under reg. 12(2)(a) with no reg. 14(1) exception, and DWP pays nothing. A - # family that claimed by the final deadline after DWP issued it a migration - # notice in 2024-25 did make a qualifying claim, and a full-time student on - # its migration day kept the protection from then. The model, which takes - # the closure from the active parameters here, protects such a student only - # from 2025-26. + # 60(1). Since 27 January 2025 DWP has applied reg. 60(1) to a person who has + # made a qualifying claim (SI 2025/3 reg. 6(9)(a) and (c)); before then it + # applied reg. 60(1) only to a notified person, so the claimant gets no + # protection under either text. They receive education under reg. 12(2)(a) + # with no reg. 14(1) exception, and DWP pays nothing. A family that claimed + # by the final deadline after DWP issued it a migration notice in 2024-25 did + # make a qualifying claim (reg. 48), and under the text in force from 27 + # January 2025 DWP applied reg. 60(1) to a full-time student in it who was + # on the course on the migration day. The model, which takes the closure from + # the active parameters here, protects such a student only from 2025-26. period: 2024 input: people: @@ -1150,14 +1161,15 @@ - name: Case 41, the student of case 36 in Northern Ireland keeps the award under SR 2016/226 reg. 61. # Under the Universal Credit (Transitional Provisions) Regulations (Northern - # Ireland) 2016, the Department for Communities issues the migration notice - # that makes the claimant a notified person (reg. 45(6)). By claiming on or + # Ireland) 2016, the Department for Communities issued the claimant a + # migration notice while they had an award of Working Tax Credit (reg. + # 45(1)), so they became a notified person (reg. 45(6)). By claiming on or # before the final deadline (reg. 47(4)), they make a qualifying claim (reg. # 49). On the migration day (reg. 50) they undertake the course, so the # Department doesn't apply the condition in the Welfare Reform (Northern # Ireland) Order 2015 art. 9(1)(d) to them while they continue it (reg. - # 61(1)). 12 x £400.14 = £4,801.68, the Northern Ireland amount in SR 2025/64 - # Schedule 13. + # 61(1)). With the assumptions of case 36, 12 x £400.14 = £4,801.68, the + # Northern Ireland amount in SR 2025/64 Schedule 13. period: 2025 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 01057bec3d..9522360554 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -63,12 +63,17 @@ head's eligible claim first. test_student_head_moves_non_dependant_to_joint_tenant pins it; the invariants above hold for families with households of their own. -test_reg_60_protection_follows_the_closure_dates checks one more invariant, +test_reg_60_protection_follows_the_closure_dates checks two more invariants, in model years 2020 to 2028, on single people aged 25 to 59 with no child, no -listed disability benefit and no foster role: uc_is_excepted_from_education_condition -holds exactly when, that year, they report a legacy benefit whose active -parameter reads false and their family claims at closure. It reads the -closure dates from the parameters. +listed disability benefit, no foster role, no capital and no other benefit +report, when the model derives claims_uc_at_legacy_closure itself: +uc_is_excepted_from_education_condition holds exactly when, that year, they +report a legacy benefit whose active parameter reads false and their family +claims at closure; and is_uc_eligible holds for a full-time degree student +exactly when that exception holds, and for anyone else always. It reads the +closure dates from the parameters. An explicit claims_uc_at_legacy_closure, +which test_education_condition_invariants draws in 2019, 2021 and 2024, +excepts them in any year. """ import json @@ -786,9 +791,10 @@ def test_student_head_moves_non_dependant_to_joint_tenant(): @st.composite def single_people_at_closure(draw): """A model year from 2020 to 2028 and single people aged 25 to 59 with no - child, no listed disability benefit and no foster role, each in a - household of their own. Each draws the four reports, both claim flags, - a region and whether they take a full-time degree.""" + child, no listed disability benefit, no foster role, no capital and no + other benefit report, each in a household of their own. For each person, + Hypothesis draws the four reports, both claim flags, a region and whether + they take a full-time degree.""" year = draw(st.sampled_from(CLOSURE_YEARS)) people = draw( st.lists( @@ -814,24 +820,26 @@ def single_people_at_closure(draw): def closure_grid(year): """Each report alone, and none, with each closure claim flag, on a - full-time degree or not: 20 people, every fourth in Northern Ireland.""" + full-time degree or not, in North East England and in Northern Ireland: + 40 people.""" people = [] - for report in [None, *CLOSING_REPORTS]: - for at_closure in (False, True): - for full_time in (False, True): - people.append( - dict( - age=25 + len(people), - is_in_full_time_education=full_time, - region=REGIONS[len(people) % len(REGIONS)], - would_claim_uc=not at_closure, - would_claim_uc_at_legacy_closure=at_closure, - **{ - name: 1_000 if name == report else 0 - for name in CLOSING_REPORTS - }, + for region in ("NORTH_EAST", "NORTHERN_IRELAND"): + for report in [None, *CLOSING_REPORTS]: + for at_closure in (False, True): + for full_time in (False, True): + people.append( + dict( + age=25 + len(people) % 35, + is_in_full_time_education=full_time, + region=region, + would_claim_uc=not at_closure, + would_claim_uc_at_legacy_closure=at_closure, + **{ + name: 1_000 if name == report else 0 + for name in CLOSING_REPORTS + }, + ) ) - ) return dict(year=year, people=people) @@ -858,25 +866,35 @@ def closure_situation(case): @CLOSURE_SETTINGS -# 2024: every legacy benefit open; 2025: tax credits closed; 2026: all closed. +# 2024: tax credits, Income Support and income-based JSA open; 2025: tax +# credits closed; 2026: Income Support and income-based JSA closed too. +# test_closure_grids_straddle_each_closure checks these against the +# parameters. @example(closure_grid(2024)) @example(closure_grid(2025)) @example(closure_grid(2026)) @given(single_people_at_closure()) def test_reg_60_protection_follows_the_closure_dates(case): """For single people aged 25 to 59 with no child, no listed disability - benefit and no foster role, uc_is_excepted_from_education_condition holds - exactly when, in that model year, they report a legacy benefit whose - active parameter reads false and their family claims at closure - (would_claim_uc_at_legacy_closure). No reg. 14(1) limb reaches them, so - the reg. 60(1) protection decides the exception, and the model takes the - closure from the active parameters. The model then finds a full-time + benefit, no foster role, no capital and no other benefit report, where the + model derives claims_uc_at_legacy_closure itself, + uc_is_excepted_from_education_condition holds exactly when, in that model + year, they report a legacy benefit whose active parameter reads false and + their family claims at closure (would_claim_uc_at_legacy_closure). None of + them meets a reg. 14(1) limb, so the model excepts them only under its + reg. 60(1) rule (SR 2016/226 reg. 61(1) in Northern Ireland), and it takes + the closure from the active parameters. The model then finds a full-time degree student among them eligible for Universal Credit exactly when the - exception holds, and anyone else eligible either way.""" + exception holds, and anyone else eligible either way. For a person off a + course the model keeps the exception, although reg. 60(1) lifts the + condition only while they continue the course (case 38 of + uc_receiving_education.yaml); this test pins that proxy.""" year = case["year"] simulation = Simulation(situation=closure_situation(case)) - # The formulas read parameters at the start of the model year, and the - # model moves each value to the fiscal year it starts in. + # The formulas read parameters at 1 January of the model year, and the + # model gives that date the value in force on 30 April + # (convert_to_fiscal_year_parameters), so the 1 April 2026 Income Support + # and income-based JSA closures first count in model year 2026. dwp = simulation.tax_benefit_system.parameters(f"{year}-01-01").gov.dwp def closed(path): @@ -905,3 +923,23 @@ def closed(path): np.testing.assert_array_equal( simulation.calculate("is_uc_eligible", year), ~full_time | expected, context ) + + +def test_closure_grids_straddle_each_closure(): + """The explicit grids for 2024, 2025 and 2026 fall either side of each + closure the active parameters set: tax credits open in 2024 and closed in + 2025, and Income Support and income-based JSA open in 2025 and closed in + 2026.""" + system = Simulation(situation={"people": {"p": {}}}).tax_benefit_system + + def active(year, path): + node = system.parameters(f"{year}-01-01").gov.dwp + for key in path.split("."): + node = getattr(node, key) + return bool(node) + + assert active(2024, "tax_credits.active") + assert not active(2025, "tax_credits.active") + for path in ("income_support.active", "JSA.income.active"): + assert active(2025, path) + assert not active(2026, path) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 30e64fbb80..e817c26803 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -36,7 +36,8 @@ class is_qualifying_young_person_for_uc_education(Variable): "out. In Northern Ireland the decision maker applies the same rule " "under reg. 29 of the Universal Credit (Transitional Provisions) " "Regulations (Northern Ireland) 2016, with reg. 6 of the Universal " - "Credit Regulations (Northern Ireland) 2016 in place of reg. 5." + "Credit Regulations (Northern Ireland) 2016 in place of the Universal " + "Credit Regulations 2013 reg. 5." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 94c86607ba..209eddad27 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -21,7 +21,8 @@ class uc_is_excepted_from_education_condition(Variable): "qualifying age for State Pension Credit; and reg. 60(1), a member of " "a family that claims Universal Credit when DWP moves it off its " "legacy benefits (claims_uc_at_legacy_closure), which the model takes " - "as the qualifying claim under reg. 48, and who doesn't count " + "as the qualifying claim under reg. 48 of the 2014 Regulations, and who " + "doesn't count " "as a qualifying young person for the condition " "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " "of the 2014 Regulations). The model needs no rule of its own for limb " From f06a9c6362bc03b4247f275e9062d532296451f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:48:44 +0000 Subject: [PATCH 25/27] Name DWP and the model as actors in the closure property docstrings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHfDXcSTTbY2JgCMXBk --- .../test_uc_receiving_education_properties.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 9522360554..026f7722e4 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -71,9 +71,9 @@ report a legacy benefit whose active parameter reads false and their family claims at closure; and is_uc_eligible holds for a full-time degree student exactly when that exception holds, and for anyone else always. It reads the -closure dates from the parameters. An explicit claims_uc_at_legacy_closure, -which test_education_condition_invariants draws in 2019, 2021 and 2024, -excepts them in any year. +closure dates from the parameters. With an explicit +claims_uc_at_legacy_closure, which test_education_condition_invariants draws +in 2019, 2021 and 2024, the model excepts them in any year. """ import json @@ -886,8 +886,8 @@ def test_reg_60_protection_follows_the_closure_dates(case): the closure from the active parameters. The model then finds a full-time degree student among them eligible for Universal Credit exactly when the exception holds, and anyone else eligible either way. For a person off a - course the model keeps the exception, although reg. 60(1) lifts the - condition only while they continue the course (case 38 of + course the model keeps the exception, although DWP lifts the condition + under reg. 60(1) only while they continue the course (case 38 of uc_receiving_education.yaml); this test pins that proxy.""" year = case["year"] simulation = Simulation(situation=closure_situation(case)) @@ -927,9 +927,9 @@ def closed(path): def test_closure_grids_straddle_each_closure(): """The explicit grids for 2024, 2025 and 2026 fall either side of each - closure the active parameters set: tax credits open in 2024 and closed in - 2025, and Income Support and income-based JSA open in 2025 and closed in - 2026.""" + closure date in the active parameters: tax credits open in 2024 and closed + in 2025, and Income Support and income-based JSA open in 2025 and closed + in 2026.""" system = Simulation(situation={"people": {"p": {}}}).tax_benefit_system def active(year, path): From a139935ee7a4f0e00e8f8b6ad4e2b29a1d161b14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:35:40 +0000 Subject: [PATCH 26/27] Narrow invariant 9, add the young closure migrant case and fix citations after review Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJANF2Y9hDYWaUWXU --- .../uc_receiving_education.yaml | 111 ++++++++++++++---- .../test_uc_receiving_education_properties.py | 84 +++++++------ ...ualifying_young_person_for_uc_education.py | 1 + 3 files changed, 138 insertions(+), 58 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index c4e00b80fd..e811e73932 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -963,23 +963,32 @@ is_uc_eligible: false universal_credit: 0 -- name: Case 36, a full-time student whose family claims when its tax credits close in 2025 keeps the award. +- name: Case 36, a full-time student whose family claimed when DWP moved it off tax credits keeps the award in 2025. # We use one claimant in cases 36 to 41: aged 30 and single, with no child, # no listed disability benefit and no earnings, so DWP applies no reg. 14(1) - # exception to them. They undertake a full-time degree, so they receive - # education under reg. 12(2)(a). We set Working Tax Credit of £1,000 to stand - # in for the award that closed, and the model closes tax credits from 6 April - # 2025. The family claims only because its tax credits close (would_claim_uc - # false, would_claim_uc_at_legacy_closure true). Under SI 2014/1230, DWP - # issued the claimant a migration notice while they had an award of Working - # Tax Credit, an existing benefit (reg. 44(1)), so they became a notified + # exception to them. While they had Working Tax Credit they worked at least + # 30 hours a week (Working Tax Credit (Entitlement and Maximum Rate) + # Regulations 2002 reg. 4(1)); they stopped before their Universal Credit + # award began, so they have no earned income from its first assessment period + # on. They undertake a full-time degree, so they receive education under reg. + # 12(2)(a). We set Working Tax Credit of £1,000 to stand in for the award + # that closed, and the model closes tax credits from 6 April 2025. The family + # claims only because its tax credits close (would_claim_uc false, + # would_claim_uc_at_legacy_closure true). Under SI 2014/1230, DWP issued the + # claimant a migration notice (reg. 44(1)) while they had an award of Working + # Tax Credit, an existing benefit (reg. 2(1)), so they became a notified # person (reg. 44(6)). By claiming on or before the final deadline, the last # day of the first assessment period of an award starting on the deadline day # (reg. 46(4)), they make a qualifying claim (reg. 48). On the migration day, # the day before their entitlement starts (reg. 49), they undertake the # course, so DWP doesn't apply the condition in s. 4(1)(d) to them while they - # continue it (reg. 60(1)). We assume 12 assessment periods at the 2025 to - # 2026 rate and no transitional element (reg. 55): 12 x £400.14 = £4,801.68. + # continue it (reg. 60(1)). No one could claim a tax credit for 2025-26 (reg. + # 6A(9)), and an award on a claim made by the final deadline starts no later + # than the deadline day (reg. 46(3)), so with a deadline day on or before 6 + # April 2025 (reg. 44(3B)) the migration day fell on or before 5 April 2025; + # in 2025-26 DWP applies the text of reg. 60(1) in force from 27 January + # 2025. We assume 12 assessment periods at the 2025 to 2026 rate and no + # transitional element (reg. 52(1)): 12 x £400.14 = £4,801.68. period: 2025 absolute_error_margin: 0.01 input: @@ -1011,8 +1020,10 @@ # DWP still doesn't apply the condition (SI 2014/1230 reg. 60(1)). Under reg. # 60(2), DWP stops the protection only in an assessment period in which a # transitional element would have stopped under reg. 56 or 57, and none of - # those events has happened. The claimant has no earned income, and DWP - # applies no minimum income floor to them, so even under reg. 56(3)(b) their + # those events has happened. The claimant has no earned income and isn't in + # gainful self-employment, so the minimum income floor neither applies to + # them nor would apply but for a start-up period (Universal Credit + # Regulations 2013 reg. 62(1) and (5)); so even under reg. 56(3)(b) their # earned income didn't reach the single administrative threshold in the first # assessment period, and DWP can't apply reg. 56(2) (reg. 56(2)(a)(ii)). DWP # applies reg. 56(3A) only to an award mentioned in reg. 60A(1), and the @@ -1117,10 +1128,10 @@ - name: Case 40, the student of case 36 claims before the closure and gets no Universal Credit in 2024. # The model keeps tax credits open in 2024, so legacy_benefits_closed reads - # false, and the family claims Universal Credit (would_claim_uc true) with - # no closure. The claimant claims before DWP issues them a migration notice, - # so they don't claim as a notified person (SI 2014/1230 reg. 44(6)) and have - # no deadline day or final deadline (regs. 44(1)(b) and 46(4)). Their claim + # false, and the family claims Universal Credit (would_claim_uc true) with no + # closure. The claimant claims before DWP issues them a migration notice, so + # they don't claim as a notified person (SI 2014/1230 reg. 44(6)) and have no + # deadline day or final deadline (regs. 44(1)(b) and 46(4)). Their claim # doesn't count as a qualifying claim (reg. 48), and DWP doesn't apply reg. # 60(1). Since 27 January 2025 DWP has applied reg. 60(1) to a person who has # made a qualifying claim (SI 2025/3 reg. 6(9)(a) and (c)); before then it @@ -1128,10 +1139,14 @@ # protection under either text. They receive education under reg. 12(2)(a) # with no reg. 14(1) exception, and DWP pays nothing. A family that claimed # by the final deadline after DWP issued it a migration notice in 2024-25 did - # make a qualifying claim (reg. 48), and under the text in force from 27 - # January 2025 DWP applied reg. 60(1) to a full-time student in it who was - # on the course on the migration day. The model, which takes the closure from - # the active parameters here, protects such a student only from 2025-26. + # make a qualifying claim (reg. 48). Until 26 January 2025 DWP applied reg. + # 60(1) to a notified person in it who was on the course on the day their + # existing awards were to terminate as a consequence of the claim, which for + # a tax credit award ended by the claim is the migration day (regs. 8(2)(a) + # and 49); from 27 January 2025 it applied reg. 60(1) to a full-time student + # in it who was on the course on the migration day. The model, which takes + # the closure from the active parameters here, protects such a student only + # from 2025-26. period: 2024 input: people: @@ -1168,8 +1183,9 @@ # 49). On the migration day (reg. 50) they undertake the course, so the # Department doesn't apply the condition in the Welfare Reform (Northern # Ireland) Order 2015 art. 9(1)(d) to them while they continue it (reg. - # 61(1)). With the assumptions of case 36, 12 x £400.14 = £4,801.68, the - # Northern Ireland amount in SR 2025/64 Schedule 13. + # 61(1)). With the assumptions of case 36, and the monthly £400.14 that the + # Department for Communities set in SR 2025/64 Schedule 13, 12 x £400.14 = + # £4,801.68. period: 2025 absolute_error_margin: 0.01 input: @@ -1195,3 +1211,54 @@ uc_is_excepted_from_education_condition: [true] is_uc_eligible: true universal_credit: 4_801.68 + +- name: Case 42, the 18-year-old of case 34 on Working Tax Credit keeps the award when the model derives the closure. + # The single 18-year-old of case 34, with Working Tax Credit of £1,000 in + # place of Housing Benefit, so the model derives the closure from the tax + # credit report (would_claim_uc false, would_claim_uc_at_legacy_closure + # true). As in cases 37 and 38, we keep the report in 2026 to stand for the + # award that closed. DWP issued the claimant a migration notice while they + # had the Working Tax Credit award, and they claimed by the final deadline, + # so they made a qualifying claim (SI 2014/1230 reg. 48). Under reg. 8(2)(a), + # the claim ended the Working Tax Credit award "on the day before the first + # date on which the claimant is entitled to universal credit", which is the + # migration day (reg. 49). So on the migration day the claimant was entitled + # to an existing benefit, and under reg. 28 they didn't count as a qualifying + # young person. They had a student loan or grant for their maintenance on a + # full-time course, so they received education under the Universal Credit + # Regulations 2013 reg. 12(2)(b), and failed the condition in s. 4(1)(d) that + # day "because the person is undertaking a full-time course (see regulation + # 12(2) and 13 ...)". So DWP doesn't apply the condition to them while they + # continue the course (reg. 60(1)), even in a year in which no award of an + # existing benefit remains for reg. 28 to read. With no reg. 60(2) event, as + # in case 37, 12 assessment periods at the 2026 to 2027 rate for a single + # claimant under 25 and no transitional element (reg. 52(1)), the award comes + # to 12 x £338.58 = £4,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + has_education_maintenance_funding: true + working_tax_credit_reported: 1_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [person1] + region: NORTH_EAST + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [true] + uc_is_excepted_from_education_condition: [true] + meets_uc_education_condition: [true] + is_uc_eligible: true + universal_credit: 4_062.96 diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 026f7722e4..44c304d9d7 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -66,14 +66,15 @@ test_reg_60_protection_follows_the_closure_dates checks two more invariants, in model years 2020 to 2028, on single people aged 25 to 59 with no child, no listed disability benefit, no foster role, no capital and no other benefit -report, when the model derives claims_uc_at_legacy_closure itself: -uc_is_excepted_from_education_condition holds exactly when, that year, they -report a legacy benefit whose active parameter reads false and their family -claims at closure; and is_uc_eligible holds for a full-time degree student -exactly when that exception holds, and for anyone else always. It reads the -closure dates from the parameters. With an explicit -claims_uc_at_legacy_closure, which test_education_condition_invariants draws -in 2019, 2021 and 2024, the model excepts them in any year. +report, when the model derives claims_uc_at_legacy_closure and +legacy_benefits_closed itself: uc_is_excepted_from_education_condition holds +exactly when, that year, they report a legacy benefit whose active parameter +reads false and their family claims at closure; and is_uc_eligible holds for +a full-time degree student exactly when that exception holds, and for anyone +not on a full-time course always. It reads the closure dates from the +parameters. With claims_uc_at_legacy_closure set true, which +test_education_condition_invariants draws in 2019, 2021 and 2024, the model +excepts them in any year. """ import json @@ -818,13 +819,18 @@ def single_people_at_closure(draw): return dict(year=year, people=people) +# Child Tax Credit with Income Support: in 2025 the model has closed the +# first and keeps the second open. +TWO_REPORTS = ("child_tax_credit_reported", "income_support_reported") + + def closure_grid(year): - """Each report alone, and none, with each closure claim flag, on a - full-time degree or not, in North East England and in Northern Ireland: - 40 people.""" + """No report, each report alone, and Child Tax Credit with Income Support, + with each closure claim flag, on a full-time degree or not, in North East + England and in Northern Ireland: 48 people.""" people = [] for region in ("NORTH_EAST", "NORTHERN_IRELAND"): - for report in [None, *CLOSING_REPORTS]: + for reports in [(), *[(r,) for r in CLOSING_REPORTS], TWO_REPORTS]: for at_closure in (False, True): for full_time in (False, True): people.append( @@ -835,7 +841,7 @@ def closure_grid(year): would_claim_uc=not at_closure, would_claim_uc_at_legacy_closure=at_closure, **{ - name: 1_000 if name == report else 0 + name: 1_000 if name in reports else 0 for name in CLOSING_REPORTS }, ) @@ -865,29 +871,34 @@ def closure_situation(case): return {"people": people, "benunits": benunits, "households": households} -@CLOSURE_SETTINGS # 2024: tax credits, Income Support and income-based JSA open; 2025: tax # credits closed; 2026: Income Support and income-based JSA closed too. # test_closure_grids_straddle_each_closure checks these against the # parameters. -@example(closure_grid(2024)) -@example(closure_grid(2025)) -@example(closure_grid(2026)) +ALL_OPEN, TAX_CREDITS_CLOSED, ALL_CLOSED = 2024, 2025, 2026 + + +@CLOSURE_SETTINGS +@example(closure_grid(ALL_OPEN)) +@example(closure_grid(TAX_CREDITS_CLOSED)) +@example(closure_grid(ALL_CLOSED)) @given(single_people_at_closure()) def test_reg_60_protection_follows_the_closure_dates(case): """For single people aged 25 to 59 with no child, no listed disability benefit, no foster role, no capital and no other benefit report, where the - model derives claims_uc_at_legacy_closure itself, - uc_is_excepted_from_education_condition holds exactly when, in that model - year, they report a legacy benefit whose active parameter reads false and - their family claims at closure (would_claim_uc_at_legacy_closure). None of - them meets a reg. 14(1) limb, so the model excepts them only under its - reg. 60(1) rule (SR 2016/226 reg. 61(1) in Northern Ireland), and it takes - the closure from the active parameters. The model then finds a full-time - degree student among them eligible for Universal Credit exactly when the - exception holds, and anyone else eligible either way. For a person off a - course the model keeps the exception, although DWP lifts the condition - under reg. 60(1) only while they continue the course (case 38 of + model derives claims_uc_at_legacy_closure and legacy_benefits_closed + itself, uc_is_excepted_from_education_condition holds exactly when, in + that model year, they report a legacy benefit whose active parameter reads + false and their family claims at closure + (would_claim_uc_at_legacy_closure). None of them meets a reg. 14(1) limb, + so the model excepts them only under its reg. 60(1) rule (SR 2016/226 reg. + 61(1) in Northern Ireland), and it takes the closure from the active + parameters. The model then finds a full-time degree student among them + eligible for Universal Credit exactly when the exception holds, and anyone + among them not on a full-time course eligible either way. For a person off + a course the model keeps the exception, although DWP lifts the condition + under reg. 60(1), and the Department for Communities under reg. 61(1), + only while they continue the course (case 38 of uc_receiving_education.yaml); this test pins that proxy.""" year = case["year"] simulation = Simulation(situation=closure_situation(case)) @@ -926,10 +937,11 @@ def closed(path): def test_closure_grids_straddle_each_closure(): - """The explicit grids for 2024, 2025 and 2026 fall either side of each - closure date in the active parameters: tax credits open in 2024 and closed - in 2025, and Income Support and income-based JSA open in 2025 and closed - in 2026.""" + """The explicit grids' years (ALL_OPEN, TAX_CREDITS_CLOSED and ALL_CLOSED) + fall either side of each closure date in the active parameters: tax + credits open in ALL_OPEN and closed in TAX_CREDITS_CLOSED, and Income + Support and income-based JSA open in TAX_CREDITS_CLOSED and closed in + ALL_CLOSED.""" system = Simulation(situation={"people": {"p": {}}}).tax_benefit_system def active(year, path): @@ -938,8 +950,8 @@ def active(year, path): node = getattr(node, key) return bool(node) - assert active(2024, "tax_credits.active") - assert not active(2025, "tax_credits.active") + assert active(ALL_OPEN, "tax_credits.active") + assert not active(TAX_CREDITS_CLOSED, "tax_credits.active") for path in ("income_support.active", "JSA.income.active"): - assert active(2025, path) - assert not active(2026, path) + assert active(TAX_CREDITS_CLOSED, path) + assert not active(ALL_CLOSED, path) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index e817c26803..39307c22a0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -46,6 +46,7 @@ class is_qualifying_young_person_for_uc_education(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", ) def formula(person, period, parameters): From 5961424461ff5ca4b7f87582d37d55314c5411b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:42:52 +0000 Subject: [PATCH 27/27] Name the claimant as holder of the existing benefit in case 42 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJANF2Y9hDYWaUWXU --- .../uc_receiving_education.yaml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index e811e73932..bc1ed54e69 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1222,18 +1222,18 @@ # so they made a qualifying claim (SI 2014/1230 reg. 48). Under reg. 8(2)(a), # the claim ended the Working Tax Credit award "on the day before the first # date on which the claimant is entitled to universal credit", which is the - # migration day (reg. 49). So on the migration day the claimant was entitled - # to an existing benefit, and under reg. 28 they didn't count as a qualifying - # young person. They had a student loan or grant for their maintenance on a - # full-time course, so they received education under the Universal Credit - # Regulations 2013 reg. 12(2)(b), and failed the condition in s. 4(1)(d) that - # day "because the person is undertaking a full-time course (see regulation - # 12(2) and 13 ...)". So DWP doesn't apply the condition to them while they - # continue the course (reg. 60(1)), even in a year in which no award of an - # existing benefit remains for reg. 28 to read. With no reg. 60(2) event, as - # in case 37, 12 assessment periods at the 2026 to 2027 rate for a single - # claimant under 25 and no transitional element (reg. 52(1)), the award comes - # to 12 x £338.58 = £4,062.96. + # migration day (reg. 49). So on the migration day the claimant still had + # entitlement to an existing benefit, and under reg. 28 they didn't count as + # a qualifying young person. They had a student loan or grant for their + # maintenance on a full-time course, so they received education under the + # Universal Credit Regulations 2013 reg. 12(2)(b), and failed the condition + # in s. 4(1)(d) that day "because the person is undertaking a full-time + # course (see regulation 12(2) and 13 ...)". So DWP doesn't apply the + # condition to them while they continue the course (reg. 60(1)), even in a + # year in which no award of an existing benefit remains for reg. 28 to read. + # With no reg. 60(2) event, as in case 37, 12 assessment periods at the 2026 + # to 2027 rate for a single claimant under 25 and no transitional element + # (reg. 52(1)), the award comes to 12 x £338.58 = £4,062.96. period: 2026 absolute_error_margin: 0.01 input: