From 505ac7e28d238efc77bed16003ab1e6e449b82cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 19:57:23 -0400 Subject: [PATCH 001/168] Add the Child Benefit qualifying young person conditions for school leavers Child Benefit (General) Regulations 2006 regs 4, 5 and 7 were not modelled: is_qualifying_young_person_for_child_benefit was reg. 3 (education or training) alone, so a 16-year-old who had left school was not a qualifying young person for Child Benefit, nor a young person for Housing Benefit, the legacy benefits or Council Tax Reduction (SSCBA 1992 s.142). - Reg. 4: until the 31 August after the 16th birthday, read from is_before_first_september_following_16th_birthday. - Reg. 5: the 20-week extension period for a 16- or 17-year-old not in remunerative work, from the new input is_in_child_benefit_extension_period. - Reg. 7: until the terminal date after leaving, from the new input is_before_child_benefit_terminal_date. - Reg. 7(3): no Child Benefit for a young person who qualifies through reg. 7 alone and works 24 hours or more a week (is_child_benefit_payable_for_person); they stay a qualifying young person. Co-Authored-By: Claude Opus 5.5 --- .../cb-qyp-school-leaver-routes.fixed.md | 1 + .../extension_period_age_limit.yaml | 9 + .../eligibility/remunerative_work_hours.yaml | 9 + .../child_benefit_school_leaver_routes.yaml | 382 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 32 +- ...benefit_school_leaver_routes_properties.py | 327 +++++++++++++++ .../gov/hmrc/child_benefit_child_index.py | 2 +- .../hmrc/child_benefit_respective_amount.py | 2 +- .../is_before_child_benefit_terminal_date.py | 20 + .../is_child_benefit_payable_for_person.py | 55 +++ .../is_in_child_benefit_extension_period.py | 23 ++ ..._in_remunerative_work_for_child_benefit.py | 18 + ...alifying_young_person_for_child_benefit.py | 58 ++- ...sion_period_condition_for_child_benefit.py | 24 ++ 14 files changed, 940 insertions(+), 22 deletions(-) create mode 100644 changelog.d/cb-qyp-school-leaver-routes.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml create mode 100644 policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py create mode 100644 policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py create mode 100644 policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..ae0e08f434 --- /dev/null +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..54c952537e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Benefit qualifying young person during an extension period. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Benefit extension period age limit + reference: + - title: Child Benefit (General) Regulations 2006 reg. 5(1) + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/5 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..e6271ff838 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Benefit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Benefit remunerative work hours + reference: + - title: Child Benefit (General) Regulations 2006 reg. 1(3), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/1 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml new file mode 100644 index 0000000000..8a32748910 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -0,0 +1,382 @@ +# The Child Benefit qualifying young person conditions for a young person who +# has left education: Child Benefit (General) Regulations 2006 reg. 4 (until +# the 31 August after the 16th birthday), reg. 5 (the 20-week extension +# period for a 16- or 17-year-old), reg. 7 (until the terminal date) and +# reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative +# work). Reg. 2(2): a qualifying young person until the last applicable +# condition ceases to be met. Reg. 8: not while receiving a benefit in their +# own right. +# +# Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for +# each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the +# other legacy schemes take their young person from the same definition +# (SSCBA 1992 s.142; HB Regs 2006 reg. 19). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + # 27.05 * 52. + child_benefit_entitlement: 1_406.60 + # The legacy schemes' young person follows Child Benefit. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 2, a 16-year-old apprentice is a qualifying young person under reg. 4, which has no education condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # Reg. 7(3) bars only Child Benefit payable through reg. 7. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 3, born 15 September 2009, the 31 August after the 16th birthday is in 2026-27 (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 4, born 31 August 2009, reg. 4 ended on 1 September 2025, before the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + is_lone_parent: false + +- name: Case 5, a 17-year-old and an 18-year-old who have left education are not qualifying young persons by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + seventeen: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + eighteen: + age: 18 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, seventeen, eighteen] + output: + is_qualifying_young_person_for_child_benefit: [false, false, false] + child_benefit_entitlement: 0 + +- name: Case 6, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 8). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 7, a 17-year-old in an extension period is a qualifying young person (reg. 5). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 8, the extension period needs the young person not to be in remunerative work (reg. 5(2)(c)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 9, the extension period is for under-18s only (reg. 5(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 10, an 18-year-old before the terminal date is a qualifying young person (reg. 7). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 11, in remunerative work a young person before the terminal date stays a qualifying young person but brings no Child Benefit (reg. 7(3)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, false] + child_benefit_entitlement: 0 + # Still a young person for Housing Benefit and the other legacy schemes. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + # Reg. 4 also applies at 16, and has no remunerative work bar. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 13, a working school leaver before the terminal date does not take the eldest-child rate from a younger child. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 30 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver, child] + output: + child_benefit_child_index: [-1, -1, 1] + # One child at the eldest rate: 27.05 * 52. + child_benefit_entitlement: 1_406.60 + +- name: Case 14, the terminal date condition ends at 20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 20 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 15, a 16-year-old at school and a 16-year-old who has left are both qualifying young persons, so two rates. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + at_school: + age: 16 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, at_school, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true, true] + # (27.05 + 17.90) * 52. + child_benefit_entitlement: 2_337.40 + +- name: Case 16, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_benefit: true + is_child_or_qualifying_young_person_for_child_benefit: false + child_benefit_entitlement: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index fcf9c8f9f4..2c62d983bc 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -44,15 +44,20 @@ 19 and without own-right benefits, that route alone makes a UC or PC qualifying young person whatever the education inputs; without it the education route is unchanged. Own-right benefits exclude both routes. UC - and PC agree below 19, where only UC's terminal date differs. + and PC agree below 19, where only UC's terminal date differs. Child + Benefit reg 4 (until the 31 August after the 16th birthday) counts for + the same years. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. CB/CTC leaver -extensions are omitted. UC/PC's route to the 1 September after the 16th -birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). UC's terminal date is an input; PC's September -terminal date is absent. +contracts, approved providers and interruptions are unobserved. The CTC +leaver extensions are omitted; Child Benefit's extension period and terminal +date (regs 5 and 7) are inputs, false here, with their own tests in +test_child_benefit_school_leaver_routes_properties.py. The route to the 1 +September after the 16th birthday (UC, PC) or the 31 August before it (Child +Benefit reg 4), which has no education condition, counts for a year that +starts before that date (invariant 5). UC's terminal date is an input; PC's +September terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -188,17 +193,19 @@ def statutory_child(age): def in_route_to_first_september_after_16th_birthday(p): - # UC reg 5(1)(a) and SPC reg 4A(1)(a) have no education condition. With no - # date of birth, a whole age on 6 October places the birthday on 6 April, - # so the year (from 6 April) starts before the 1 September following the - # 16th birthday exactly when that age is under 17. + # UC reg 5(1)(a), SPC reg 4A(1)(a) and Child Benefit reg 4 have no + # education condition. With no date of birth, a whole age on 6 October + # places the birthday on 6 April, so the year (from 6 April) starts before + # the 1 September following the 16th birthday exactly when that age is + # under 17. Child Benefit's 31 August is the day before. return p.get("is_before_first_september_following_16th_birthday", p["age"] < 17) def qualifying_young_person(p, programme): - # CB and CTC: education/training routes only (see omissions in module - # docstring). UC and PC: also the route with no education condition. + # CTC: education/training routes only (see omissions in module + # docstring). UC, PC and CB: also the route with no education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 @@ -208,6 +215,7 @@ def qualifying_young_person(p, programme): if programme in { "universal_credit", "pension_credit", + "child_benefit", } and in_route_to_first_september_after_16th_birthday(p): return True if not (non_advanced_education(p) or p["is_in_approved_training"]): diff --git a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..a29e48450f --- /dev/null +++ b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py @@ -0,0 +1,327 @@ +"""Child Benefit qualifying young persons who have left education. + +Child Benefit (General) Regulations 2006: a person aged 16 to 19 is a +qualifying young person while any of regs 3 to 7 applies (reg. 2(2)) and +they are not receiving a benefit in their own right (reg. 8). + +- Reg. 3: non-advanced education or approved training, begun before 19. +- Reg. 4: under 17 and left education, until the 31 August next following + the 16th birthday (the 1 September for a 16th birthday on 31 August). +- Reg. 5: under 18, in an extension period, not in remunerative work. +- Reg. 7: until the terminal date after leaving; reg. 7(3) pays no Child + Benefit "by virtue of this regulation" in a week of remunerative work (24 + hours or more, reg. 1(3)). + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education is a qualifying young person for a year + exactly when they are 16 to 19 on 6 October and the year starts (6 April) + on or before the reg. 4 end date, for every date of birth. +2. Over every combination of age, education, training, own-right benefits, + the three leaver facts and hours: the status equals the union of the four + conditions within ages 16 to 19 and without own-right benefits. So each + condition ends at its age limit, own-right benefits exclude every + condition, and with the three leaver facts off the status is reg. 3's + alone, as before. +3. Switching a leaver fact on never removes the status. +4. Child Benefit is payable for the benefit unit's child or qualifying young + person unless they qualify through reg. 7 alone and are in remunerative + work. They stay a qualifying young person, and a legacy young person. +5. Child Benefit entitlement is the eldest payable member at the eldest + rate and each other at the additional rate, for 52 weeks. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_benefit_extension_period" +TERMINAL = "is_before_child_benefit_terminal_date" +QYP = "is_qualifying_young_person_for_child_benefit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + TERMINAL: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def regulation_4_end_date(born): + # Reg. 4(2): "the 31st August next following the person's 16th birthday"; + # reg. 4(3): for a 16th birthday on 31 August, "the 1st September + # immediately following". A 29 February birthday is before 31 August + # whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 9, 1) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + # "Has not passed": the year, from 6 April, reaches the period if it + # starts on or before the end date. + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= regulation_4_end_date(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2006 to 2011: ages 14 to 20 on 6 October 2026, + # with the 31 August and 1 September edges in each year. + start, end = date(2006, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + # Not vacuous, and the edges fall where the regulation puts them. + by_birth = dict(zip(births, expected)) + assert by_birth[date(2010, 6, 1)] and not by_birth[date(2009, 6, 1)] + # 16 on 31 August 2025: the period ended on 1 September 2025. + assert not by_birth[date(2009, 8, 31)] + # 16 on 1 September 2025: the period runs to 31 August 2026. + assert by_birth[date(2009, 9, 1)] + # 16 on 6 October 2026 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2010, 10, 6)] and not by_birth[date(2010, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def regulation_3(p): + # Education status stands in for full-time non-advanced education. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + studying = ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return studying and begun_before_19 + + +def remunerative_work(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/1 + return p["weekly_hours"] >= 24 + + +def regulation_5(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/5 + return p["age"] < 18 and p[EXTENSION] and not remunerative_work(p) + + +def qualifying_young_person(p): + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + return regulation_3(p) or p[ROUTE] or regulation_5(p) or p[TERMINAL] + + +def payable(p): + # Reg. 7(3) bars Child Benefit payable through reg. 7 only. + if p["is_claimant_or_partner"]: + return False + if p["age"] < 16: + return True + if not qualifying_young_person(p): + return False + through_another = regulation_3(p) or p[ROUTE] or regulation_5(p) + return through_another or not remunerative_work(p) + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension, TERMINAL: terminal}, + ) + for age, education, training, own_right, hours in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (0, 23, 24) + ) + for route, extension, terminal in product((False, True), repeat=3) +] + + +def test_status_is_the_union_of_the_four_conditions(): + # 8 ages * every education value * 2 * 2 * 3 hours, each with the 8 + # combinations of the three leaver facts (adjacent rows). + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[ + np.array([p["receives_benefits_in_own_right"] for p in GRID]) + ].any() + assert status.any() + # With the leaver facts off, reg. 3 alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION] or p[TERMINAL]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and regulation_3(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: within each block of 8, a row with more facts on never + # loses the status. Rows are ordered (route, extension, terminal). + blocks = status.reshape(-1, 8) + for less, more in ( + (0, 1), + (0, 2), + (0, 4), + (1, 3), + (1, 5), + (2, 3), + (2, 6), + (4, 5), + (4, 6), + (3, 7), + (5, 7), + (6, 7), + ): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4. + np.testing.assert_array_equal( + sim.calculate("is_child_benefit_payable_for_person", YEAR)[1::2], + [payable(p) for p in GRID], + ) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_benefit", YEAR + )[1::2] + legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR)[1::2] + np.testing.assert_array_equal(family, (age < 16) | status) + np.testing.assert_array_equal(legacy, family) + barred = family & ~np.array([payable(p) for p in GRID]) + # Not vacuous: some young persons are barred, all of them 16 or over, + # before the terminal date and working 24 hours. + assert barred.any() + assert all( + p[TERMINAL] and p["weekly_hours"] == 24 and p["age"] >= 16 + for p, b in zip(GRID, barred) + if b + ) + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2024, 2026])) +def test_child_benefit_entitlement_counts_payable_members(units, year): + # Invariant 5, from the inputs and the weekly rates alone. + sim = simulate(units, year) + rates = sim.tax_benefit_system.parameters(f"{year}-06-01").gov.hmrc.child_benefit + entitlement = sim.calculate("child_benefit_entitlement", year) + for unit, actual in zip(units, entitlement): + number = sum(payable(p) for p in unit) + weekly = ( + 0 + if number == 0 + else rates.amount.eldest + (number - 1) * rates.amount.additional + ) + np.testing.assert_allclose(actual, weekly * 52, rtol=1e-6) diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py index fbf470039c..785e042a10 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py @@ -8,7 +8,7 @@ class child_benefit_child_index(Variable): definition_period = YEAR def formula(person, period, parameters): - child = person("is_child_or_qualifying_young_person_for_child_benefit", period) + child = person("is_child_benefit_payable_for_person", period) rank = ( person.get_rank( person.benunit, diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py index e8e66493c9..3f5b702adc 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py @@ -12,7 +12,7 @@ class child_benefit_respective_amount(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/part/IX", "https://www.legislation.gov.uk/uksi/2006/965/regulation/2", ) - defined_for = "is_child_or_qualifying_young_person_for_child_benefit" + defined_for = "is_child_benefit_payable_for_person" def formula(person, period, parameters): eligible = True diff --git a/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py new file mode 100644 index 0000000000..64f48ee6e7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_before_child_benefit_terminal_date(Variable): + value_type = bool + entity = Person + label = "Before the Child Benefit terminal date after leaving education" + documentation = ( + "Whether, for some of the year, the person has left relevant " + "education or approved training and the week that includes their " + "terminal date has not ended. The terminal date is the last day of " + "February, May, August or November, whichever comes first after they " + "left, or after their last external examination if they were entered " + "for one (Child Benefit (General) Regulations 2006 reg. 7(2), Cases 1 " + "and 2). The model holds one education status for the year and no " + "leaving date, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/7" diff --git a/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py new file mode 100644 index 0000000000..8114bf40e2 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class is_child_benefit_payable_for_person(Variable): + value_type = bool + entity = Person + label = "Child Benefit is payable for this person" + documentation = ( + "Whether the benefit unit's Child Benefit includes an amount for this " + "person: they are its child or qualifying young person, and Child " + "Benefit is not barred by remunerative work. Child Benefit is not " + "payable for a qualifying young person 'by virtue of' the terminal " + "date rule for any week they are in remunerative work (Child Benefit " + "(General) Regulations 2006 reg. 7(3)). So a young person who " + "qualifies only through the terminal date, and works 24 hours or " + "more a week, stays a qualifying young person (and a Housing Benefit " + "young person) but brings no Child Benefit. One who also meets " + "another condition is paid through that one (reg. 2(2))." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + ) + + def formula(person, period, parameters): + another_condition = ( + ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + ) + | person("is_before_first_september_following_16th_birthday", period) + | person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + ) + only_through_terminal_date = ( + person("is_qualifying_young_person_for_child_benefit", period) + & ~person("is_child_for_child_benefit", period) + & ~another_condition + ) + barred = only_through_terminal_date & person( + "is_in_remunerative_work_for_child_benefit", period + ) + return ( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~barred + ) diff --git a/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py new file mode 100644 index 0000000000..beb5ed3938 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_in_child_benefit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Benefit extension period" + documentation = ( + "Whether the person is in a Child Benefit extension period in the " + "year: they have ceased education or training, are registered for " + "work, education or training with a qualifying body (the Careers " + "Service, Connexions or the Ministry of Defence), the person " + "responsible for them was entitled to Child Benefit for them " + "immediately before and asked for the extension within three months, " + "and the 20 weeks from the week after they left have not run out " + "(Child Benefit (General) Regulations 2006 reg. 5(2)(a), (b), (d), " + "(e) and (f), and (3)). The model applies the age limit (reg. 5(1)) " + "and the bar on remunerative work (reg. 5(2)(c)). Surveys do not " + "record registration or the request, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" diff --git a/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py new file mode 100644 index 0000000000..10a05ee191 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_benefit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Benefit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Benefit (General) Regulations 2006 " + "reg. 1(3)), read from average weekly hours over the year." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/1" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 898a91b7cd..03a6cdd3ce 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -5,25 +5,67 @@ class is_qualifying_young_person_for_child_benefit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Benefit" + documentation = ( + "A person aged 16 to 19 who meets any of the conditions in Child " + "Benefit (General) Regulations 2006 regs 3 to 7 (reg. 2(2): a " + "qualifying young person until the last of them ceases to be met) " + "and is not receiving a benefit in their own right (reg. 8). The " + "conditions modelled are: non-advanced education or approved " + "training, begun before 19 (reg. 3); the period to the 31 August " + "after the 16th birthday, whatever their education (reg. 4); an " + "extension period for a 16- or 17-year-old (reg. 5); and the period " + "to the terminal date after leaving education (reg. 7). Reg. 4 " + "reads is_before_first_september_following_16th_birthday: its 31 " + "August (1 September for a 16th birthday on 31 August) falls in the " + "same fiscal year as Universal Credit's 1 September, so a year that " + "starts before one starts before the other. Interruptions (reg. 6) " + "are not modelled. The own-right benefits input covers Universal " + "Credit, Employment and Support Allowance and Jobseeker's Allowance " + "but not the Income Support, incapacity benefit and tax credits that " + "reg. 8 also lists." + ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/ukpga/1992/4/section/142", - "https://www.legislation.gov.uk/uksi/2006/223", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/4", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", ] def formula(person, period, parameters): + # Reg. 3: education or training, begun before 19. + in_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + # Reg. 4: until the 31 August after the 16th birthday. A person in + # that period is under 17, as reg. 4(1) requires. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5: the extension period. + in_extension_period = person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + # Reg. 7: until the terminal date after leaving. + before_terminal_date = person("is_before_child_benefit_terminal_date", period) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_benefit", - period, + & ( + in_education_or_training + | before_31_august_following_16th_birthday + | in_extension_period + | before_terminal_date ) & ~person("receives_benefits_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py new file mode 100644 index 0000000000..592e0189f7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_extension_period_condition_for_child_benefit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person extension period condition for Child Benefit" + documentation = ( + "A person under 18 who has left education or training, is registered " + "with a qualifying body and is not in remunerative work, for the 20 " + "weeks of the extension period their parent or carer asked for." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return ( + (person("age", period) < p.extension_period_age_limit) + & person("is_in_child_benefit_extension_period", period) + & ~person("is_in_remunerative_work_for_child_benefit", period) + ) From 9b3b82aa9c101f73e85c7558b8a0a148b2c1cb80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:08:53 -0400 Subject: [PATCH 002/168] Add the Child Tax Credit qualifying young person rules for school leavers Child Tax Credit Regulations 2002 reg. 4 makes a person a qualifying young person from their 16th birthday until the 31 August that next follows, with no education condition, and reg. 5(3)(b) adds the 20 weeks after leaving for an under-18 registered for work or training. The model had only the education condition (reg. 5(3)(a), (ab)). - Reg. 4 reads is_before_first_september_following_16th_birthday. - Reg. 5(3)(b) reads the new input is_in_child_tax_credit_extension_period. - Reg. 5(4)(a): a young person who has left education or training and works 24 hours a week or more is not a qualifying young person through either. Tax credits ended on 5 April 2025, so this changes years to 2024-25. Co-Authored-By: Claude Opus 5.5 --- .../ctc-qyp-school-leaver-routes.fixed.md | 1 + .../extension_period_age_limit.yaml | 9 + .../eligibility/remunerative_work_hours.yaml | 9 + ...child_tax_credit_school_leaver_routes.yaml | 309 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 39 ++- ..._credit_school_leaver_routes_properties.py | 298 +++++++++++++++++ ...is_in_child_tax_credit_extension_period.py | 21 ++ ..._remunerative_work_for_child_tax_credit.py | 20 ++ ...fying_young_person_for_child_tax_credit.py | 63 +++- 9 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 changelog.d/ctc-qyp-school-leaver-routes.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml create mode 100644 policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py create mode 100644 policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..33a0ea5ad5 --- /dev/null +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)). diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..2266a1fe0c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Tax Credit qualifying young person for 20 weeks while registered for work or training. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Tax Credit extension period age limit + reference: + - title: The Child Tax Credit Regulations 2002 reg. 5(3)(b) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..dc614090d1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Tax Credit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Tax Credit remunerative work hours + reference: + - title: The Child Tax Credit Regulations 2002 reg. 2(1), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/2 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml new file mode 100644 index 0000000000..fe113d9ad4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -0,0 +1,309 @@ +# Child Tax Credit qualifying young persons who have left education: Child +# Tax Credit Regulations 2002 reg. 4 (from the 16th birthday until the 31 +# August that next follows, with no education condition), reg. 5(3)(b) (20 +# weeks after leaving, under 18, while registered for work or training) and +# the second condition in reg. 5(4), which applies to both: not in +# remunerative work (24 hours a week or more, reg. 2(1)) after leaving, and +# not receiving a benefit in their own right. +# +# The child element was £3,455 in 2024-25, the last year of tax credits +# (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] + is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit: true + CTC_child_element: 3_455 + +- name: Case 2, a 16-year-old who has left education and works 24 hours a week is not a qualifying young person (reg. 5(4)(a)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_tax_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + # Child Benefit reg. 4 has no remunerative work bar. + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 3, working 23 hours a week does not end the period. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 4, remunerative work does not matter for a 16-year-old still at school. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pupil: + age: 16 + current_education: UPPER_SECONDARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, pupil] + output: + # Reg. 5(4)(a) applies to a person "having ceased" education or training. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, a 16-year-old apprentice working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + # Education "by virtue of his employment" is not the first condition + # (reg. 5(3)(a)(ii)), so the apprentice has left it and is in + # remunerative work. + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 6, born 15 September 2007, the period runs to 31 August 2024. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 7, born 31 August 2007, reg. 4(1A) gives no period to the next 31 August. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 8, born 31 August 2008, the year in which they are a child until 30 August counts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + date_of_birth: 20080831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 9, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 5(4)(c)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 10, a 17-year-old who has left education is not a qualifying young person by default. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 11, a 17-year-old in the 20 weeks after leaving, registered for work or training, is a qualifying young person (reg. 5(3)(b)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 12, the 20 weeks end if the 17-year-old starts remunerative work. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 13, the 20 weeks are for under-18s only. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 14, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: true + is_child_or_qualifying_young_person_for_child_tax_credit: false + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 2c62d983bc..c3807f0b2e 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -45,19 +45,21 @@ qualifying young person whatever the education inputs; without it the education route is unchanged. Own-right benefits exclude both routes. UC and PC agree below 19, where only UC's terminal date differs. Child - Benefit reg 4 (until the 31 August after the 16th birthday) counts for - the same years. + Benefit reg 4 and CTC reg 4 (until the 31 August after the 16th birthday) + count for the same years. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. The CTC -leaver extensions are omitted; Child Benefit's extension period and terminal -date (regs 5 and 7) are inputs, false here, with their own tests in -test_child_benefit_school_leaver_routes_properties.py. The route to the 1 +contracts, approved providers and interruptions are unobserved. Child +Benefit's extension period and terminal date (regs 5 and 7) and CTC's 20 +weeks after leaving (reg 5(3)(b)) are inputs, false here, with their own +tests in test_child_benefit_school_leaver_routes_properties.py and +test_child_tax_credit_school_leaver_routes_properties.py. The route to the 1 September after the 16th birthday (UC, PC) or the 31 August before it (Child -Benefit reg 4), which has no education condition, counts for a year that -starts before that date (invariant 5). UC's terminal date is an input; PC's -September terminal date is absent. +Benefit reg 4, CTC reg 4), which has no education condition, counts for a +year that starts before that date (invariant 5). Nobody here is in +remunerative work, which ends CTC's route. UC's terminal date is an input; +PC's September terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -202,23 +204,24 @@ def in_route_to_first_september_after_16th_birthday(p): def qualifying_young_person(p, programme): - # CTC: education/training routes only (see omissions in module - # docstring). UC, PC and CB: also the route with no education condition. + # Every programme: the education/training route, and the route with no + # education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + # CTC at 16: https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: return False - if programme in { - "universal_credit", - "pension_credit", - "child_benefit", - } and in_route_to_first_september_after_16th_birthday(p): - return True - if not (non_advanced_education(p) or p["is_in_approved_training"]): + studying = non_advanced_education(p) or p["is_in_approved_training"] + if in_route_to_first_september_after_16th_birthday(p): + # CTC reg 4 lifts only the first condition, for a person who has left + # education; one still in it is tested as a student below. + if programme != "child_tax_credit" or not studying: + return True + if not studying: return False if ( p["age"] >= 19 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..fde32f0087 --- /dev/null +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -0,0 +1,298 @@ +"""Child Tax Credit qualifying young persons who have left education. + +Child Tax Credit Regulations 2002: + +- Reg. 5(3)(a), (ab), (3A): the first condition is full-time non-advanced + education or approved training, begun before 19. +- Reg. 4(1)-(2): from the 16th birthday until the 31 August that next + follows, the first condition is not required. Reg. 4(1A): a 16th birthday + on 31 August gets no such period. +- Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for + work or training satisfy the first condition. +- Reg. 5(4), the second condition, always applies: a person who has left + education or training must not be in remunerative work (24 hours a week or + more, reg. 2(1)), and no one may be receiving a benefit in their own right. + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education, not working, is a qualifying young person for + a year exactly when they are 16 to 19 on 6 October and some of the year, + from 6 April, falls on or before the last day they are a child or in the + reg. 4 period. For a 16th birthday on 31 August that day is 30 August. +2. Over every combination of age, education, training, own-right benefits, + the two leaver facts and hours: the status is the education condition, or + a leaver fact for a person who has left and is not in remunerative work, + within ages 16 to 19 and without own-right benefits. So remunerative work + ends both leaver routes and neither a student's status, the 20 weeks end + at 18, and with the leaver facts off the status is the education + condition's alone, as before. +3. Switching a leaver fact on never removes the status. +4. The benefit unit's child or qualifying young person is a child or + qualifying young person who is not its claimant or partner. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2024 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_tax_credit_extension_period" +QYP = "is_qualifying_young_person_for_child_tax_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def last_day_as_child_or_in_regulation_4_period(born): + # Reg. 4(1): "until 31st August which next follows" the 16th birthday. + # Reg. 4(1A): a 16th birthday on 31 August starts no such period, so the + # last day covered is the day before it, the last day under 16. + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 8, 30) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= last_day_as_child_or_in_regulation_4_period(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 14 to 20 on 6 October 2024. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + by_birth = dict(zip(births, expected)) + assert by_birth[date(2008, 6, 1)] and not by_birth[date(2007, 6, 1)] + # 16 on 31 August 2023: reg. 4(1A) gives no period to 31 August 2024. + assert not by_birth[date(2007, 8, 31)] + # 16 on 31 August 2024: a child until 30 August 2024, in the year. + assert by_birth[date(2008, 8, 31)] + # 16 on 1 September 2023: the period runs to 31 August 2024. + assert by_birth[date(2007, 9, 1)] + # 16 on 6 October 2024 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2008, 10, 6)] and not by_birth[date(2008, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def receiving_education_or_training(p): + # Education status stands in for full-time non-advanced education that is + # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def first_condition_through_education(p): + # Reg. 5(3A): begun before 19. + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return receiving_education_or_training(p) and begun_before_19 + + +def qualifying_young_person(p): + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if first_condition_through_education(p): + return True + left = not receiving_education_or_training(p) + leaver_fact = p[ROUTE] or (p["age"] < 18 and p[EXTENSION]) + # Reg. 2(1): remunerative work is 24 hours a week or more. + return left and leaver_fact and p["weekly_hours"] < 24 + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension}, + ) + for age, education, training, apprentice, own_right, hours in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (0, 23, 24), + ) + for route, extension in product((False, True), repeat=2) +] + + +def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + hours = np.array([p["weekly_hours"] for p in GRID]) + student = np.array([receiving_education_or_training(p) for p in GRID]) + own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[own_right].any() + # Remunerative work ends a leaver's status and not a student's. + assert not status[~student & (hours >= 24)].any() + assert status[student & (hours >= 24)].any() + # The 20 weeks alone do not reach an 18-year-old. + alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) + assert not status[alone & ~student & (age >= 18)].any() + assert status[alone & ~student & (age == 17)].any() + # With the leaver facts off, the education condition alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and first_condition_through_education(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: rows are ordered (route, extension) within blocks of 4. + blocks = status.reshape(-1, 4) + for less, more in ((0, 1), (0, 2), (1, 3), (2, 3)): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4, for members who are not the claimant. + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", YEAR + ) + np.testing.assert_array_equal(family[1::2], (age < 16) | status) + assert not family[0::2].any() + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + is_looked_after_by_local_authority=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2020, 2024])) +def test_family_members_in_any_benefit_unit(units, year): + # Invariants 2 and 4 on benefit units of any shape, where a claimant or + # partner can be a qualifying young person by status. + sim = simulate(units, year) + people = [p for unit in units for p in unit] + status = sim.calculate(QYP, year) + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in people]) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", year + ) + expected = [ + (p["age"] < 16 or qualifying_young_person(p)) + and not p["is_claimant_or_partner"] + and not p["is_looked_after_by_local_authority"] + for p in people + ] + np.testing.assert_array_equal(family, expected) diff --git a/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py new file mode 100644 index 0000000000..de6be75a66 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class is_in_child_tax_credit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Tax Credit extension period" + documentation = ( + "Whether, for some of the year, the person has ceased full-time " + "education or approved training, HMRC was notified within three " + "months that they are registered for work or training with a " + "qualifying body (the Careers Service, Connexions or the Ministry of " + "Defence), and no more than 20 weeks have passed since they left " + "(Child Tax Credit Regulations 2002 reg. 5(3)(b)(i)-(iii)). The model " + "applies the age limit (reg. 5(3)(b)) and the second condition (reg. " + "5(4)). Surveys do not record the registration, so this is false " + "unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" diff --git a/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py new file mode 100644 index 0000000000..5369423124 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_tax_credit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Tax Credit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Tax Credit Regulations 2002 reg. " + "2(1)), read from average weekly hours over the year. The Working " + "Tax Credit rules the definition refers to, for counting hours and " + "for excluded work, are not modelled." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/2" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2c5e98dd46..3fa5ac8721 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -5,22 +5,69 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Tax Credit" + documentation = ( + "A person aged 16 to 19 who meets the Child Tax Credit Regulations " + "2002's conditions. The first condition (reg. 5(3)) is full-time " + "non-advanced education or approved training, begun before 19, or, " + "under 18, the 20 weeks after leaving while registered for work or " + "training. Reg. 4(1) lifts the first condition from the 16th " + "birthday until the 31 August that next follows it. That period " + "reads is_before_first_september_following_16th_birthday, so it " + "counts in any year that starts before the 31 August. For a 16th " + "birthday on 31 August, reg. 4(1A) gives no such period, where " + "Universal Credit gives that day and Child Benefit two: the year " + "counts all the same, as the one in which the person is a child " + "until 30 August, and not the year after. The second condition " + "(reg. 5(4)) always applies: a young person who has left education or " + "training must not be in remunerative work, and no one may be " + "receiving a benefit in their own right. The own-right benefits " + "input covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " + "Income Support and the income-related and income-based allowances. " + "Interruptions (reg. 5(7)) are not modelled." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/4", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ] def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + # Reg. 5(3)(a), (ab) and (3A). + receiving_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", + period, + ) + in_education_or_training = receiving_education_or_training & person( + "meets_qualifying_young_person_entry_condition_for_child_tax_credit", + period, + ) + # Reg. 4(1): from the 16th birthday to the 31 August that next + # follows. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(3)(b): 20 weeks after leaving, under 18. + in_extension_period = ( + person("age", period) < p.extension_period_age_limit + ) & person("is_in_child_tax_credit_extension_period", period) + # Reg. 5(4)(a): having left, not in remunerative work. + left_and_not_working = ~receiving_education_or_training & ~person( + "is_in_remunerative_work_for_child_tax_credit", period + ) return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_tax_credit", - period, + & ( + in_education_or_training + | ( + (before_31_august_following_16th_birthday | in_extension_period) + & left_and_not_working + ) ) + # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) ) From dc4cf4476af63b00a1fb7f222d73a9d859097ab4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:14:28 -0400 Subject: [PATCH 003/168] Place the 1 September after a QYP's 19th birthday from the date of birth UC Regs 2013 reg. 5(1)(b) and SPC Regs 2002 reg. 4A(1)(b) keep a young person in education or training a qualifying young person up to, but not including, the 1 September following their 19th birthday. - Universal Credit read the date from an input that defaulted to false, so a 19-year-old at school was not a qualifying young person in a household calculation, while the microdata sets the input true for every 19-year-old in education. - Pension Credit did not model the date at all. Add is_before_first_september_following_19th_birthday, which counts a year that starts before the date, as the 16th-birthday fact does. is_before_universal_credit_qualifying_young_person_terminal_date now follows it unless set, and Pension Credit's new terminal date condition reads it. Co-Authored-By: Claude Opus 5.5 --- changelog.d/qyp-terminal-date-19.fixed.md | 1 + .../eligibility/terminal_date_age_limit.yaml | 9 + ...qualifying_young_person_terminal_date.yaml | 239 ++++++++++++++++++ ...g_young_person_terminal_date_properties.py | 232 +++++++++++++++++ ...lifying_young_person_for_pension_credit.py | 29 ++- ...minal_date_condition_for_pension_credit.py | 25 ++ ...t_qualifying_young_person_terminal_date.py | 20 +- ...nal_date_condition_for_universal_credit.py | 13 +- ...first_september_following_19th_birthday.py | 44 ++++ 9 files changed, 594 insertions(+), 18 deletions(-) create mode 100644 changelog.d/qyp-terminal-date-19.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml create mode 100644 policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py create mode 100644 policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md new file mode 100644 index 0000000000..f6ffcb4951 --- /dev/null +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -0,0 +1 @@ +- Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). A 19-year-old in education now counts for Universal Credit by default in household calculations, as in the microdata, and Pension Credit applies the date, which it did not model. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml new file mode 100644 index 0000000000..09616dbd7b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age from which Pension Credit qualifying young person status is limited to the period before the 1 September following the 19th birthday. +values: + 0001-01-01: 19 +metadata: + unit: year + label: Pension Credit qualifying young person terminal date age limit + reference: + - title: The State Pension Credit Regulations 2002 reg. 4A(1)(b) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml new file mode 100644 index 0000000000..66991dfb56 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -0,0 +1,239 @@ +# A young person in non-advanced education or approved training is a +# qualifying young person "up to, but not including, the 1st September +# following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 +# reg. 4A(1)(b)). The model places that date from the date of birth and +# counts a year that starts (6 April) before it, so every 19-year-old counts +# unless an input says the date has passed. +# +# 2026-27 amounts: Universal Credit child element for a first child born +# before 6 April 2017, £351.88 a month +# (gov/dwp/universal_credit/elements/child/first/higher_amount.yaml); +# Pension Credit first-child addition, £81.07 a week +# (gov/dwp/pension_credit/guarantee_credit/child/first/addition.yaml). + +- name: Case 1, a 19-year-old at school is a Universal Credit and Pension Credit qualifying young person by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + # 351.88 * 12. + uc_individual_child_element: [0, 4_222.56] + is_uc_work_allowance_eligible: true + +- name: Case 2, the Universal Credit input ends Universal Credit status only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 3, the date fact set false ends both. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_universal_credit_qualifying_young_person_terminal_date: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + +- name: Case 4, a pensioner gets the Pension Credit child addition for a 19-year-old at school. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, true] + # 81.07 * 52. + child_minimum_guarantee_addition: 4_215.64 + +- name: Case 5, no Pension Credit child addition once the 1 September after the 19th birthday has passed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 6, born 31 August 2007, the period ends on the 19th birthday, in the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + date_of_birth: 20070831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: Case 7, born 15 September 2006, before the date until 31 August 2026 but aged 20 on 6 October. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060915 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 8, born 31 August 2006, the 1 September after the 19th birthday was in 2025. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: Case 9, a course begun at 19 does not qualify, whatever the date (reg. 5(2); reg. 4A(2)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 19 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 10, a 19-year-old outside education is not a qualifying young person, whatever the date. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_adult: + age: 19 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_adult] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py new file mode 100644 index 0000000000..f0ef7d86da --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -0,0 +1,232 @@ +"""The 1 September following the 19th birthday, for Universal Credit and +Pension Credit qualifying young persons. + +A young person enrolled on, or accepted for, non-advanced education or +approved training is a qualifying young person "up to, but not including, +the 1st September following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); +SPC Regs 2002 reg. 4A(1)(b)). + +Invariants: + +1. is_before_first_september_following_19th_birthday is true exactly when + the year starts (6 April) before that 1 September, for every date of + birth, against calendar arithmetic that shares no code with the model. +2. Universal Credit's own terminal-date variable follows that fact unless it + is set, and setting it changes Universal Credit only. +3. Over every combination of age, education, training, apprenticeship, + own-right benefits, entry age and the two date facts: Universal Credit + and Pension Credit agree, and both equal the regulation's conditions. +4. With the 19th-birthday fact on, both statuses are the earlier definition + without a terminal date, so nothing changes below 19 or for a 19-year-old + before the date; switching the fact on never removes the status; and it + matters only for a 19-year-old in education or training. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +NINETEEN = "is_before_first_september_following_19th_birthday" +UC_TERMINAL = "is_before_universal_credit_qualifying_young_person_terminal_date" +UC = "is_qualifying_young_person_for_universal_credit" +PC = "is_qualifying_young_person_for_pension_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "age_started_or_accepted_current_education_or_training": 17, + **inputs, + } + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def first_september_following_19th_birthday(born): + # A 19th birthday before 1 September is followed by 1 September of the + # same year, and one on or after it by the next year's. A 29 February + # birthday is before 1 September whichever day it is kept on. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 19 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_fact_matches_calendar(births, year): + people = [ + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")) + for born in births + ] + expected = [ + date(year, 4, 6) < first_september_following_19th_birthday(born) + for born in births + ] + sim = simulate(people, year) + # Invariant 1. + np.testing.assert_array_equal(sim.calculate(NINETEEN, year), expected) + # Invariant 2: Universal Credit's variable follows it when not set. + np.testing.assert_array_equal(sim.calculate(UC_TERMINAL, year), expected) + return expected + + +def test_fact_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 16 to 22 on 6 October 2026. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = dict(zip(births, assert_fact_matches_calendar(births, YEAR))) + # 19 on 31 August 2026: the period ends that day, in the year. + assert expected[date(2007, 8, 31)] + # 19 on 31 August 2025: it ended before the year. + assert not expected[date(2006, 8, 31)] + # 19 on 1 September 2025: it runs to 31 August 2026. + assert expected[date(2006, 9, 1)] + # Everyone aged 19 on 6 October 2026 is before the date. + assert all( + before + for born, before in expected.items() + if age_on_6_october(born, YEAR) == 19 + ) + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_fact_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_fact_matches_calendar(births, year) + + +def studying(p): + # Education status stands in for a non-advanced course of more than 12 + # hours a week; reg. 5(3) and reg. 4A(3) exclude a contract of employment. + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def qualifying_young_person(p, terminal_date=True): + # Reg. 5(1), (2), (5); reg. 4A(1), (2), (5). + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if p[SIXTEEN]: + return True + if not studying(p): + return False + if ( + p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + ): + return False + return p["age"] < 19 or p[NINETEEN] or not terminal_date + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + for age, education, training, apprentice, own_right, entry_age, sixteen in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (17, 19, 1000), + (False, True), + ) + for nineteen in (False, True) +] + + +def test_universal_credit_and_pension_credit_agree_on_every_combination(): + sim = simulate(GRID) + uc, pc = sim.calculate(UC, YEAR), sim.calculate(PC, YEAR) + expected = np.array([qualifying_young_person(p) for p in GRID]) + # Invariant 3. + np.testing.assert_array_equal(uc, expected) + np.testing.assert_array_equal(pc, expected) + # Invariant 4. Rows are adjacent pairs: the 19th-birthday fact off, on. + off, on = expected[0::2], expected[1::2] + assert (on >= off).all() + np.testing.assert_array_equal( + on, [qualifying_young_person(p, terminal_date=False) for p in GRID[1::2]] + ) + changed = on != off + assert changed.any() + assert all( + p["age"] == 19 and studying(p) and not p[SIXTEEN] + for p, c in zip(GRID[1::2], changed) + if c + ) + + +def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): + # Invariant 2: a 19-year-old at school, with each programme's input set + # against the fact. + rows = list(product((False, True), repeat=2)) + people = [ + person( + 19, + current_education="UPPER_SECONDARY", + **{SIXTEEN: False, NINETEEN: nineteen, UC_TERMINAL: uc_terminal}, + ) + for nineteen, uc_terminal in rows + ] + sim = simulate(people) + np.testing.assert_array_equal(sim.calculate(UC, YEAR), [u for _, u in rows]) + np.testing.assert_array_equal(sim.calculate(PC, YEAR), [n for n, _ in rows]) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 6fc28d3f3a..ff054781b9 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -9,11 +9,11 @@ class is_qualifying_young_person_for_pension_credit(Variable): "A person aged 16 to 19 who is either before the 1 September following " "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " "non-advanced education or approved training that meets the entry " - "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " - "benefits in their own right (reg. 4A(5)). The 1 September after the " - "19th birthday in reg. 4A(1)(b) is not modelled, and the own-right " - "benefits input covers Universal Credit, Employment and Support " - "Allowance and Jobseeker's Allowance but not Income Support." + "condition, before the 1 September following their 19th birthday " + "(reg. 4A(1)(b), (2) and (3)), and who is not receiving benefits in " + "their own right (reg. 4A(5)). The own-right benefits input covers " + "Universal Credit, Employment and Support Allowance and Jobseeker's " + "Allowance but not Income Support." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -24,12 +24,19 @@ def formula(person, period, parameters): "is_before_first_september_following_16th_birthday", period ) # Reg. 4A(1)(b), (2) and (3). - in_education_or_training = person( - "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", - period, - ) & person( - "meets_qualifying_young_person_entry_condition_for_pension_credit", - period, + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + period, + ) ) return ( person( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py new file mode 100644 index 0000000000..6427f201db --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person terminal date condition for Pension Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (SPC Regs 2002 reg. 4A(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_first_september_following_19th_birthday." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit.child.eligibility + age = person("age", period) + return (age < p.terminal_date_age_limit) | person( + "is_before_first_september_following_19th_birthday", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index b9f6e4a784..97bd3928c2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -6,11 +6,19 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) entity = Person label = "Before the Universal Credit qualifying young person terminal date" documentation = ( - "Whether this person is before the Universal Credit terminal date " - "for a 19-year-old qualifying young person. This captures date-of-birth " - "and assessment-period detail that is not otherwise available in the " - "annual model." + "Whether this person is before the Universal Credit terminal date for " + "a 19-year-old qualifying young person: the 1 September following " + "their 19th birthday (UC Regs 2013 reg. 5(1)(b)). It follows the date " + "of birth (is_before_first_september_following_19th_birthday), so a " + "year counts if it starts before that date. Datasets and situations " + "can set it directly: set it to false for a 19-year-old whose 1 " + "September has passed." ) definition_period = YEAR - default_value = False - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) + + def formula(person, period, parameters): + return person("is_before_first_september_following_19th_birthday", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py index 73837e7b14..9ab4e6a4f5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py @@ -7,8 +7,19 @@ class meets_qualifying_young_person_terminal_date_condition_for_universal_credit value_type = bool entity = Person label = "Meets qualifying young person terminal date condition for Universal Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (UC Regs 2013 reg. 5(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_universal_credit_qualifying_young_person_terminal_date, " + "which follows the date of birth unless set." + ) definition_period = YEAR - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.eligibility diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py new file mode 100644 index 0000000000..379d89c660 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_19th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 19th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 19th birthday, the date to which a young " + "person in non-advanced education or approved training stays a " + "qualifying young person (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 " + "reg. 4A(1)(b)). The date comes from date_of_birth, or from age and " + "months_since_last_birthday. A 19th birthday on 31 August ends the " + "period that day, and one on 1 September runs it to the 20th " + "birthday. The period ends on a fixed 1 September that annual " + "periods cannot split, so a person in it for part of the year counts " + "for the year, as is_before_first_september_following_16th_birthday " + "does. Everyone aged 19 on 6 October is before the date. So is a " + "20-year-old born from 1 September to 6 October, whom the qualifying " + "young person age limit, read on 6 October, already excludes. Set " + "this to false for a year that the period does not reach." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 19th birthday falls in the birth year plus 19; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 19 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year From 9b0c276729c5b597897fc6304b1fb07632aac52a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:29:41 -0400 Subject: [PATCH 004/168] Exclude Income Support in own right from PC, Child Benefit and CTC QYPs SPC Regs 2002 reg. 4A(5), Child Benefit (General) Regulations 2006 reg. 8(2)(a) and CTC Regs 2002 reg. 5(4)(c) each say a person receiving income support is not a qualifying young person. The own-right benefits input covers Universal Credit, Employment and Support Allowance and Jobseeker's Allowance, which is Universal Credit's list (reg. 5(5)). Add receives_income_support_in_own_right: the person's own reported Income Support while Income Support is in payment (to 2025-26), settable directly. It does not read the benefit unit's calculated award, which depends on who the family's young persons are. The three programmes' qualifying young person tests exclude it; Universal Credit's does not. Co-Authored-By: Claude Opus 5.5 --- .../qyp-income-support-exclusion.fixed.md | 1 + ...ualifying_young_person_income_support.yaml | 165 ++++++++++++++++++ ..._young_person_income_support_properties.py | 128 ++++++++++++++ ...fying_young_person_for_child_tax_credit.py | 10 +- ...lifying_young_person_for_pension_credit.py | 9 +- ...alifying_young_person_for_child_benefit.py | 10 +- .../receives_income_support_in_own_right.py | 30 ++++ 7 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 changelog.d/qyp-income-support-exclusion.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml create mode 100644 policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py create mode 100644 policyengine_uk/variables/household/income/receives_income_support_in_own_right.py diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md new file mode 100644 index 0000000000..35769651aa --- /dev/null +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -0,0 +1 @@ +- Stopped counting a young person who receives Income Support in their own right as a qualifying young person for Pension Credit, Child Benefit (and so Housing Benefit and the legacy benefits) and Child Tax Credit (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c)), read from their own reported Income Support while it was in payment. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml new file mode 100644 index 0000000000..3d25203df1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml @@ -0,0 +1,165 @@ +# Income Support in a young person's own right ends qualifying young person +# status for Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child +# Benefit (General) Regulations 2006 reg. 8(2)(a)) and Child Tax Credit (CTC +# Regs 2002 reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) +# names Universal Credit, Employment and Support Allowance and Jobseeker's +# Allowance only. +# +# Income Support stopped paying on 31 March 2026 +# (gov/dwp/income_support/active.yaml), so reported receipt counts to 2025-26. +# 2025-26 amounts: Child Benefit eldest child £26.05 a week; Pension Credit +# first-child addition £78.10 a week. CTC child element £3,455 in 2024-25. + +- name: Case 1, a 17-year-old at school with no Income Support is every programme's qualifying young person. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + # 78.10 * 52. + child_minimum_guarantee_addition: 4_061.20 + # 26.05 * 52. + child_benefit_entitlement: 1_354.60 + +- name: Case 2, reported Income Support of their own ends it for Pension Credit and Child Benefit, not Universal Credit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + child_minimum_guarantee_addition: 0 + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + +- name: Case 3, the same for Child Tax Credit in 2024-25. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 4, without it the Child Tax Credit child element is paid. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, it ends the 16-year-old's status too, which has no education condition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, school_leaver] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: Case 6, a report of Income Support after it stopped paying does not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 7, the input can be set directly. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + receives_income_support_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py new file mode 100644 index 0000000000..bd5ab7e22e --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -0,0 +1,128 @@ +"""Income Support in a young person's own right and qualifying young person +status. + +A person receiving Income Support is not a qualifying young person for +Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child Benefit +(General) Regulations 2006 reg. 8(2)(a)) or Child Tax Credit (CTC Regs 2002 +reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) does not +include Income Support. + +Invariants: + +1. Over every combination of age, education, training, the other own-right + benefits and the 16th-birthday fact: with Income Support in their own + right, nobody is a Pension Credit, Child Benefit or Child Tax Credit + qualifying young person. +2. Without it, each of those three statuses is exactly what it is when the + input is left alone, so nothing else changes. +3. Universal Credit status never depends on it. +4. Without it, Pension Credit and Universal Credit agree on every combination; + with it, Pension Credit is the narrower. +5. By default the input is the person's own reported Income Support while + Income Support is in payment (to 2025-26), and false after. +""" + +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2025 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +INCOME_SUPPORT = "receives_income_support_in_own_right" +EXCLUDING = ("pension_credit", "child_benefit", "child_tax_credit") +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def status(sim, programme, year=YEAR): + return sim.calculate(f"is_qualifying_young_person_for_{programme}", year) + + +BASE = [ + { + "age": age, + "current_education": education, + "is_in_approved_training": training, + "receives_benefits_in_own_right": own_right, + "age_started_or_accepted_current_education_or_training": 17, + SIXTEEN: sixteen, + } + for age, education, training, own_right, sixteen in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (False, True) + ) +] + + +def test_income_support_ends_three_statuses_and_not_universal_credit(): + untouched = simulate(BASE) + without = simulate([dict(p, **{INCOME_SUPPORT: False}) for p in BASE]) + with_it = simulate([dict(p, **{INCOME_SUPPORT: True}) for p in BASE]) + for programme in EXCLUDING: + before = status(untouched, programme) + # Not vacuous: there is a status to lose. + assert before.any(), programme + # Invariant 1. + assert not status(with_it, programme).any(), programme + # Invariant 2. + np.testing.assert_array_equal(status(without, programme), before) + # Invariant 3. + universal_credit = status(untouched, "universal_credit") + np.testing.assert_array_equal(status(with_it, "universal_credit"), universal_credit) + np.testing.assert_array_equal(status(without, "universal_credit"), universal_credit) + # Invariant 4. + np.testing.assert_array_equal(status(without, "pension_credit"), universal_credit) + assert (status(with_it, "pension_credit") <= universal_credit).all() + assert universal_credit.any() + + +@SETTINGS +@given( + st.lists( + st.tuples(st.integers(0, 60), st.sampled_from([0, 0, 1, 2_500, 9_000])), + min_size=1, + max_size=40, + ), + st.sampled_from([2021, 2024, 2025, 2026, 2028]), +) +def test_default_follows_reported_income_support_while_it_is_paid(rows, year): + # Invariant 5. Income Support paid its last awards on 31 March 2026. + people = [{"age": age, "income_support_reported": amount} for age, amount in rows] + sim = simulate(people, year) + expected = [year <= 2025 and amount > 0 for _, amount in rows] + np.testing.assert_array_equal(sim.calculate(INCOME_SUPPORT, year), expected) diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 3fa5ac8721..f0e018b4ec 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -20,10 +20,11 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "until 30 August, and not the year after. The second condition " "(reg. 5(4)) always applies: a young person who has left education or " "training must not be in remunerative work, and no one may be " - "receiving a benefit in their own right. The own-right benefits " - "input covers Universal Credit, Employment and Support Allowance and " - "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " - "Income Support and the income-related and income-based allowances. " + "receiving a benefit in their own right. That is read from the " + "own-right inputs for Universal Credit, Employment and Support " + "Allowance, Jobseeker's Allowance and Income Support; reg. 5(4)(c) " + "lists Universal Credit, Income Support and only the income-related " + "and income-based allowances, which the inputs do not separate. " "Interruptions (reg. 5(7)) are not modelled." ) definition_period = YEAR @@ -70,4 +71,5 @@ def formula(person, period, parameters): ) # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index ff054781b9..4cf4be9fd0 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -10,10 +10,9 @@ class is_qualifying_young_person_for_pension_credit(Variable): "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " "non-advanced education or approved training that meets the entry " "condition, before the 1 September following their 19th birthday " - "(reg. 4A(1)(b), (2) and (3)), and who is not receiving benefits in " - "their own right (reg. 4A(5)). The own-right benefits input covers " - "Universal Credit, Employment and Support Allowance and Jobseeker's " - "Allowance but not Income Support." + "(reg. 4A(1)(b), (2) and (3)), and who is not receiving Universal " + "Credit, Employment and Support Allowance, Jobseeker's Allowance or " + "Income Support in their own right (reg. 4A(5))." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -47,5 +46,7 @@ def formula(person, period, parameters): before_first_september_following_16th_birthday | in_education_or_training ) + # Reg. 4A(5). & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 03a6cdd3ce..2a122a392c 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -19,10 +19,10 @@ class is_qualifying_young_person_for_child_benefit(Variable): "August (1 September for a 16th birthday on 31 August) falls in the " "same fiscal year as Universal Credit's 1 September, so a year that " "starts before one starts before the other. Interruptions (reg. 6) " - "are not modelled. The own-right benefits input covers Universal " - "Credit, Employment and Support Allowance and Jobseeker's Allowance " - "but not the Income Support, incapacity benefit and tax credits that " - "reg. 8 also lists." + "are not modelled. Reg. 8 is read from the own-right inputs for " + "Universal Credit, Employment and Support Allowance, Jobseeker's " + "Allowance and Income Support; the incapacity benefit and tax credits " + "it also lists are not." ) definition_period = YEAR reference = [ @@ -67,5 +67,7 @@ def formula(person, period, parameters): | in_extension_period | before_terminal_date ) + # Reg. 8. & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py new file mode 100644 index 0000000000..25b20f8df6 --- /dev/null +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class receives_income_support_in_own_right(Variable): + value_type = bool + entity = Person + label = "Receives Income Support in own right" + documentation = ( + "Whether this person receives Income Support on a claim of their own. " + "It ends qualifying young person status for Pension Credit (SPC Regs " + "2002 reg. 4A(5)), Child Benefit (Child Benefit (General) Regulations " + "2006 reg. 8(2)(a)), with it the Housing Benefit and legacy young " + "person, and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)). Universal " + "Credit's list (UC Regs 2013 reg. 5(5)) does not include it. By " + "default it is the person's own reported Income Support " + "(income_support_reported) while Income Support is in payment; set it " + "directly otherwise. It does not read the benefit unit's calculated " + "award, which belongs to the claimant and depends on who the family's " + "young persons are." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ) + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + return active & (person("income_support_reported", period) > 0) From 086b78ba8ef23da9dd69f72453dc1dd95473b3bf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:31:02 -0400 Subject: [PATCH 005/168] Update the Housing Benefit childcare case for a disabled 16-year-old school leaver A disabled 16-year-old outside education is now a young person until the 31 August after their 16th birthday (Child Benefit reg. 4; HB reg 19(1)), so HB reg 28(6) counts them. Keep the original point, that the disability age extension does not itself create young-person status, as a second case with the reg. 4 period ended. Co-Authored-By: Claude Opus 5.5 --- ...t_applicable_income_childcare_element.yaml | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml index 411dd36183..dfa142a349 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml @@ -219,7 +219,33 @@ num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 -- name: Disabled 16-year-old outside education and training is not a relevant young person +- name: Disabled 16-year-old outside education and training is a relevant young person until the 31 August after their 16th birthday + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9_000 + young_person: + age: 16 + is_disabled_for_benefits: true + is_in_non_advanced_education: false + is_in_approved_training: false + benunits: + benunit: + members: [claimant, young_person] + output: + # A 16-year-old who has left education is a young person until that 31 + # August (Child Benefit (General) Regulations 2006 reg. 4; HB reg 19(1)), + # in any year that starts before it. HB reg 28(6) then counts a disabled + # one: min(9,000, one-child cap * 52). + is_child_or_young_person_for_legacy_benefits: [false, true] + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9_000 + +- name: Disabled 16-year-old outside education and training is not a relevant young person after that 31 August period: 2025 input: people: @@ -232,11 +258,13 @@ is_disabled_for_benefits: true is_in_non_advanced_education: false is_in_approved_training: false + is_before_first_september_following_16th_birthday: false benunits: benunit: members: [claimant, young_person] output: # HB reg 28(6) requires a child of the family, including a qualifying # young person; the disability age extension does not create that status. + is_child_or_young_person_for_legacy_benefits: [false, false] num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 From bb6f48795eabc56d5d8f022c40bf5e54177f3902 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 21:07:01 -0400 Subject: [PATCH 006/168] Keep the Income Support property's outside member out of the Child Benefit reg. 4 period test_excluded_member_never_changes_is_eligibility adds a member aged 16 or over who is not in education, on the premise that they are not a qualifying young person. With reg. 4 a 16-year-old who has left education is one until the 31 August after their 16th birthday, so the generator now puts the member past that date, as #2279 does for the same reason in two other property tests. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_support_eligibility_properties.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 2ad7e658bb..20db7f7554 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -206,8 +206,10 @@ def families(draw): def excluded_members(draw): """An adult who is neither claimant, partner nor in the family. - They are not in education, so a 16 to 19 year old is not a qualifying - young person. Most are primed with what barred or opened the claim when + They are not in education and past the 31 August following their 16th + birthday (Child Benefit (General) Regulations 2006 reg. 4), so a 16 to 19 + year old is not a qualifying young person. Most are primed with what + barred or opened the claim when every member counted: over state pension age, income-related ESA, an Income Support report, or caring. Some are primed with what the s.124(1)(g) proxy reads for a couple: over the qualifying age, with @@ -216,6 +218,7 @@ def excluded_members(draw): adult = { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, } primed = draw( st.sampled_from(["random", "elderly", "esa", "award", "carer", "pension"]) From 1954ebe02ffa005b5c3cabd065ba202eabeb9c55 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 21:38:20 -0400 Subject: [PATCH 007/168] Vary the age the course began in the Child Tax Credit leaver grid A mutation that opened the leaver routes to a student who fails the entry condition (reg. 5(3A)) passed every test: the grid fixed the entry age at 17. The grid now also draws a course begun at 19, with the date fact set directly, and asserts that such a student never qualifies. Co-Authored-By: Claude Opus 5.5 --- ..._credit_school_leaver_routes_properties.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index fde32f0087..8442535e3d 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -20,13 +20,15 @@ a year exactly when they are 16 to 19 on 6 October and some of the year, from 6 April, falls on or before the last day they are a child or in the reg. 4 period. For a 16th birthday on 31 August that day is 30 August. -2. Over every combination of age, education, training, own-right benefits, - the two leaver facts and hours: the status is the education condition, or - a leaver fact for a person who has left and is not in remunerative work, - within ages 16 to 19 and without own-right benefits. So remunerative work - ends both leaver routes and neither a student's status, the 20 weeks end - at 18, and with the leaver facts off the status is the education - condition's alone, as before. +2. Over every combination of age, education, training, apprenticeship, + own-right benefits, the age the course began, the two leaver facts and + hours: the status is the education condition, or a leaver fact for a + person who has left and is not in remunerative work, within ages 16 to 19 + and without own-right benefits. So remunerative work ends both leaver + routes and neither a student's status, the 20 weeks end at 18, a leaver + fact never reaches a student whose course began at 19, and with the + leaver facts off the status is the education condition's alone, as + before. 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. @@ -199,15 +201,19 @@ def qualifying_young_person(p): is_apprentice=apprentice, receives_benefits_in_own_right=own_right, weekly_hours=hours, + age_started_or_accepted_current_education_or_training=entry_age, **{ROUTE: route, EXTENSION: extension}, ) - for age, education, training, apprentice, own_right, hours in product( + for age, education, training, apprentice, own_right, hours, entry_age in product( range(14, 22), EDUCATIONS, (False, True), (False, True), (False, True), (0, 23, 24), + # A course begun at 19 fails reg. 5(3A). Such a student has not left + # education, so neither leaver fact reaches them. + (17, 1000), ) for route, extension in product((False, True), repeat=2) ] @@ -227,6 +233,17 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) assert not status[(age < 16) | (age >= 20)].any() assert not status[own_right].any() + # A student whose course began at 19 does not qualify, whatever the + # leaver facts say. + late_student = np.array( + [ + receiving_education_or_training(p) + and p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + for p in GRID + ] + ) + assert late_student.any() and not status[late_student].any() # Remunerative work ends a leaver's status and not a student's. assert not status[~student & (hours >= 24)].any() assert status[student & (hours >= 24)].any() From 3876f055c3ebb4829b09893861eb9bd6df84732b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 21:50:20 -0400 Subject: [PATCH 008/168] Pin the 1 September birthday in the terminal-date YAML cases A mutation that read a 19th birthday on 1 September as its own "1 September following" was caught by the calendar property test only. Case 11 pins the edge in YAML too. Co-Authored-By: Claude Opus 5.5 --- ...qualifying_young_person_terminal_date.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml index 66991dfb56..7cb647bcc5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -237,3 +237,29 @@ is_before_first_september_following_19th_birthday: [false, true] is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 11, born 1 September 2006, the 1 September following the 19th birthday is in 2026, not the birthday itself. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060901 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The 19th birthday is 1 September 2025; the 1 September that follows it + # is in 2026, inside the year. + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] From 061e087ac3a25bff38d37ad8ae8dd81a8731c412 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:44:29 -0400 Subject: [PATCH 009/168] Bar remunerative work only for a young person who has ceased full-time education Review round 1 of #2301: the formula took failing the non-advanced education test as having left education, so a 16-year-old in full-time advanced education working 24 hours a week lost the reg. 4 period. Reg. 5(4)(a) bars work only for a person "having ceased to receive full-time education or approved training", and full-time education at a school or college counts at any level (reg. 5(5)). Reg. 4(2) lifts the first condition only. The 20 weeks of reg. 5(3)(b) are for a person who has ceased. The property oracle had the same conflation and is fixed with it; the grid now draws a course begun at 19, as its comment says. YAML Cases 15 and 16 pin the advanced student. Co-Authored-By: Claude Opus 5.5 --- ...child_tax_credit_school_leaver_routes.yaml | 46 +++++++++++++ ..._credit_school_leaver_routes_properties.py | 65 ++++++++++++++----- ...fying_young_person_for_child_tax_credit.py | 38 +++++++---- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index fe113d9ad4..a77dc789c2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -307,3 +307,49 @@ is_qualifying_young_person_for_child_tax_credit: true is_child_or_qualifying_young_person_for_child_tax_credit: false CTC_child_element: 0 + +- name: Case 15, a 16-year-old in full-time advanced education working 24 hours a week is a qualifying young person (reg. 4(2), 5(4)(a), 5(5)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 16 + date_of_birth: 20080101 + current_education: TERTIARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # Advanced education fails the first condition, which reg. 4(2) lifts. + # It is still full-time education (reg. 5(5)), so the student has not + # "ceased" it and the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 16, the same advanced student aged 17, outside the reg. 4 period, is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + date_of_birth: 20070101 + current_education: TERTIARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 8442535e3d..18d63930ee 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -9,9 +9,12 @@ on 31 August gets no such period. - Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for work or training satisfy the first condition. -- Reg. 5(4), the second condition, always applies: a person who has left - education or training must not be in remunerative work (24 hours a week or - more, reg. 2(1)), and no one may be receiving a benefit in their own right. +- Reg. 5(4), the second condition, always applies: a person who has ceased + full-time education or approved training must not be in remunerative work + (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit + in their own right. Full-time education at any level counts (reg. 5(5)), so + an advanced student has not ceased; an apprentice is treated as having + ceased. Invariants: @@ -22,13 +25,15 @@ reg. 4 period. For a 16th birthday on 31 August that day is 30 August. 2. Over every combination of age, education, training, apprenticeship, own-right benefits, the age the course began, the two leaver facts and - hours: the status is the education condition, or a leaver fact for a - person who has left and is not in remunerative work, within ages 16 to 19 - and without own-right benefits. So remunerative work ends both leaver - routes and neither a student's status, the 20 weeks end at 18, a leaver - fact never reaches a student whose course began at 19, and with the - leaver facts off the status is the education condition's alone, as - before. + hours: the status is the education condition; or the reg. 4 period, + unless the person has ceased education or training and is in remunerative + work; or the 20 weeks for a person under 18 who has ceased and is not in + remunerative work; within ages 16 to 19 and without own-right benefits. + So remunerative work ends both routes for a person who has ceased and + never a student's status, an advanced student keeps the reg. 4 period + whatever their hours, the 20 weeks end at 18 and never reach a student + whose course began at 19, and with the leaver facts off the status is the + education condition's alone, as before. 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. @@ -173,6 +178,15 @@ def receiving_education_or_training(p): ) or p["is_in_approved_training"] +def has_ceased_education_or_training(p): + # Reg. 5(4)(a), (5): full-time education at any level, not received by + # virtue of employment, or approved training, has not ceased. + in_full_time_education = ( + p["current_education"] != "NOT_IN_EDUCATION" and not p["is_apprentice"] + ) + return not (in_full_time_education or p["is_in_approved_training"]) + + def first_condition_through_education(p): # Reg. 5(3A): begun before 19. begun_before_19 = ( @@ -187,10 +201,14 @@ def qualifying_young_person(p): return False if first_condition_through_education(p): return True - left = not receiving_education_or_training(p) - leaver_fact = p[ROUTE] or (p["age"] < 18 and p[EXTENSION]) + ceased = has_ceased_education_or_training(p) # Reg. 2(1): remunerative work is 24 hours a week or more. - return left and leaver_fact and p["weekly_hours"] < 24 + working = p["weekly_hours"] >= 24 + # Reg. 4(2): the period needs only the second condition. + if p[ROUTE] and not (ceased and working): + return True + # Reg. 5(3)(b): under 18, having ceased, and reg. 5(4)(a). + return p["age"] < 18 and p[EXTENSION] and ceased and not working GRID = [ @@ -213,7 +231,7 @@ def qualifying_young_person(p): (0, 23, 24), # A course begun at 19 fails reg. 5(3A). Such a student has not left # education, so neither leaver fact reaches them. - (17, 1000), + (17, 19), ) for route, extension in product((False, True), repeat=2) ] @@ -230,23 +248,34 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): age = np.array([p["age"] for p in GRID]) hours = np.array([p["weekly_hours"] for p in GRID]) student = np.array([receiving_education_or_training(p) for p in GRID]) + ceased = np.array([has_ceased_education_or_training(p) for p in GRID]) + route = np.array([p[ROUTE] for p in GRID]) own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) assert not status[(age < 16) | (age >= 20)].any() assert not status[own_right].any() - # A student whose course began at 19 does not qualify, whatever the - # leaver facts say. + # A student whose course began at 19 does not qualify through the 20 + # weeks, which need them to have ceased. (The grid sets the reg. 4 fact + # directly; from a date of birth it is never true at 19.) late_student = np.array( [ receiving_education_or_training(p) and p["age"] >= 19 and p["age_started_or_accepted_current_education_or_training"] >= 19 + and not p[ROUTE] for p in GRID ] ) assert late_student.any() and not status[late_student].any() - # Remunerative work ends a leaver's status and not a student's. - assert not status[~student & (hours >= 24)].any() + # Remunerative work ends the status of a person who has ceased education + # or training, and not a student's. + assert not status[ceased & (hours >= 24)].any() assert status[student & (hours >= 24)].any() + # An advanced student in the reg. 4 period qualifies whatever their hours. + advanced = np.array([p["current_education"] == "TERTIARY" for p in GRID]) + in_age = (age >= 16) & (age < 20) & ~own_right + advanced_in_route = advanced & ~ceased & route & in_age + assert advanced_in_route.any() and status[advanced_in_route].all() + assert (advanced_in_route & (hours >= 24)).any() # The 20 weeks alone do not reach an 18-year-old. alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) assert not status[alone & ~student & (age >= 18)].any() diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 3fa5ac8721..2905c084d8 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) class is_qualifying_young_person_for_child_tax_credit(Variable): @@ -18,9 +21,12 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "Universal Credit gives that day and Child Benefit two: the year " "counts all the same, as the one in which the person is a child " "until 30 August, and not the year after. The second condition " - "(reg. 5(4)) always applies: a young person who has left education or " - "training must not be in remunerative work, and no one may be " - "receiving a benefit in their own right. The own-right benefits " + "(reg. 5(4)) always applies: a young person who has ceased full-time " + "education or approved training must not be in remunerative work, and " + "no one may be receiving a benefit in their own right. Full-time " + "education at any level counts (reg. 5(5)), so an advanced student " + "has not ceased it; an apprentice's education, received by virtue of " + "employment, is treated as having ceased. The own-right benefits " "input covers Universal Credit, Employment and Support Allowance and " "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " "Income Support and the income-related and income-based allowances. " @@ -52,22 +58,28 @@ def formula(person, period, parameters): in_extension_period = ( person("age", period) < p.extension_period_age_limit ) & person("is_in_child_tax_credit_extension_period", period) - # Reg. 5(4)(a): having left, not in remunerative work. - left_and_not_working = ~receiving_education_or_training & ~person( - "is_in_remunerative_work_for_child_tax_credit", period + # Reg. 5(4)(a) bars remunerative work only for a person "having + # ceased to receive full-time education or approved training". + # Full-time education at a school or college counts at any level + # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does + # not mean having ceased it. + in_full_time_education = ( + person("current_education", period) != EducationType.NOT_IN_EDUCATION + ) & ~person("is_apprentice", period) + ceased = ~in_full_time_education & ~person("is_in_approved_training", period) + working = person("is_in_remunerative_work_for_child_tax_credit", period) + # Reg. 4(2): the period needs no first condition, only the second. + in_regulation_4_period = before_31_august_following_16th_birthday & ~( + ceased & working ) + # Reg. 5(3)(b) is for a person who has ceased. + in_20_weeks = in_extension_period & ceased & ~working return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & ( - in_education_or_training - | ( - (before_31_august_following_16th_birthday | in_extension_period) - & left_and_not_working - ) - ) + & (in_education_or_training | in_regulation_4_period | in_20_weeks) # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) ) From ba3ddf83843ce79dfd1e5d241681371d77a948a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:48:06 -0400 Subject: [PATCH 010/168] Default the terminal-date variables to true, the formula's value under 20 Review round 1 of #2300: setting the 19th-birthday fact for one person gave everyone else in the situation false (the default), so another 19-year-old student silently lost the Pension Credit and Universal Credit status. The formula gives true for everyone under 20 on 6 October, which is everyone the age limit lets be a qualifying young person, so true is now the default for the fact and for the two terminal-date variables that read it. A partial setting then leaves everyone else where the formula puts them. Tested from age alone for ages 0 to 25 and all months, and in a mixed household (property test and YAML Case 12). Co-Authored-By: Claude Opus 5.5 --- ...qualifying_young_person_terminal_date.yaml | 29 +++++++ ...g_young_person_terminal_date_properties.py | 79 +++++++++++++++++++ ...minal_date_condition_for_pension_credit.py | 6 +- ...t_qualifying_young_person_terminal_date.py | 5 +- ...first_september_following_19th_birthday.py | 10 ++- 5 files changed, 125 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml index 7cb647bcc5..74db3f7cbf 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -263,3 +263,32 @@ # The age limit, read on 6 October, excludes them. is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 12, setting the date fact for one 19-year-old leaves another's status as the formula gives it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + first: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + second: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, first, second] + output: + # The second takes the default, true, which is the formula's value for + # anyone under 20 on 6 October. + is_before_first_september_following_19th_birthday: [true, false, true] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_qualifying_young_person_for_pension_credit: [false, false, true] diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py index f0ef7d86da..a91cb639fa 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -20,6 +20,10 @@ without a terminal date, so nothing changes below 19 or for a 19-year-old before the date; switching the fact on never removes the status; and it matters only for a 19-year-old in education or training. +5. The formula gives true for everyone under 20 on 6 October, whether the + date of birth or only the age is given, and true is each terminal-date + variable's default. So a situation that sets one of them for some people + leaves everyone else's status as it would be with nothing set. """ from datetime import date, timedelta @@ -230,3 +234,78 @@ def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): sim = simulate(people) np.testing.assert_array_equal(sim.calculate(UC, YEAR), [u for _, u in rows]) np.testing.assert_array_equal(sim.calculate(PC, YEAR), [n for n, _ in rows]) + + +DEFAULTED = ( + NINETEEN, + UC_TERMINAL, + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", +) + + +def test_formula_is_true_under_20_and_is_each_default(): + # Invariant 5, from age and months alone: every age to 25 and every + # month since the last birthday, in three years. + rows = list(product(range(26), range(12))) + for year in (2022, 2026, 2030): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, months) in enumerate(rows): + situation["people"][f"p{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + under_20 = np.array([age < 20 for age, _ in rows]) + for variable in DEFAULTED: + values = sim.calculate(variable, year) + assert values[under_20].all(), (variable, year) + assert sim.tax_benefit_system.variables[variable].default_value is True + # Not vacuous: the fact is false for some people of 20 or more. + assert not sim.calculate(NINETEEN, year)[~under_20].all() + + +def test_setting_one_person_leaves_the_others_status(): + # Invariant 5 in a household: two 19-year-olds at school, course begun + # at 17. Each terminal-date variable is set false for the first only; + # the second stays a qualifying young person for both programmes. + for variable in DEFAULTED: + people = { + "parent": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + uc = sim.calculate(UC, YEAR) + pc = sim.calculate(PC, YEAR) + # The second student is a qualifying young person for both. + assert uc[2] and pc[2], variable + # The first loses the status the variable governs. + if variable == UC_TERMINAL: + assert not uc[1] and pc[1] + elif variable == NINETEEN: + assert not uc[1] and not pc[1] + else: + assert uc[1] and not pc[1] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py index 6427f201db..374b0c0220 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -12,9 +12,13 @@ class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( "up to, but not including, the 1 September following their 19th " "birthday (SPC Regs 2002 reg. 4A(1)(b)). Under 19 that date is always " "ahead; at 19 it is read from " - "is_before_first_september_following_19th_birthday." + "is_before_first_september_following_19th_birthday. Its default is " + "true, the value the formula gives everyone under 20 on 6 October, so " + "setting it for some people leaves everyone else where the formula " + "puts them." ) definition_period = YEAR + default_value = True reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index 97bd3928c2..1b5e54959e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -12,9 +12,12 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) "of birth (is_before_first_september_following_19th_birthday), so a " "year counts if it starts before that date. Datasets and situations " "can set it directly: set it to false for a 19-year-old whose 1 " - "September has passed." + "September has passed. Its default is true, the value the date fact " + "gives everyone under 20 on 6 October, so setting it for some people " + "leaves everyone else where the date fact puts them." ) definition_period = YEAR + default_value = True reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py index 379d89c660..9b8f708724 100644 --- a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -21,10 +21,16 @@ class is_before_first_september_following_19th_birthday(Variable): "for the year, as is_before_first_september_following_16th_birthday " "does. Everyone aged 19 on 6 October is before the date. So is a " "20-year-old born from 1 September to 6 October, whom the qualifying " - "young person age limit, read on 6 October, already excludes. Set " - "this to false for a year that the period does not reach." + "young person age limit, read on 6 October, already excludes. So the " + "formula gives true for everyone under 20 on 6 October, which is " + "everyone that limit lets be a qualifying young person, and the " + "default is true: a situation that sets this for some people gives " + "everyone else true, the value the formula gives them. Set it to " + "false for a person whose 1 September has passed, or set their " + "date_of_birth." ) definition_period = YEAR + default_value = True reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", From 39895f731fbea966e617ab5d85582eb50273d124 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:02:15 -0400 Subject: [PATCH 011/168] Treat Income Support as not received once a reform removes it Review round 1 of #2302: receives_income_support_in_own_right stayed true under a reform that neutralizes income_support, so a young person's report still excluded them from the Pension Credit, Child Benefit and CTC qualifying young person status. It now also needs Income Support not to be neutralized, as is_on_income_support already does for another member's own claim. New test: the same student is excluded with Income Support and qualifies for all three once a reform removes it. Co-Authored-By: Claude Opus 5.5 --- ..._young_person_income_support_properties.py | 23 ++++++++++++++++++- .../receives_income_support_in_own_right.py | 12 +++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py index bd5ab7e22e..fef49a5512 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -19,7 +19,8 @@ 4. Without it, Pension Credit and Universal Credit agree on every combination; with it, Pension Credit is the narrower. 5. By default the input is the person's own reported Income Support while - Income Support is in payment (to 2025-26), and false after. + Income Support is in payment (to 2025-26) and no reform removes it, and + false otherwise. """ from itertools import product @@ -126,3 +127,23 @@ def test_default_follows_reported_income_support_while_it_is_paid(rows, year): sim = simulate(people, year) expected = [year <= 2025 and amount > 0 for _, amount in rows] np.testing.assert_array_equal(sim.calculate(INCOME_SUPPORT, year), expected) + + +def test_no_receipt_once_a_reform_removes_income_support(): + # Invariant 5 under a reform: a 17-year-old at school reporting £3,000 of + # Income Support of their own is excluded while Income Support exists, + # and is a qualifying young person again once a reform neutralizes it. + student = { + "age": 17, + "current_education": "UPPER_SECONDARY", + "income_support_reported": 3_000, + } + kept = simulate([student]) + assert kept.calculate(INCOME_SUPPORT, YEAR).all() + removed = simulate([student]) + removed.tax_benefit_system.neutralize_variable("income_support") + removed.delete_arrays("income_support") + assert not removed.calculate(INCOME_SUPPORT, YEAR).any() + for programme in EXCLUDING: + assert not status(kept, programme).any(), programme + assert status(removed, programme).all(), programme diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py index 25b20f8df6..648ba7221c 100644 --- a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -13,8 +13,10 @@ class receives_income_support_in_own_right(Variable): "person, and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)). Universal " "Credit's list (UC Regs 2013 reg. 5(5)) does not include it. By " "default it is the person's own reported Income Support " - "(income_support_reported) while Income Support is in payment; set it " - "directly otherwise. It does not read the benefit unit's calculated " + "(income_support_reported) while Income Support is in payment and no " + "reform removes it (neutralizes income_support), as is_on_income_support " + "reads another member's own claim; set it directly otherwise. It does " + "not read the benefit unit's calculated " "award, which belongs to the claimant and depends on who the family's " "young persons are." ) @@ -27,4 +29,8 @@ class receives_income_support_in_own_right(Variable): def formula(person, period, parameters): active = parameters(period).gov.dwp.income_support.active - return active & (person("income_support_reported", period) > 0) + # A reform that removes Income Support leaves no award to receive. + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized + return active & (not removed) & (person("income_support_reported", period) > 0) From 6f6e7debdd8fc6f49a880d09506e11c47f4546a3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:05:15 -0400 Subject: [PATCH 012/168] Keep the YAML cases right under the true default Case 3's parent, not set, now takes the default (true): harmless, since every reader is behind the qualifying young person age limit. The older child_qualifying_young_person.yaml case left its after-terminal-date person unset and relied on the old false default; it now sets the input explicitly. The docstrings now say the default is the formula's value under 20 and that older people are excluded before it is read. Co-Authored-By: Claude Opus 5.5 --- .../finance/benefit/child_qualifying_young_person.yaml | 3 +++ .../benefit/qualifying_young_person_terminal_date.yaml | 4 +++- ...g_person_terminal_date_condition_for_pension_credit.py | 4 ++-- ...versal_credit_qualifying_young_person_terminal_date.py | 3 ++- .../is_before_first_september_following_19th_birthday.py | 8 +++++--- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml index aaf0b00010..4b062a0214 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml @@ -92,6 +92,9 @@ age: 19 age_started_or_accepted_current_education_or_training: 18 current_education: POST_SECONDARY + # Set explicitly: a person left unset takes the date fact's value, + # which is true for anyone under 20 on 6 October. + is_before_universal_credit_qualifying_young_person_terminal_date: false under_terminal_date_age_limit: age: 18 current_education: POST_SECONDARY diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml index 74db3f7cbf..3a3e2493f8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -80,7 +80,9 @@ benunit: members: [parent, student] output: - is_before_universal_credit_qualifying_young_person_terminal_date: [false, false] + # The parent, not set, takes the default, true: harmless, since every + # reader is behind the qualifying young person age limit. + is_before_universal_credit_qualifying_young_person_terminal_date: [true, false] is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_pension_credit: [false, false] uc_individual_child_element: [0, 0] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py index 374b0c0220..0e616c8a64 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -14,8 +14,8 @@ class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( "ahead; at 19 it is read from " "is_before_first_september_following_19th_birthday. Its default is " "true, the value the formula gives everyone under 20 on 6 October, so " - "setting it for some people leaves everyone else where the formula " - "puts them." + "setting it for some people leaves everyone else under 20 where the " + "formula puts them; older people are excluded by the age limit." ) definition_period = YEAR default_value = True diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index 1b5e54959e..de58b11b88 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -14,7 +14,8 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) "can set it directly: set it to false for a 19-year-old whose 1 " "September has passed. Its default is true, the value the date fact " "gives everyone under 20 on 6 October, so setting it for some people " - "leaves everyone else where the date fact puts them." + "leaves everyone else under 20 where the date fact puts them; older " + "people are excluded by the age limit before it is read." ) definition_period = YEAR default_value = True diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py index 9b8f708724..6d30c8ad83 100644 --- a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -25,9 +25,11 @@ class is_before_first_september_following_19th_birthday(Variable): "formula gives true for everyone under 20 on 6 October, which is " "everyone that limit lets be a qualifying young person, and the " "default is true: a situation that sets this for some people gives " - "everyone else true, the value the formula gives them. Set it to " - "false for a person whose 1 September has passed, or set their " - "date_of_birth." + "everyone else true. That is the formula's value for anyone under 20; " + "for someone older it is not, but every reader of this variable is " + "behind the qualifying young person age limit, which excludes them " + "first. Set it to false for a person whose 1 September has passed, or " + "set their date_of_birth." ) definition_period = YEAR default_value = True From e626d3f6ff72ccda9e17508cc0a5cfdac6493ca6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:21:36 -0400 Subject: [PATCH 013/168] Give the working school-leaver in Case 11 earnings, and check the parent's means tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 of #2299: a young person who qualifies through reg. 7 and works 24 hours stays a young person, so their earnings must not enter the parent's Housing Benefit, Income Support or Council Tax Reduction income. #2387, merged in here, makes that so; Case 11 now has £20,000 of earnings and asserts the three applicable incomes are nil. Co-Authored-By: Claude Opus 5.5 --- .../child_benefit_school_leaver_routes.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index 8a32748910..75a9d25169 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -256,10 +256,16 @@ current_education: NOT_IN_EDUCATION is_before_child_benefit_terminal_date: true weekly_hours: 24 + employment_income: 20_000 is_claimant_or_partner: false benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false + households: + household: + members: [parent, school_leaver] + country: ENGLAND output: is_qualifying_young_person_for_child_benefit: [false, true] is_child_or_qualifying_young_person_for_child_benefit: [false, true] @@ -268,6 +274,12 @@ # Still a young person for Housing Benefit and the other legacy schemes. is_child_or_young_person_for_legacy_benefits: [false, true] is_lone_parent: true + # Their £20,000 of earnings is not the parent's (HB Regs 2006 reg 25(3), + # IS Regs 1987 reg 23(2), CTR applicant-and-partner rules; #2387). The + # parent has no income of their own. + housing_benefit_applicable_income: 0 + income_support_applicable_income: 0 + council_tax_reduction_applicable_income: 0 - name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. period: 2026 From 3737d29e9c4d7b2aff5217c099360d61b85dd67b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:28:38 -0400 Subject: [PATCH 014/168] Count an apprentice still on a course as not having ceased full-time education MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 of #2301: the formula took apprenticeship as ceasing education, so a 16-year-old apprentice still on a college course and working 24 hours or more lost the reg. 4 period. Education received by virtue of employment is left out of the first condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts; reg. 5(5) counts a course at a school or college without that exclusion, so the apprentice has not "ceased to receive full-time education" and the reg. 5(4)(a) bar does not apply. The property oracle had the same assumption and is fixed with it. YAML Case 5 now expects the apprentice on a course to qualify (£3,455); new Case 17 keeps an apprentice who has left every course out. Co-Authored-By: Claude Opus 5.5 --- ...child_tax_credit_school_leaver_routes.yaml | 36 +++++++++++++++---- ..._credit_school_leaver_routes_properties.py | 12 +++---- ...fying_young_person_for_child_tax_credit.py | 10 ++++-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index a77dc789c2..0db7aece61 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -98,7 +98,7 @@ is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 -- name: Case 5, a 16-year-old apprentice working 30 hours a week is not a qualifying young person. +- name: Case 5, a 16-year-old apprentice still on a college course and working 30 hours a week is a qualifying young person. period: 2024 absolute_error_margin: 0.01 input: @@ -117,11 +117,12 @@ benunit: members: [parent, apprentice] output: - # Education "by virtue of his employment" is not the first condition - # (reg. 5(3)(a)(ii)), so the apprentice has left it and is in - # remunerative work. - is_qualifying_young_person_for_child_tax_credit: [false, false] - CTC_child_element: 0 + # Education "by virtue of his employment" is left out of the first + # condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts. The apprentice has + # not "ceased to receive full-time education" (reg. 5(4)(a), 5(5)), so + # the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 - name: Case 6, born 15 September 2007, the period runs to 31 August 2024. period: 2024 @@ -353,3 +354,26 @@ output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 + +- name: Case 17, a 16-year-old apprentice no longer on any course and working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: NOT_IN_EDUCATION + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + # Ceased full-time education and in remunerative work (reg. 5(4)(a)). + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 18d63930ee..0381c3728a 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -13,8 +13,8 @@ full-time education or approved training must not be in remunerative work (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit in their own right. Full-time education at any level counts (reg. 5(5)), so - an advanced student has not ceased; an apprentice is treated as having - ceased. + an advanced student has not ceased, nor has an apprentice still on a + course: the employment exclusion belongs to the first condition. Invariants: @@ -179,11 +179,9 @@ def receiving_education_or_training(p): def has_ceased_education_or_training(p): - # Reg. 5(4)(a), (5): full-time education at any level, not received by - # virtue of employment, or approved training, has not ceased. - in_full_time_education = ( - p["current_education"] != "NOT_IN_EDUCATION" and not p["is_apprentice"] - ) + # Reg. 5(4)(a), (5): full-time education at any level, an apprentice's + # course included, or approved training, has not ceased. + in_full_time_education = p["current_education"] != "NOT_IN_EDUCATION" return not (in_full_time_education or p["is_in_approved_training"]) diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2905c084d8..e23714dd8b 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -25,8 +25,9 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "education or approved training must not be in remunerative work, and " "no one may be receiving a benefit in their own right. Full-time " "education at any level counts (reg. 5(5)), so an advanced student " - "has not ceased it; an apprentice's education, received by virtue of " - "employment, is treated as having ceased. The own-right benefits " + "has not ceased it, nor has an apprentice still on a course; the " + "employment exclusion is part of the first condition only. The " + "own-right benefits " "input covers Universal Credit, Employment and Support Allowance and " "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " "Income Support and the income-related and income-based allowances. " @@ -63,9 +64,12 @@ def formula(person, period, parameters): # Full-time education at a school or college counts at any level # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does # not mean having ceased it. + # Education received by virtue of employment is left out of the first + # condition (reg. 5(3)(a)), which reg. 4(2) lifts, and not out of reg. + # 5(5): an apprentice still on a course has not ceased it. in_full_time_education = ( person("current_education", period) != EducationType.NOT_IN_EDUCATION - ) & ~person("is_apprentice", period) + ) ceased = ~in_full_time_education & ~person("is_in_approved_training", period) working = person("is_in_remunerative_work_for_child_tax_credit", period) # Reg. 4(2): the period needs no first condition, only the second. From 2932030f63aca0f8157940f96672c9d21e478007 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:31:05 -0400 Subject: [PATCH 015/168] Document and test crossed terminal-date overrides Review round 2 of #2300: a programme-level terminal-date variable set for one person gives everyone else its default (true), not their date fact, so a student whose date fact was set false became eligible again. No default can keep the formula for people left unset, so the two programme variables now say so and tell users setting both to set the programme variable for everyone whose status matters. Invariant 5 is qualified, and a crossed test shows both the hazard and the documented use, for each programme. Co-Authored-By: Claude Opus 5.5 --- ...g_young_person_terminal_date_properties.py | 54 ++++++++++++++++++- ...minal_date_condition_for_pension_credit.py | 7 ++- ...t_qualifying_young_person_terminal_date.py | 7 ++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py index a91cb639fa..8d4975de36 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -23,7 +23,11 @@ 5. The formula gives true for everyone under 20 on 6 October, whether the date of birth or only the age is given, and true is each terminal-date variable's default. So a situation that sets one of them for some people - leaves everyone else's status as it would be with nothing set. + leaves everyone else's status as it would be with nothing set. That holds + for one variable at a time: a programme variable set for some people gives + everyone else its default, not their date fact, so a situation that also + sets the date fact must set the programme variable for every person whose + status matters (the crossed test). """ from datetime import date, timedelta @@ -309,3 +313,51 @@ def test_setting_one_person_leaves_the_others_status(): assert not uc[1] and not pc[1] else: assert uc[1] and not pc[1] + + +def test_crossed_settings_need_the_programme_variable_for_everyone(): + # Invariant 5's qualification. Two 19-year-olds at school: the first has + # the date fact set false, the second the programme variable set false. + # Set for the second alone, the programme variable gives the first its + # default (true), so the first becomes eligible again for that + # programme; set for both, both are excluded, as the documentation says. + def household(programme_variable, both): + people = { + "pensioner": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][NINETEEN] = {YEAR: False} + people["second"][programme_variable] = {YEAR: False} + if both: + people["first"][programme_variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + for variable, status in ( + (UC_TERMINAL, UC), + ( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + PC, + ), + ): + partial = household(variable, both=False).calculate(status, YEAR) + # The documented hazard: the first student takes the default. + assert partial[1] and not partial[2], variable + full = household(variable, both=True).calculate(status, YEAR) + assert not full[1] and not full[2], variable diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py index 0e616c8a64..2a65f21606 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -15,7 +15,12 @@ class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( "is_before_first_september_following_19th_birthday. Its default is " "true, the value the formula gives everyone under 20 on 6 October, so " "setting it for some people leaves everyone else under 20 where the " - "formula puts them; older people are excluded by the age limit." + "formula puts them; older people are excluded by the age limit. " + "Everyone else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." ) definition_period = YEAR default_value = True diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index de58b11b88..0c79bf170d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -15,7 +15,12 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) "September has passed. Its default is true, the value the date fact " "gives everyone under 20 on 6 October, so setting it for some people " "leaves everyone else under 20 where the date fact puts them; older " - "people are excluded by the age limit before it is read." + "people are excluded by the age limit before it is read. Everyone " + "else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." ) definition_period = YEAR default_value = True From 2f0f15a520d7fa024fa95733636ebacaba29def0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:41:04 -0400 Subject: [PATCH 016/168] Let a school-leaver's DLA and AFIP lift the Housing Benefit cap After main's benefit cap work, two cases asserted that a 16-year-old who has left education is not a Housing Benefit young person, so their DLA or armed forces independence payment does not lift the cap, and the exemption's docstring named this PR's issue (#2287) as the gap. Under reg. 4 they are a Child Benefit qualifying young person until the 31 August after their 16th birthday, and so a Housing Benefit young person (HB Regs 2006 reg. 19), whose benefit lifts the cap (reg. 75F(1)). Both expectations and the docstring now say so; Universal Credit's exemption is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../benefit_cap_specified_benefits.yaml | 21 ++++++++++++------- ...it_benefit_cap_exempt_specified_benefit.py | 17 +++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 082ac4d353..e8ba38e8ea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -821,13 +821,15 @@ members: [parent, sixteen] region: NORTH_EAST output: - is_child_or_young_person_for_legacy_benefits: [false, false] + # HB reg 19(1) requires a Child Benefit "qualifying young person". A + # 16-year-old who has left education is one until the 31 August after + # their 16th birthday (Child Benefit (General) Regulations 2006 reg. 4), + # so they are the family's young person for Housing Benefit too. + is_child_or_young_person_for_legacy_benefits: [false, true] # UC reg 5(1)(a): "up to, but not including, the 1st September following # their 16th birthday". Annual ages count the 16-year-old for the year. - # HB reg 19(1) requires a Child Benefit "qualifying young person"; - # this person is outside education and is not one in the model. is_uc_benefit_cap_exempt_specified_benefit: true - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 19-year-old outside education is not a young person, so their DLA does not lift the cap period: 2025 @@ -1188,7 +1190,7 @@ housing_benefit: 30_000 absolute_error_margin: 1 -- name: A 16-year-old out of education with armed forces independence payment does not lift the cap +- name: A 16-year-old out of education with armed forces independence payment lifts the Housing Benefit cap, not Universal Credit's period: 2025 input: people: @@ -1207,10 +1209,13 @@ members: [parent, sixteen] region: NORTH_EAST output: - # Not a Housing Benefit young person (not in education), and Universal - # Credit counts the payment for a claimant only (UC Regs 2013 reg 2). + # A Housing Benefit young person until the 31 August after the 16th + # birthday (Child Benefit (General) Regulations 2006 reg. 4), whose armed + # forces independence payment lifts the cap (HB Regs 2006 reg. 75F(1)). + # Universal Credit counts the payment for a claimant only (UC Regs 2013 + # reg 2). is_uc_benefit_cap_exempt_specified_benefit: false - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 16-year-old receiving benefits in their own right is not a young person, so their DLA does not lift the cap period: 2025 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 6ebc6992ed..90ad82cc9a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -60,16 +60,13 @@ class is_housing_benefit_benefit_cap_exempt_specified_benefit(Variable): "Universal Credit before the cap (universal_credit_pre_benefit_cap), " "so a Universal Credit award the cap reduces to nil still lifts the " "Housing Benefit cap. A young person is the model's Child Benefit " - "qualifying young person, which leaves out a 16-year-old in neither " - "non-advanced education nor approved training. The Housing Benefit " - "cap rate (is_housing_benefit_benefit_cap_single_claimant_rate) " - "counts such a 16-year-old for the year, so a lone parent with one " - "gets the family rate, but the 16-year-old's own disability living " - "allowance or personal independence payment does not lift the cap. " - "In law one status decides both: a school leaver remains a " - "qualifying young person until a terminal date or the end of an " - "extension period (Child Benefit (General) Regulations 2006 regs. 5 " - "and 7), which the model does not place (#2287)." + "qualifying young person (HB Regs 2006 reg. 19), which includes a " + "16-year-old who has left education, until the 31 August after their " + "16th birthday (Child Benefit (General) Regulations 2006 reg. 4), and " + "a school leaver in an extension period or before a terminal date " + "where those inputs are set (regs. 5 and 7). Such a young person's " + "own disability living allowance, personal independence payment or " + "armed forces independence payment lifts the cap." ) definition_period = YEAR reference = ( From 4772258fba9606ba05e40f14f3f57b840eef536d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:53:20 -0400 Subject: [PATCH 017/168] Drop the comments that said a school leaver is not an HB young person Three comments still said the Housing Benefit cap exceptions leave out a 16-year-old in neither non-advanced education nor approved training (#2287). Under reg. 4 the model's Child Benefit qualifying young person now counts every 16-year-old for the year, whatever their education, so the exceptions and the cap rate agree on them. Comments and documentation only; no formula changes. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/benefit_unit.py | 4 ++-- ...housing_benefit_benefit_cap_exempt_specified_benefit.py | 7 +++---- .../is_housing_benefit_benefit_cap_single_claimant_rate.py | 7 +++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 2df7693ab5..cc1503b4b6 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -43,8 +43,8 @@ def has_sixteen_year_old_dependant(benunit, period): those dates, so the benefit cap rates, which turn on responsibility for a child or young person, count such a member for the year. The Housing Benefit cap exceptions read the model's Child Benefit qualifying young - person instead, which leaves out a 16-year-old in neither non-advanced - education nor approved training (#2287). + person, which counts a 16-year-old for the year too, whatever their + education (Child Benefit (General) Regulations 2006 reg. 4). """ person = benunit.members age = person("age", period) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 90ad82cc9a..a16ebed2e0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -83,10 +83,9 @@ def formula(benunit, period, parameters): # A child under 16 or a Child Benefit qualifying young person the # claimant or partner is responsible for (HB Regs 2006 regs 2(1), 19). - # A 16-year-old in neither non-advanced education nor approved - # training is not one here, although the cap rate counts them for the - # year (has_sixteen_year_old_dependant); the model's qualifying young - # person has no school-leaver terminal date (#2287). + # A 16-year-old who has left education is one for the year (Child + # Benefit (General) Regulations 2006 reg. 4), as the cap rate counts + # them (has_sixteen_year_old_dependant). child_or_young_person = person( "is_child_or_young_person_for_legacy_benefits", period ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py index 9d5daa2c20..562729465f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py @@ -15,11 +15,10 @@ class is_housing_benefit_benefit_cap_single_claimant_rate(Variable): "the other rate even where the partner could not be a joint claimant " "for Universal Credit. A 16-year-old member counts as a young person " "for the year (reg. 19(1); Child Benefit (General) Regulations 2006 " - "regs. 5 and 7). The exceptions " + "regs. 4, 5 and 7). The exceptions " "(is_housing_benefit_benefit_cap_exempt_specified_benefit) read the " - "model's Child Benefit qualifying young person instead, which leaves " - "out a 16-year-old in neither non-advanced education nor approved " - "training (#2287)." + "model's Child Benefit qualifying young person, which counts a " + "16-year-old for the year too, whatever their education (reg. 4)." ) definition_period = YEAR reference = ( From 8a5980c997ebe3842bd8698312ba3471c2baf8d0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:52:39 -0400 Subject: [PATCH 018/168] Count a 16-year-old school leaver in the benefit cap property oracle reference_specified_benefit counted the school-leaver role as a young person for Universal Credit only, matching the old model, where the Child Benefit qualifying young person (and so Housing Benefit's young person, reg. 19) had no route for a 16-year-old who has left education. Under reg. 4 they are one for the year, so their DLA, PIP, AFIP or carer benefit lifts the Housing Benefit cap too, as the formula now gives. Neither scheme counts one receiving benefits in their own right. Co-Authored-By: Claude Opus 5.5 --- ...nefit_cap_specified_benefits_properties.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 9648177f44..81e968235f 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -30,8 +30,10 @@ qualifying young person"; HB reg 75F(1)(e) names a "child or young person". UC includes a 16-year-old school leaver (reg 5(1)(a)) who does not receive benefits in their own right (reg 5(5)) and is not looked after (reg 4(6)). -HB young persons follow Child Benefit (reg 19), so only the education-based -young-person role counts for Housing Benefit below. UC reg 83(1)(c) names +HB young persons follow Child Benefit (reg 19), which includes a 16-year-old +school leaver until the 31 August after their 16th birthday (Child Benefit +(General) Regulations 2006 reg 4), counted for the year as the model does, +unless they receive benefits in their own right (reg 8). UC reg 83(1)(c) names "a claimant" for attendance allowance, which includes armed forces independence payment (reg 2); HB reg 75F(1)(ea) also names "a young person" for it. Only UC reg 83(1)(a), (j) names the LCWRA and carer elements; only @@ -273,10 +275,10 @@ def reference_specified_benefit(members, benunit_inputs, scheme): claimants = [m for m in members if m["_role"] == "claimant"] # UC reg 83 names a "qualifying young person" (reg 5), which includes a # 16-year-old school leaver (reg 5(1)(a)); HB reg 75F names a "young - # person" (reg 19), a Child Benefit qualifying young person. The - # school-leaver role here has no Child Benefit education or terminal-date - # circumstance, so it is not one. - young_roles = ("young_person", "sixteen") if scheme == "uc" else ("young_person",) + # person" (reg 19), a Child Benefit qualifying young person, which + # includes one too (Child Benefit (General) Regulations 2006 reg 4). + # Neither counts one receiving benefits in their own right. + young_roles = ("young_person", "sixteen") young_persons = [m for m in members if m["_role"] in young_roles] children = [m for m in members if m["_role"] == "child"] # No capital, so the couple's income-related award is their reports. @@ -376,8 +378,8 @@ def reference_cap(members): # Each scheme's rate: a single claimant (UC reg 80A(2)(c)) or a claimant # who neither has a partner nor is a lone parent (HB regs 2(1), 75CA(2)), # with no child or young person. The family-rate helper counts any - # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit's - # school-leaver terminal dates, which annual ages cannot place); it does + # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit reg 4 + # and its school-leaver terminal dates, which annual ages cannot place); it does # not yet apply reg 5(5) to one receiving benefits in their own right. claimants = sum(m["_role"] == "claimant" for m in members) dependants = any( @@ -573,8 +575,8 @@ def test_each_head_alone_matches_regulations(mode): assert not mismatches, mismatches # Both outcomes occur under each scheme. assert exempt.any() and not exempt.all() - # The schemes differ: UC's elements and 16-year-old school leaver, HB's - # young person's armed forces independence payment. + # The schemes differ: UC's elements, HB's young person's armed forces + # independence payment. assert (exemptions["uc"] != exemptions["hb"]).any() # Every role meets every circumstance. assert len(units) == len(SINGLE_HEAD_ROLES) * len(CIRCUMSTANCES) From b6d8af5b556d5c64848a0d9035b39bef298eab09 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 07:59:11 -0400 Subject: [PATCH 019/168] Re-derive VAT survey coverage on the certified a133f37a cut survey_consumption_coverage was derived in #2187 on Microcosm f9d1922c. The 6cba5d71 and a133f37a cuts record about 10% more standard-rated consumption, so with 0.67 total vat ran 7.5% to 10.3% above OBR receipts in 2024-25 to 2026-27. The doc's own calculation on a133f37a gives 0.739 for the latest outturn year; it is used as 0.74 (product 0.518), inside the 0.720 to 0.756 bound from receipts and the ready reckoner. Adds Hypothesis properties for the grossing factor and for vat being statutory liability over the shipped product. Totals at the new product and the reform-regression fixture wait on runs in the hub's next reserved window. Fixes #2633 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2633.fixed.md | 1 + docs/book/assumptions/mp30k-uk-switch-plan.md | 11 ++- docs/engineering/vat-coverage.md | 79 +++++++++++-------- .../simulation/microdata_vat_coverage.yaml | 4 +- .../vat/survey_consumption_coverage.yaml | 34 ++++---- .../baseline/gov/hmrc/vat/vat_grossing.yaml | 6 +- .../tests/test_vat_grossing_legacy.py | 2 +- .../tests/test_vat_grossing_properties.py | 77 ++++++++++++++++++ 8 files changed, 154 insertions(+), 60 deletions(-) create mode 100644 changelog.d/2633.fixed.md create mode 100644 policyengine_uk/tests/test_vat_grossing_properties.py diff --git a/changelog.d/2633.fixed.md b/changelog.d/2633.fixed.md new file mode 100644 index 0000000000..1bc303aa32 --- /dev/null +++ b/changelog.d/2633.fixed.md @@ -0,0 +1 @@ +- Re-derived the VAT survey consumption coverage on the certified Microcosm UK 2024-25 cut a133f37a (0.67 to 0.74, product 0.469 to 0.518). The earlier value came from an older cut that recorded about 10% less standard-rated consumption, which put VAT 7.5% to 10.3% above OBR receipts. diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md index 374e9209f3..b7bbe44954 100644 --- a/docs/book/assumptions/mp30k-uk-switch-plan.md +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -117,12 +117,11 @@ A documented gate must pass before any environment's default changes: which dataset is loaded. One parameter is tied to the dataset, though: `gov.simulation.vat.survey_consumption_coverage` is calibrated to one Microcosm UK 2024-25 national cut only - (`microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68`; policyengine.py - 6.10.0 pins the later cut - `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`), puts 2024-25 - VAT 38% (policyengine-uk-data 1.56.16) or 44% (1.58.0) above receipts on - the Enhanced FRS 2024-25, and has to be re-derived when the dataset it - should match changes (`docs/engineering/vat-coverage.md`). + (`microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`; the earlier + `6cba5d71` cut gives the same value), puts VAT above receipts on the + Enhanced FRS 2024-25 (whose implied coverage is 0.90 to 0.97), and has to + be re-derived when the dataset it should match changes + (`docs/engineering/vat-coverage.md`). 2. While both are in use, CI runs the dataset-dependent tests on both. 3. The default is set per environment: for direct use of this package through `POLICYENGINE_UK_DEFAULT_DATASET`; for this repository's tests diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 49c467898a..b6169c2261 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -11,7 +11,7 @@ factors, so that total VAT matches receipts: theoretical liability; the rest falls on government, charities, VAT-exempt businesses and new housing. This is a published quantity and does not depend on the dataset. -- `gov.simulation.vat.survey_consumption_coverage` (0.67): the share of +- `gov.simulation.vat.survey_consumption_coverage` (0.74): the share of household VAT liability that the dataset's consumption captures. This depends on the dataset's consumption imputation and must be re-derived when the default dataset changes. @@ -22,33 +22,41 @@ to households in proportion to their own VAT-liable spending. These two factors replace `gov.simulation.microdata_vat_coverage`, a single undated 0.38 added in January 2023 against an older LCFS-based consumption -imputation, with no derivation recorded. Its product with today's datasets put -2024-25 VAT 24% above OBR receipts on Microcosm UK 2024-25 and 71% above on -Enhanced FRS 2024-25 (policyengine-uk-data 1.56.16). +imputation, with no derivation recorded. `gov.simulation.microdata_vat_coverage` is kept as a deprecated parameter so that existing reforms to it still work. Its value is now the product of the two -factors (0.469). If a reform sets it to any other value, `vat` and +factors (0.518). If a reform sets it to any other value, `vat` and `baseline_vat` use that value in place of the two factors (`policyengine_uk/utils/vat.py`); otherwise they use the two factors. New reforms should change the two factors instead. ## Which datasets the factor applies to -The survey coverage of 0.67 is calibrated to one dataset: the certified -Microcosm UK 2024-25 national release. It does not carry over to other -datasets, because their consumption imputations capture a different share of -household VAT. On Enhanced FRS 2024-25 it puts VAT 35% to 44% above receipts, -depending on the release and year (see the tables below). Re-derive the coverage, with -the calculation below, whenever the dataset it should match changes. +The survey coverage of 0.74 is calibrated to one dataset: the certified +Microcosm UK 2024-25 national release `a133f37a`. It does not carry over to +other datasets, because their consumption imputations capture a different +share of household VAT: on Enhanced FRS 2024-25 its implied coverage is +0.90 to 0.97 (see the tables below). Re-derive the coverage, with the calculation below, whenever the dataset it +should match changes. -## Derivation (October 2026) +## Derivation (24 October 2026) + +The first derivation (#2187, 0.67) used the Microcosm release `f9d1922c`. +That release was replaced by the `6cba5d71` and `a133f37a` cuts, which record +about 10% more standard-rated consumption. With 0.67, total `vat` on +`a133f37a` ran 7.5% to 10.3% above receipts in 2024-25 to 2026-27, and on +`6cba5d71` a reform run of one point on the standard rate cost £9.94bn in +2026-27, 12.9% above HMRC's ready reckoner. This derivation repeats the same calculation on `a133f37a` +(PolicyEngine/policyengine-uk#2633). ### Inputs | Input | Value | Source | |---|---|---| -| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68` (revision `f9d1922c`), SHA-256 `aa31bdf67c977927ea2b325567d1cf7a79d94381239bc79918a0a0fc9c9588af` | Private repository | +| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`, SHA-256 `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa` | Private repository | +| Microcosm UK 2024-25 national release, cross-check | tag `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`, SHA-256 `810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece` | Private repository | +| Microcosm UK 2024-25 national release, first derivation | tag `microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68` (revision `f9d1922c`), SHA-256 `aa31bdf67c977927ea2b325567d1cf7a79d94381239bc79918a0a0fc9c9588af` | Private repository | | Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.56.16 (revision `a9e52499`, SHA-256 `e433e532b17bd8ce76030156285816e33d44e93edabd2204adbef71d19a68712`) and 1.58.0 (revision `47bbb58d`, SHA-256 `5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6`) | Private repository | | VAT receipts | £170.994bn (2024-25 outturn), £180.169bn (2025-26), £187.704bn (2026-27) | OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8, cash-basis VAT | | Household share of VAT liabilities | 0.7 | HMRC VAT gap estimates (around 70% of the VAT total theoretical liability); `household_share_of_receipts` | @@ -56,8 +64,9 @@ the calculation below, whenever the dataset it should match changes. | Statutory rates | 20% standard, 5% reduced | `gov.hmrc.vat` | Model year 2024 is the 2024-25 fiscal year. All dataset totals are weighted -sums from `Microsimulation`, computed with policyengine-uk at this branch and -policyengine-core 3.33.2. +sums from `Microsimulation`. The `a133f37a` and `6cba5d71` rows were computed +with policyengine-uk at this branch's base (main `c0252415a`) and +policyengine-core 3.33.2; the other rows come from the first derivation. ### Formula @@ -75,6 +84,12 @@ equals receipts when the product matches the dataset. | Dataset | Year | Standard-rated base | Reduced-rated base | Pre-scaling VAT | Receipts | Product | Survey coverage | |---|---|---|---|---|---|---|---| +| Microcosm UK 2024-25 (`a133f37a`) | 2024-25 | £435.60bn | £26.76bn | £88.46bn | £171.0bn | 0.517 | 0.739 | +| Microcosm UK 2024-25 (`a133f37a`) | 2025-26 | £453.65bn | £27.87bn | £92.12bn | £180.2bn | 0.511 | 0.730 | +| Microcosm UK 2024-25 (`a133f37a`) | 2026-27 | £465.85bn | £28.62bn | £94.60bn | £187.7bn | 0.504 | 0.720 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2024-25 | | | £88.51bn | £171.0bn | 0.518 | 0.739 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2025-26 | | | £92.18bn | £180.2bn | 0.512 | 0.731 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2026-27 | | | £94.66bn | £187.7bn | 0.504 | 0.720 | | Microcosm UK 2024-25 (`f9d1922c`) | 2024-25 | £396.15bn | £25.02bn | £80.48bn | £171.0bn | 0.471 | 0.672 | | Microcosm UK 2024-25 (`f9d1922c`) | 2025-26 | £412.57bn | £26.06bn | £83.82bn | £180.2bn | 0.465 | 0.665 | | Microcosm UK 2024-25 (`f9d1922c`) | 2026-27 | £423.66bn | £26.76bn | £86.07bn | £187.7bn | 0.459 | 0.655 | @@ -85,34 +100,32 @@ equals receipts when the product matches the dataset. | Enhanced FRS 2024-25, uk-data 1.58.0 | 2025-26 | £593.92bn | £34.54bn | £120.51bn | £180.2bn | 0.669 | 0.956 | | Enhanced FRS 2024-25, uk-data 1.58.0 | 2026-27 | £609.89bn | £35.47bn | £123.75bn | £187.7bn | 0.659 | 0.942 | -The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the -latest outturn year. +The parameter takes the `a133f37a` value for the latest outturn year, 0.739, +rounded to 0.74 (product 0.518). -With that product, total `vat` against receipts is: +With that product, total `vat` against receipts, from runs at this branch: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| -| Microcosm UK 2024-25 (`f9d1922c`) | £171.6bn (+0.4%) | £178.7bn (-0.8%) | £183.5bn (-2.2%) | -| Enhanced FRS 2024-25, uk-data 1.56.16 | £236.7bn (+38.4%) | £246.5bn (+36.8%) | £253.1bn (+34.8%) | -| Enhanced FRS 2024-25, uk-data 1.58.0 | £246.7bn (+44.3%) | £257.0bn (+42.6%) | £263.9bn (+40.6%) | +| Microcosm UK 2024-25 (`a133f37a`) | PENDING | PENDING | PENDING | +| Enhanced FRS 2024-25, uk-data 1.58.1 | PENDING | PENDING | PENDING | ### Bound Total VAT depends only on the product, so the household share being a rounded "around 70%" changes how the product is split between the two factors, -not the totals. On Microcosm the product is bounded by: +not the totals. On `a133f37a` the product is bounded by: -- the receipts route across 2024-25 to 2026-27: 0.459 to 0.471 (coverage 0.655 - to 0.672); +- the receipts route across 2024-25 to 2026-27: 0.504 to 0.517 (coverage 0.720 + to 0.739); - the ready reckoner, which does not use the level of receipts: one point on - the standard rate raises £4.24bn before scaling in 2026-27 (1% of - £423.66bn), so matching HMRC's £8.8bn needs a product of 0.481 (coverage - 0.688). - -So on Microcosm the product lies between 0.459 and 0.481, and the coverage -between 0.655 and 0.688 at a household share of 0.7. The value used, 0.469 -(0.67), sits inside that range, and the totals it gives are within 2.2% of -receipts from 2024-25 to 2026-27. The implied product moves by less than 3% + the standard rate raises £4.66bn before scaling in 2026-27 (1% of + £465.85bn), so matching HMRC's £8.8bn needs a product of 0.529 (coverage + 0.756). With the product used, a reform run of one point costs PENDING. + +So on `a133f37a` the product lies between 0.504 and 0.529, and the coverage +between 0.720 and 0.756 at a household share of 0.7. The value used, 0.518 +(0.74), sits inside that range. The implied product moves by less than 3% over those years, so the factors are not dated by year. ### Reproducing @@ -129,7 +142,7 @@ path = hf_hub_download( "policyengine/populace-uk-private", "microcosm_uk_2024_25.h5", repo_type="dataset", - revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88", + revision="microcosm-uk-2024-25-national-20261023T075039Z-a133f37a", ) sim = Microsimulation(dataset=path) # OBR March 2026 EFO, table 3.8, cash-basis VAT receipts diff --git a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml index 54e8d87667..bfc2ffb8dd 100644 --- a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml @@ -1,4 +1,4 @@ -description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.67 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. +description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.74 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. metadata: unit: /1 period: year @@ -8,4 +8,4 @@ metadata: - title: docs/engineering/vat-coverage.md (derivation of the two factors this combines) href: https://github.com/PolicyEngine/policyengine-uk/blob/main/docs/engineering/vat-coverage.md values: - 2010-01-01: 0.469 + 2010-01-01: 0.518 diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index e5a16038db..9f0f250ca7 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -6,24 +6,28 @@ metadata: derivation: >- Calibrated to the certified Microcosm UK 2024-25 national release only (policyengine/populace-uk-private, tag - microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68, revision - f9d1922c). Pre-scaling VAT (standard rate times full_rate_vat_consumption - plus reduced rate times reduced_rate_vat_consumption) on that release is - 0.20 x £396.15bn + 0.05 x £25.02bn = £80.48bn in 2024. OBR VAT receipts - for 2024-25 (outturn, March 2026 EFO table 3.8, cash basis) are £170.994bn. - 80.48 / 170.994 = 0.471 for the product of the two factors; divided by the - household share of 0.7 gives 0.67. Across 2024-25 to 2026-27 the product is - 0.459 to 0.471, and the HMRC ready reckoner (£8.8bn for one point on the - standard rate in 2026-27) implies 0.481, so the coverage lies between 0.655 - and 0.688. On Enhanced FRS 2024-25 the same calculation gives a coverage of - 0.927 (policyengine-uk-data 1.56.16) or 0.967 (1.58.0), so this value puts - VAT on that dataset 38% or 44% above 2024-25 receipts. Re-derive when the - dataset it should match changes, with docs/engineering/vat-coverage.md, - which lists the inputs, file hashes and per-dataset results. + microcosm-uk-2024-25-national-20261023T075039Z-a133f37a, SHA-256 + 64f83900ca3a1242...). Pre-scaling VAT (standard rate times + full_rate_vat_consumption plus reduced rate times + reduced_rate_vat_consumption) on that release is 0.20 x £435.60bn + 0.05 x + £26.76bn = £88.46bn in 2024. OBR VAT receipts for 2024-25 (outturn, March + 2026 EFO table 3.8, cash basis) are £170.994bn. 88.46 / 170.994 = 0.517 for + the product of the two factors; divided by the household share of 0.7 gives + 0.739, used as 0.74 (product 0.518). Across 2024-25 to 2026-27 the product + is 0.504 to 0.517, and the HMRC ready reckoner (£8.8bn for one point on the + standard rate in 2026-27) implies 0.529, so the coverage lies between 0.720 + and 0.756. The cut it replaces (tag + microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68) recorded about 10% + less standard-rated consumption and gave 0.67; the 6cba5d71 cut gives the + same value as a133f37a to three decimal places. Enhanced FRS 2024-25 + records more consumption (implied coverage 0.90 to 0.97), so this value + puts VAT on that dataset above receipts. Re-derive when the dataset it + should match changes, with docs/engineering/vat-coverage.md, which lists + the inputs, file hashes and per-dataset results. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ - title: HMRC, Direct effects of illustrative tax changes bulletin (June 2025) href: https://www.gov.uk/government/statistics/direct-effects-of-illustrative-tax-changes/direct-effects-of-illustrative-tax-changes-bulletin-january-2025 values: - 2010-01-01: 0.67 + 2010-01-01: 0.74 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index 934f838e84..8e6eed4fd2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -5,9 +5,9 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - # (10,000 x 20% + 1,000 x 5%) / (0.67 x 0.7) = 2,050 / 0.469 - vat: 4_371.00 - baseline_vat: 4_371.00 + # (10,000 x 20% + 1,000 x 5%) / (0.74 x 0.7) = 2,050 / 0.518 + vat: 3_957.53 + baseline_vat: 3_957.53 - name: With both factors at one, VAT is the statutory liability on recorded consumption period: 2025 diff --git a/policyengine_uk/tests/test_vat_grossing_legacy.py b/policyengine_uk/tests/test_vat_grossing_legacy.py index 32c98a28c7..d3f798de36 100644 --- a/policyengine_uk/tests/test_vat_grossing_legacy.py +++ b/policyengine_uk/tests/test_vat_grossing_legacy.py @@ -28,7 +28,7 @@ def test_legacy_default_is_the_product_of_the_two_factors(): year ) * p.vat.household_share_of_receipts(year) assert p.microdata_vat_coverage(year) == pytest.approx(product) - assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.469) + assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.518) def test_reform_dict_to_legacy_path_changes_vat(): diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py new file mode 100644 index 0000000000..b14948e51c --- /dev/null +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -0,0 +1,77 @@ +"""Properties of VAT grossing that hold for every input (#2633). + +`vat` is the statutory liability on recorded consumption divided by one +grossing factor. The factor is the survey coverage times the household share +of receipts, unless a reform moves the deprecated combined parameter, in which +case that value is used. +""" + +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.vat import LEGACY_COVERAGE_DEFAULT, vat_grossing_factor + +SHARE = st.floats(min_value=0.01, max_value=1, allow_nan=False) +SPEND = st.floats(min_value=0, max_value=1e6, allow_nan=False) + + +def params(legacy, coverage, share): + return SimpleNamespace( + microdata_vat_coverage=legacy, + vat=SimpleNamespace( + survey_consumption_coverage=coverage, + household_share_of_receipts=share, + ), + ) + + +@given(coverage=SHARE, share=SHARE) +def test_untouched_legacy_factor_uses_the_product_of_the_two(coverage, share): + factor = vat_grossing_factor(params(LEGACY_COVERAGE_DEFAULT, coverage, share)) + assert factor == pytest.approx(coverage * share) + + +@given(legacy=SHARE, coverage=SHARE, share=SHARE) +def test_moved_legacy_factor_overrides_the_two(legacy, coverage, share): + factor = vat_grossing_factor(params(legacy, coverage, share)) + if legacy == pytest.approx(LEGACY_COVERAGE_DEFAULT, rel=1e-5, abs=1e-8): + assert factor == pytest.approx(coverage * share) + else: + assert factor == legacy + + +@settings(max_examples=5, deadline=None) +@given(spend=st.lists(st.tuples(SPEND, SPEND), min_size=1, max_size=20)) +def test_vat_is_statutory_liability_over_the_shipped_product(spend): + # One simulation of many households, so the property is checked on a + # vector: VAT is linear in each household's recorded consumption, with + # no cross-household term. + p = system.parameters.gov + product = p.simulation.vat.survey_consumption_coverage( + 2025 + ) * p.simulation.vat.household_share_of_receipts(2025) + names = [f"h{i}" for i in range(len(spend))] + situation = { + "people": {f"p{i}": {"age": {2025: 40}} for i in range(len(spend))}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(spend))}, + "households": { + name: { + "members": [f"p{i}"], + "full_rate_vat_consumption": {2025: full}, + "reduced_rate_vat_consumption": {2025: reduced}, + } + for i, (name, (full, reduced)) in enumerate(zip(names, spend)) + }, + } + vat = Simulation(situation=situation).calculate("vat", 2025) + for value, (full, reduced) in zip(vat, spend): + expected = ( + full * p.hmrc.vat.standard_rate(2025) + + reduced * p.hmrc.vat.reduced_rate(2025) + ) / product + assert value == pytest.approx(expected, rel=1e-5, abs=1e-2) From ad53a004fe5a578ea991d7b898f2e9c9a9038595 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 08:04:39 -0400 Subject: [PATCH 020/168] Pin 2024 VAT to the product and record the cuts' revisions The deprecated combined factor has one undated value, so every year, 2024 included, takes the two factors' product. A YAML case pins vat in 2024; adding a dated entry to the deprecated factor makes it fail. The doc's inputs now give the Hugging Face revisions of the a133f37a and 6cba5d71 tags, and the reproduction snippet pins a133f37a's revision. Refs #2633 Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 6 +++--- .../policy/baseline/gov/hmrc/vat/vat_grossing.yaml | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index b6169c2261..81eb6c62c1 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -54,8 +54,8 @@ about 10% more standard-rated consumption. With 0.67, total `vat` on | Input | Value | Source | |---|---|---| -| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`, SHA-256 `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa` | Private repository | -| Microcosm UK 2024-25 national release, cross-check | tag `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`, SHA-256 `810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece` | Private repository | +| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a` (revision `c3a751d9`), SHA-256 `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa` | Private repository | +| Microcosm UK 2024-25 national release, cross-check | tag `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71` (revision `bb225848`), SHA-256 `810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece` | Private repository | | Microcosm UK 2024-25 national release, first derivation | tag `microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68` (revision `f9d1922c`), SHA-256 `aa31bdf67c977927ea2b325567d1cf7a79d94381239bc79918a0a0fc9c9588af` | Private repository | | Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.56.16 (revision `a9e52499`, SHA-256 `e433e532b17bd8ce76030156285816e33d44e93edabd2204adbef71d19a68712`) and 1.58.0 (revision `47bbb58d`, SHA-256 `5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6`) | Private repository | | VAT receipts | £170.994bn (2024-25 outturn), £180.169bn (2025-26), £187.704bn (2026-27) | OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8, cash-basis VAT | @@ -142,7 +142,7 @@ path = hf_hub_download( "policyengine/populace-uk-private", "microcosm_uk_2024_25.h5", repo_type="dataset", - revision="microcosm-uk-2024-25-national-20261023T075039Z-a133f37a", + revision="c3a751d911ac82e482d298dda0b6f2c949f4ed4d", # tag ...-a133f37a ) sim = Microsimulation(dataset=path) # OBR March 2026 EFO, table 3.8, cash-basis VAT receipts diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index 8e6eed4fd2..1701290963 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -9,6 +9,18 @@ vat: 3_957.53 baseline_vat: 3_957.53 +- name: VAT in 2024 is grossed up by the same product as later years + period: 2024 + absolute_error_margin: 0.01 + input: + full_rate_vat_consumption: 10_000 + reduced_rate_vat_consumption: 1_000 + output: + # The deprecated combined factor has one undated value, so 2024 takes the + # two factors' product too: 2,050 / 0.518 + vat: 3_957.53 + baseline_vat: 3_957.53 + - name: With both factors at one, VAT is the statutory liability on recorded consumption period: 2025 absolute_error_margin: 0.01 From e1ff49300f5a5e93e0c146dc7665fbf747c51405 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 10:19:11 -0400 Subject: [PATCH 021/168] Cite the 6cba reform runs and align the legacy-factor property with np.isclose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r1 of #2634 (Sol, REQUEST_CHANGES at b6d8af5) found: - The £9.94bn one-point figure was described as a reform run without the runs being cited. The doc now names them: reform minus baseline `vat` and `gov_balance` on 6cba5d71 (revision bb225848), policyengine-uk 2.135.7 and 2.152.6, £9.94bn in 2026-27 and £9.68bn in 2025-26. - The moved-legacy property classified closeness with pytest.approx, which takes the larger tolerance, while the helper uses np.isclose, which adds them. A legacy value of 0.518005185 failed the property. The band is now stated once (1e-8 + 1e-5 x the default), the moved property draws values outside it, a new property draws values inside it, and fixed cases sit at 0.99 and 1.01 bands either side. Changing the helper to exact equality fails the inside cases. - The helper properties kept Hypothesis's 200 ms deadline and flaked on a loaded host; they now have no deadline. - The doc attributed both cuts' rows to main and core 3.33.2; it now gives each cut's recorded versions. - The parameter said 6cba5d71 matches a133f37a to three decimal places; it now says the 2024-25 coverage (0.739) matches. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 19 ++++++---- .../vat/survey_consumption_coverage.yaml | 2 +- .../tests/test_vat_grossing_properties.py | 36 ++++++++++++++++--- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 81eb6c62c1..c08d6d326a 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -45,9 +45,13 @@ should match changes. The first derivation (#2187, 0.67) used the Microcosm release `f9d1922c`. That release was replaced by the `6cba5d71` and `a133f37a` cuts, which record about 10% more standard-rated consumption. With 0.67, total `vat` on -`a133f37a` ran 7.5% to 10.3% above receipts in 2024-25 to 2026-27, and on -`6cba5d71` a reform run of one point on the standard rate cost £9.94bn in -2026-27, 12.9% above HMRC's ready reckoner. This derivation repeats the same calculation on `a133f37a` +`a133f37a` ran 7.5% to 10.3% above receipts in 2024-25 to 2026-27. On +`6cba5d71` (revision `bb225848`), a reform run that raises the standard rate +from 20% to 21% changed total `vat` by £9.94bn in 2026-27 (£9.68bn in +2025-26), 12.9% above HMRC's ready reckoner. That is the reform run minus the +baseline run, with policyengine-uk 2.135.7 and again with 2.152.6, which give +the same figure; `gov_balance` changed by the same amount. This derivation +repeats the same calculation on `a133f37a` (PolicyEngine/policyengine-uk#2633). ### Inputs @@ -64,9 +68,12 @@ about 10% more standard-rated consumption. With 0.67, total `vat` on | Statutory rates | 20% standard, 5% reduced | `gov.hmrc.vat` | Model year 2024 is the 2024-25 fiscal year. All dataset totals are weighted -sums from `Microsimulation`. The `a133f37a` and `6cba5d71` rows were computed -with policyengine-uk at this branch's base (main `c0252415a`) and -policyengine-core 3.33.2; the other rows come from the first derivation. +sums from `Microsimulation`. The `a133f37a` rows were computed with +policyengine-uk 2.152.7 (this branch's base, main `c0252415a`) and +policyengine-core 3.33.2, and again with policyengine-uk 2.147.2 and +policyengine-core 3.33.3, which give the same totals. The `6cba5d71` rows were +computed with policyengine-uk 2.147.2 and policyengine-core 3.33.3. The other +rows come from the first derivation. ### Formula diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 9f0f250ca7..6ced1c592b 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -19,7 +19,7 @@ metadata: and 0.756. The cut it replaces (tag microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68) recorded about 10% less standard-rated consumption and gave 0.67; the 6cba5d71 cut gives the - same value as a133f37a to three decimal places. Enhanced FRS 2024-25 + same 2024-25 coverage, 0.739. Enhanced FRS 2024-25 records more consumption (implied coverage 0.90 to 0.97), so this value puts VAT on that dataset above receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py index b14948e51c..e3eaf72e7f 100644 --- a/policyengine_uk/tests/test_vat_grossing_properties.py +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -9,7 +9,7 @@ from types import SimpleNamespace import pytest -from hypothesis import given, settings +from hypothesis import assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -18,6 +18,9 @@ SHARE = st.floats(min_value=0.01, max_value=1, allow_nan=False) SPEND = st.floats(min_value=0, max_value=1e6, allow_nan=False) +# A legacy value counts as untouched when it is within NumPy's default +# closeness of the declared default: 1e-8 plus 1e-5 times the default. +BAND = 1e-8 + 1e-5 * abs(LEGACY_COVERAGE_DEFAULT) def params(legacy, coverage, share): @@ -30,19 +33,42 @@ def params(legacy, coverage, share): ) +# The helper properties check algebra and routing, not speed, so they have no +# deadline: a slow first example on a loaded host is not a failure. +@settings(deadline=None) @given(coverage=SHARE, share=SHARE) def test_untouched_legacy_factor_uses_the_product_of_the_two(coverage, share): factor = vat_grossing_factor(params(LEGACY_COVERAGE_DEFAULT, coverage, share)) assert factor == pytest.approx(coverage * share) +@settings(deadline=None) +@given( + offset=st.floats(min_value=-0.99, max_value=0.99), + coverage=SHARE, + share=SHARE, +) +def test_legacy_factor_within_the_band_counts_as_untouched(offset, coverage, share): + legacy = LEGACY_COVERAGE_DEFAULT + offset * BAND + factor = vat_grossing_factor(params(legacy, coverage, share)) + assert factor == pytest.approx(coverage * share) + + +@settings(deadline=None) @given(legacy=SHARE, coverage=SHARE, share=SHARE) def test_moved_legacy_factor_overrides_the_two(legacy, coverage, share): + assume(abs(legacy - LEGACY_COVERAGE_DEFAULT) > 1.01 * BAND) factor = vat_grossing_factor(params(legacy, coverage, share)) - if legacy == pytest.approx(LEGACY_COVERAGE_DEFAULT, rel=1e-5, abs=1e-8): - assert factor == pytest.approx(coverage * share) - else: - assert factor == legacy + assert factor == legacy + + +@pytest.mark.parametrize( + "multiple, moved", [(-1.01, True), (-0.99, False), (0.99, False), (1.01, True)] +) +def test_legacy_factor_either_side_of_the_band(multiple, moved): + legacy = LEGACY_COVERAGE_DEFAULT + multiple * BAND + factor = vat_grossing_factor(params(legacy, 0.5, 0.7)) + assert factor == (legacy if moved else pytest.approx(0.35)) @settings(max_examples=5, deadline=None) From aecd34ebf3681387233be3ddca77c1b85e99c572 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:04:24 -0400 Subject: [PATCH 022/168] Bar income-related ESA at pensionable age and on State Pension Credit Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached pensionable age, and Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member of the couple, is entitled to State Pension Credit. esa_income_eligible now applies both. Pension Credit reads the couple's ESA, so the screen takes the couple to be on Pension Credit where it meets the age conditions and would claim it, as income_support_eligible does for SSCBA 1992 s.124(1)(g). An outside member's own claim, and the payee of the couple's award, follow s.1(3)(c). Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 57 ++- .../esa_income/esa_income_pension_age.yaml | 406 ++++++++++++++++++ ...t_income_support_eligibility_properties.py | 26 +- ...t_gate_reads_claimant_or_partner_awards.py | 24 +- .../variables/gov/dwp/_legacy_award_payee.py | 17 +- .../variables/gov/dwp/esa_income.py | 16 +- .../variables/gov/dwp/esa_income_eligible.py | 90 +++- ...it_benefit_cap_exempt_specified_benefit.py | 7 +- .../gov/dwp/is_on_income_based_jsa.py | 2 +- .../gov/dwp/is_on_income_related_esa.py | 28 +- ...uc_benefit_cap_exempt_specified_benefit.py | 7 +- 11 files changed, 629 insertions(+), 51 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 318019bded..e6da1c1c96 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -25,6 +25,23 @@ against pay (reg 98(11)). - ESA partner (para 6(1)(f), reg 42(1)): 24 hours or more, unless a carer (reg 43(2)(c)). +- ESA age (WRA 2007 s.1(3)(c)): no claimant may have reached pensionable age, + their own State Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). A + caller that reads the model's pensionable age passes it as is_SP_age; + otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone + aged 64 or under was born after 5 October 1960 and reaches it at 66 and + some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born + before 6 April 1960 and reached it at 66 or earlier. +- ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant + nor the other member of the couple may be entitled to it. The model reads + this as the couple meeting the Pension Credit age conditions and claiming + it; a caller passes the model's reading (on_pension_credit). Otherwise no + member reports Pension Credit or Housing Benefit, so no mixed-age couple + keeps the SI 2019/37 art 4 saving, the exclusion of mixed-age couples is in + force (SPCA 2002 s.4(1A)) and every couple would claim, and a couple is on + it when every member has reached the qualifying age, which from 2019-20 is + pensionable age for everyone (SPCA 2002 s.1(6)). It bars only the couple's + claim, not that of a member claiming in their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -153,6 +170,26 @@ def esa_claimant_in_remunerative_work(adult, parameters): return not (within_lower_limit or permitted_work) +def reached_pensionable_age(adult): + """WRA 2007 s.1(3)(c): whether the adult has reached pensionable age.""" + if "is_SP_age" in adult: + return bool(adult["is_SP_age"]) + assert not 65 <= adult["age"] < 70, adult + return adult["age"] >= 70 + + +def couple_on_pension_credit(couple): + """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing + Benefit reports in 2025 or 2026: every member has reached the qualifying + age, so the couple meets the age conditions and would claim.""" + assert not any( + adult.get("pension_credit_reported", 0) + or adult.get("housing_benefit_reported", 0) + for adult in couple + ), couple + return len(couple) > 0 and all(reached_pensionable_age(a) for a in couple) + + def legacy_benefits_closed(couple, others, parameters): """Annual closure proxy for the claimant's and partner's own award. @@ -202,12 +239,25 @@ def candidates(couple, others, reported, parameters): ] -def esa_screen(couple, others, capital, parameters): +def esa_screen(couple, others, capital, parameters, on_pension_credit=None): """esa_income_eligible for a claimant and partner (couple) and other - members of the benefit unit outside the family (others).""" + members of the benefit unit outside the family (others). on_pension_credit + is whether the couple is taken to be on State Pension Credit, if the + caller reads it from the model.""" ESA = parameters.gov.dwp.ESA.income members = couple + others + def barred(i): + # WRA 2007 s.1(3)(c), for every claimant. + if reached_pensionable_age(members[i]): + return True + # Sch 1 para 6(1)(c) and (d), for the couple's claim only. + if i >= len(couple): + return False + if on_pension_credit is not None: + return bool(on_pension_credit) + return couple_on_pension_credit(couple) + def other_member_works(i): if i >= len(couple): return False @@ -219,7 +269,8 @@ def other_member_works(i): ) passes = any( - not esa_claimant_in_remunerative_work(members[i], parameters) + not barred(i) + and not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) for i in candidates(couple, others, "esa_income_reported", parameters) ) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml new file mode 100644 index 0000000000..5bfdbc17e7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -0,0 +1,406 @@ +# Income-related ESA and pensionable age or State Pension Credit. +# Welfare Reform Act 2007 s.1(3)(c): a claimant who has reached pensionable +# age is not entitled to ESA. Pensionable age is the person's own State +# Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). +# Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the +# claimant "is not entitled to state pension credit" and "is not a member of a +# couple the other member of which is entitled to ... state pension credit". +# A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 +# (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving +# applies. Northern Ireland's Welfare Reform Act (Northern Ireland) 2007 sets +# the same conditions in the same words. +# Everyone here is a legacy claimant (would_claim_uc false), since a Universal +# Credit claim ends the couple's income-related ESA anyway. No case has +# capital, so no tariff income: an award that passes is the 5_000 reported. +# Pension Credit with no income is the guarantee credit's standard minimum +# guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and +# £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social +# Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so +# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here reached +# pensionable age before 6 April 2016, so no savings credit is due. + +- name: Case 1, a single claimant aged 70 who reports income-related ESA gets Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 7). + is_SP_age: true + # s.1(3)(c) bars the claim: 0, not the 5_000 reported. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: false + # 52 x 238.00. + pension_credit: 12_376 + +- name: Case 2, a couple aged 70 and 68 where the 68-year-old reports income-related ESA get Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 68 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Both born after 5 October 1954 and before 6 April 1960: pensionable + # age 66 (rule 7). + is_SP_age: [true, true] + meets_pension_credit_age_conditions: true + # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it + # too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. + pension_credit: 18_889 + +- name: Case 3, a saved mixed-age couple aged 70 and 60 get Pension Credit and no ESA on the younger member's report. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + # SI 2019/37 art 4(1): the couple was entitled to Pension Credit on + # 14 May 2019 and has been since, so s.4(1A) does not exclude it. + has_mixed_age_couple_pension_credit_saving: true + households: + household: + members: [person1, person2] + output: + # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not + # bar them. + is_SP_age: [true, false] + meets_pension_credit_age_conditions: true + # Para 6(1)(d): the other member of the couple is entitled to State + # Pension Credit, so the 60-year-old gets no income-related ESA. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. + pension_credit: 18_889 + +- name: Case 4, the same mixed-age couple without the saving keep income-related ESA and get no Pension Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: false + households: + household: + members: [person1, person2] + output: + # SPCA 2002 s.4(1A): the 70-year-old is not entitled to Pension Credit + # while the partner is under the qualifying age. + meets_pension_credit_age_conditions: false + pension_credit: 0 + # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 5, a saved mixed-age couple that does not claim Pension Credit keep income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: true + would_claim_pc: false + households: + household: + members: [person1, person2] + output: + # Entitlement to Pension Credit needs a claim (Social Security + # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 6, a single claimant aged 60 keeps income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + is_SP_age: false + meets_pension_credit_age_conditions: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 7, a man aged 63 in 2016 over the Pension Credit qualifying age but under pensionable age gets no income-related ESA while he would claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born 1 March 1953, before 6 December 1953: pensionable age 65, on 1 + # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so s.1(3)(c) does + # not bar him in 2016-17. + is_SP_age: false + # The qualifying age for State Pension Credit is a woman's pensionable + # age for the same date of birth (SPCA 2002 s.1(6)): a woman born from 6 + # February to 5 March 1953 reached it on 6 January 2016 (rule 3, table 1). + has_attained_state_pension_credit_qualifying_age: true + meets_pension_credit_age_conditions: true + # Para 6(1)(c): he is entitled to State Pension Credit. + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + +- name: Case 8, the same man in 2016 keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + is_SP_age: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 9, a woman born on the same day reached pensionable age, so gets no income-related ESA in 2016 even without a Pension Credit claim. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: FEMALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + # Rule 3, table 1: pensionable age on 6 January 2016. + is_SP_age: true + # s.1(3)(c). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + +- name: Case 10, before the mixed-age couple exclusion, a couple aged 70 and 60 that would claim Pension Credit get no ESA on the younger member's report. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # SPCA 2002 s.4(1A) came into force on 15 May 2019 (SI 2019/37 art 3), so + # in 2018-19 the 70-year-old can claim Pension Credit for the couple. + is_mixed_age_couple: true + meets_pension_credit_age_conditions: true + # Para 6(1)(d). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 11, a member outside the couple aged 70 who reports income-related ESA gets none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_claimant_or_partner: true + person2: + age: 70 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # They claim in their own right, and s.1(3)(c) bars their claim too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 12, the couple's Pension Credit does not bar a member outside the couple who claims income-related ESA in their own right. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The 70-year-old is a single claimant of Pension Credit: 52 x 238.00. + meets_pension_credit_age_conditions: true + pension_credit: 12_376 + # The 40-year-old is not a member of the 70-year-old's couple, so para + # 6(1)(d) does not reach them: their own award of 5_000. + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, true] + +- name: Case 13, where both members of a mixed-age couple report income-related ESA, only the one under pensionable age can be its payee. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: false + households: + household: + members: [person1, person2] + output: + # The 60-year-old can claim; the model makes the couple's two reports one + # award of 5_000 + 5_000 = 10_000. + esa_income_eligible: true + esa_income: 10_000 + claimant_or_partner_esa_income: 10_000 + # s.1(3)(c): the 70-year-old cannot be the claimant, so the award is not + # payable to them. + is_on_income_related_esa: [false, true] diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index cb3319414a..f9ba9889ec 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -58,7 +58,9 @@ model's reported or proxy inputs; it reads the qualifying age (has_attained_state_pension_credit_qualifying_age) and the model's proxy for being on Pension Credit (meets_pension_credit_age_conditions and -would_claim_pc) from the model; and the means test is out of scope. No +would_claim_pc) from the model, and so, for the income-related ESA screen, +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and the same Pension Credit +proxy (Sch 1 para 6(1)(c) and (d)); and the means test is out of scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit (only some added adults do), so in 2025 no family with a claimant or partner under the qualifying age meets the Pension Credit age conditions. The @@ -448,7 +450,9 @@ def is_claimant(i): sum(a["esa_income_reported"] for a in adults), capital, parameters.gov.dwp.ESA.income.capital, - ) and esa_screen(adults, [], capital, parameters) + ) and esa_screen( + adults, [], capital, parameters, on_pension_credit=on_pension_credit + ) if jsa_income is not None: # Within half a penny of zero is no award. income_based_jsa = jsa_income > 0.005 @@ -511,9 +515,19 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) + pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 for i, (adults, dependants, capital, extra) in enumerate(units): + # The ESA screen reads each member's pensionable age from the model. + adults = [ + dict(adult, is_SP_age=pensionable_age[start + j]) + for j, adult in enumerate(adults) + ] + if extra is not None: + extra = dict( + extra, is_SP_age=pensionable_age[start + len(adults) + len(dependants)] + ) # An esa_income or jsa_income the reported amounts explain is read # through them, like a calculated one. entered_esa = entered_jsa = None @@ -529,7 +543,13 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): reported_total, capital, parameters, - screen=esa_screen(adults, others, capital, parameters), + screen=esa_screen( + adults, + others, + capital, + parameters, + on_pension_credit=on_pension_credit[i], + ), ): entered_esa = esa_income[i] if jsa_income is not None: diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index ff2edaeab4..ca34dbf5c3 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -62,6 +62,9 @@ def reference_report_awards(benunit, period, award): "pension_credit_reported", "esa_income_reported", "jsa_income_reported", + # The ESA reference reads the model's pensionable age (WRA 2007 + # s.1(3)(c)). + "is_SP_age", ] arrays = {name: person(name, period) for name in names} roles = person("is_claimant_or_partner", period) @@ -76,6 +79,23 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen + # Sch 1 para 6(1)(c) and (d), through the model's proxy for the couple + # being on State Pension Credit. It never bars an outside member's claim. + on_pension_credit = benunit("meets_pension_credit_age_conditions", period) & ( + benunit("would_claim_pc", period) + ) + + def passes(members, unit, couple): + if not is_esa: + return screen(members, [], capitals[unit], parameters) + return screen( + members, + [], + capitals[unit], + parameters, + on_pension_credit=couple and on_pension_credit[unit], + ) + full, scoped = [], [] for unit in range(benunit.count): ids = np.flatnonzero(benunit.members_entity_id == unit) @@ -92,13 +112,13 @@ def reference_report_awards(benunit, period, award): active = is_esa or parameters.gov.dwp.JSA.income.active couple_amount = ( max(0, sum(member[f"{award}_reported"] for member in couple) - tariff) - if active and screen(couple, [], capital, parameters) + if active and passes(couple, unit, couple=True) else 0 ) outside_amount = sum( max(0, member[f"{award}_reported"] - tariff) for member in others - if active and screen([member], [], capital, parameters) + if active and passes([member], unit, couple=False) ) full.append(couple_amount + outside_amount) scoped.append(couple_amount) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index cee5d2c28a..35f5fdc5d6 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -15,7 +15,8 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No can_claim, if given, limits the payee to the members of the couple who can be the claimant (for Pension Credit, those who have attained the - qualifying age). Where neither can, both are considered, so a positive + qualifying age; for income-related ESA, those who have not reached + pensionable age). Where neither can, both are considered, so a positive award entered for the couple is still paid to one of them. couple, if given (a person-level mask), replaces the claimant and @@ -39,15 +40,17 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No return where(person.benunit.any(reports), reports, claimant) -def own_report_is_paid(person, period, award, works, capital_limit): +def own_report_is_paid(person, period, award, barred, capital_limit): """Whether income-related ESA or income-based JSA (award: "esa_income" or "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. - Their claim is screened on them alone. They must not be engaged in - remunerative work as its claimant (works), and the award on their report - alone must be positive: the report exceeds the tariff income from the - benefit unit's capital, within the capital limit (capital_limit). Their + Their claim is screened on them alone. No condition of their own may bar + it (barred): they must not be engaged in remunerative work as its + claimant, and, for income-related ESA, must not have reached pensionable + age (Welfare Reform Act 2007 s.1(3)(c)). The award on their report alone + must be positive: the report exceeds the tariff income from the benefit + unit's capital, within the capital limit (capital_limit). Their own capital is not observed, so the benefit unit's stands in for it. The benefit unit's screen (esa_income_eligible or jsa_income_eligible) cannot stand in for theirs, because when the claimant or partner reports @@ -63,7 +66,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): """ benunit = person.benunit own_award = ( - ~works + ~barred & (benunit(f"{award}_assessable_capital", period) <= capital_limit) & ( person(f"{award}_reported", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index f8bd1ff885..fe14c79598 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -3,8 +3,8 @@ def income_related_esa_award(benunit, period, reported_award): """Income-related ESA paid on a reported award: the award less tariff - income from capital, or nothing if the benefit unit fails the screen in - esa_income_eligible.""" + income from capital, or nothing if the benefit unit fails the age, Pension + Credit, work and capital screen in esa_income_eligible.""" tariff_income = benunit("esa_income_tariff_income", period) eligible = benunit("esa_income_eligible", period) return where(eligible, max_(0, reported_award - tariff_income), 0) @@ -14,7 +14,9 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) """Screen open person reports, retaining each original claim's ownership. The claimant and partner share one claim. Other reporters have separate - claims, each using the existing own-work and shared-capital/tariff proxy. + claims, each using the existing own-work and shared-capital/tariff proxy, + and barred once the reporter reaches pensionable age (Welfare Reform Act + 2007 s.1(3)(c)). Selecting members changes only reports, never the original couple or the work/capital screen. This helper must not read payable-person status, which itself depends on the aggregate award. @@ -28,9 +30,10 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) benunit, period, benunit.sum(reported * couple) ) works = person("esa_income_claimant_remunerative_work", period) + pensionable_age = person("is_SP_age", period) capital = benunit("esa_income_assessable_capital", period) limit = rules.income.capital.limit - own_claim = ~couple & ~works & benunit.project(capital <= limit) + own_claim = ~couple & ~works & ~pensionable_age & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) return couple_award + benunit.sum(own_awards) @@ -41,8 +44,9 @@ class esa_income(Variable): entity = BenUnit label = "ESA (income-based)" documentation = ( - "Reported income-related ESA screened through bounded capital and " - "remunerative work tests (esa_income_eligible). Reports whose award " + "Reported income-related ESA screened through bounded pensionable " + "age, Pension Credit, capital and remunerative work tests " + "(esa_income_eligible). Reports whose award " "has ended are left out (esa_income_report_closed): the owner's " "legacy benefits have closed, or the owner is the claimant or " "partner of a family that claims Universal Credit. " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 22f9c6b4b6..87650488be 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -5,34 +5,58 @@ class esa_income_eligible(Variable): value_type = bool entity = BenUnit label = ( - "Whether a reported income-related ESA award passes the capital and work tests" + "Whether a reported income-related ESA award passes the age, Pension " + "Credit, capital and work tests" ) documentation = ( - "Bounded work/capital screen for reported income-related ESA, not a full " - "entitlement model. When an original claimant or partner reports an open " - "award, only they are candidate claimants; otherwise other open reporters " - "are candidates. The claimant must not engage in remunerative work (Welfare " - "Reform Act 2007 Sch 1 para 6(1)(e); " - "esa_income_claimant_remunerative_work). The other member of the couple " - "must not engage in remunerative work (para 6(1)(f)): paid work of 24 hours " - "a week or more (ESA Regs 2008 reg 42(1)), subject to the existing carer " - "exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " - "reads as is_carer_for_benefits. Assessable capital must remain within the " - "existing limit (para 6(1)(b); reg 110). The aggregate esa_income uses this " - "screen for its original couple's part; each outside reporter's claim is " - "paid separately on their own existing work/capital screen and tariff. The " - "benefit unit's capital is an explicit proxy for unobserved outside-claim " - "capital. Reports whose own award is closed are excluded before choosing " - "candidates; ESA also excludes the couple's reports on a UC claim. This " - "variable's candidate rules are unchanged by the separate aggregation." + "Bounded age, Pension Credit, work and capital screen for reported " + "income-related ESA, not a full entitlement model. When an original claimant " + "or partner reports an open award, only they are candidate claimants; " + "otherwise other open reporters are candidates. The claimant must not have " + "reached pensionable age (Welfare Reform Act 2007 s.1(3)(c)): their own State " + "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " + "Neither the claimant nor the other member of the couple may be entitled to " + "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " + "couple's income-related ESA, so the model cannot read the Pension Credit " + "award here. As for Income Support (income_support_eligible), it takes the " + "couple to be on Pension Credit where the couple meets the Pension Credit age " + "conditions (meets_pension_credit_age_conditions) and would claim it " + "(would_claim_pc). That leaves out Pension Credit's means test, so the screen " + "also fails for a couple that test would refuse. The couple's Pension Credit " + "bars only the couple's claim, not that of a member who claims in their own " + "right. The claimant must not engage in remunerative work (para 6(1)(e); " + "esa_income_claimant_remunerative_work). The other member of the couple must " + "not engage in remunerative work (para 6(1)(f)): paid work of 24 hours a week " + "or more (ESA Regs 2008 reg 42(1)), subject to the existing carer exception " + "(reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model reads as " + "is_carer_for_benefits. Assessable capital must remain within the existing " + "limit (para 6(1)(b); reg 110). The model applies neither para 6(1)(g) " + "(receiving education) nor the other member's income-based JSA under para " + "6(1)(d). The Welfare Reform Act (Northern Ireland) 2007 sets the same " + "conditions in the same words, and Northern Ireland's pensionable age " + "(Pensions (Northern Ireland) Order 1995 Sch 2 para 1) follows the same " + "timetable, so one rule covers the UK. The aggregate esa_income uses this " + "screen for its original couple's part; each outside reporter's claim is paid " + "separately on their own existing work/capital screen, pensionable age and " + "tariff. The benefit unit's capital is an explicit proxy for unobserved " + "outside-claim capital. Reports whose own award is closed are excluded before " + "choosing candidates; ESA also excludes the couple's reports on a UC claim. " + "This variable's candidate rules are unchanged by the separate aggregation." ) definition_period = YEAR reference = ( + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/uksi/2008/794/regulation/41", "https://www.legislation.gov.uk/uksi/2008/794/regulation/42", "https://www.legislation.gov.uk/uksi/2008/794/regulation/43", "https://www.legislation.gov.uk/uksi/2008/794/regulation/110", + "https://www.legislation.gov.uk/nia/2007/2/section/1", + "https://www.legislation.gov.uk/nia/2007/2/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/nisi/1995/3213/schedule/2", ) def formula(benunit, period, parameters): @@ -49,6 +73,28 @@ def formula(benunit, period, parameters): candidate = reports_award & ( claimant_or_partner | ~benunit.project(couple_reports) ) + # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age", + # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man + # born before 6 December 1953 reaches it at 65. + pensionable_age = person("is_SP_age", period) + # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State + # Pension Credit, nor is the other member of the couple. Reading the + # Pension Credit amount here would be circular: its higher earnings + # disregard reads this award (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), + # and so does the working tax credit income test (TCA 2002 s.7(2)), + # and working tax credit counts as Pension Credit income. As + # income_support_eligible does for SSCBA 1992 s.124(1)(g), the couple + # is taken to be on Pension Credit where it meets the Pension Credit + # age conditions and would claim it. That leaves out the means test: a + # couple Pension Credit's means test would refuse is still taken to be + # on it. A family that left the Pension Credit route when DWP moved it + # to Universal Credit claims Universal Credit, which already ends the + # couple's award (esa_income_report_closed). The couple's Pension + # Credit does not bar a member who claims in their own right. + on_pension_credit = benunit( + "meets_pension_credit_age_conditions", period + ) & benunit("would_claim_pc", period) + pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) # Para 6(1)(f) and reg 42(1): the other member of the couple works 24 @@ -62,5 +108,11 @@ def formula(benunit, period, parameters): other_member_works = claimant_or_partner & ( (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) - claimant = candidate & ~claimant_works & ~other_member_works + claimant = ( + candidate + & ~pensionable_age + & ~pension_credit_bars + & ~claimant_works + & ~other_member_works + ) return benunit.any(claimant) & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 2481b490be..0cbd1f4173 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -120,7 +120,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=claimant_or_partner + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=claimant_or_partner, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 318ee8f10c..52b463ab20 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -55,7 +55,7 @@ def formula(person, period, parameters): person, period, "jsa_income", - works=hours >= JSA.remunerative_work.claimant_hours, + barred=hours >= JSA.remunerative_work.claimant_hours, capital_limit=JSA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index dde494cf3b..2a52e7d81c 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -16,12 +16,16 @@ class is_on_income_related_esa(Variable): "couple's award is paid to the claimant, not the partner: of the " "claimant and partner, only the payee is on it, meaning the one who " "reports the award, or the claimant where neither does, and only while " - "their award (claimant_or_partner_esa_income) is positive. Any other " + "their award (claimant_or_partner_esa_income) is positive. A member " + "who has reached pensionable age cannot be the claimant (Welfare Reform " + "Act 2007 s.1(3)(c)), so where the other member has not, only the " + "other member can be the payee. Any other " "member of the benefit unit, such as a non-dependent adult, claims in " - "their own right and is on it only if their own claim passes: they are " - "not engaged in remunerative work as its claimant " - "(esa_income_claimant_remunerative_work; Welfare Reform Act 2007 Sch 1 " - "para 6(1)(e)), and the award on their own report alone is positive, " + "their own right and is on it only if their own claim passes: they " + "have not reached pensionable age (is_SP_age; s.1(3)(c)), they are not " + "engaged in remunerative work as its claimant " + "(esa_income_claimant_remunerative_work; Sch 1 para 6(1)(e)), and the " + "award on their own report alone is positive, " "the report exceeding the tariff income from the benefit unit's " "capital, within the capital limit. On the formula path the claimant's " "or partner's claim and work never change this. The legacy " @@ -39,19 +43,27 @@ class is_on_income_related_esa(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", ) def formula(person, period, parameters): + # WRA 2007 s.1(3)(c): a member who has reached pensionable age cannot + # be the claimant, so the award is not payable to them. + pensionable_age = person("is_SP_age", period) couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 - ) & (is_payee_of_couple_award(person, period, "esa_income_reported")) - # Their own claim, on their own report and their own work alone. + ) & is_payee_of_couple_award( + person, period, "esa_income_reported", can_claim=~pensionable_age + ) + # Their own claim, on their own report, work and age alone (WRA 2007 + # s.1(3)(c): a claimant who has reached pensionable age is not entitled). own_award = own_report_is_paid( person, period, "esa_income", - works=person("esa_income_claimant_remunerative_work", period), + barred=person("esa_income_claimant_remunerative_work", period) + | pensionable_age, capital_limit=parameters(period).gov.dwp.ESA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index d752aad48f..b88e32758a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -152,7 +152,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=couple + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=couple, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa From dcf29693bed60ba212ec960a77575a5a860ea27b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:28:25 -0400 Subject: [PATCH 023/168] Test that pensionable age and Pension Credit bar income-related ESA Property tests: no family gets both awards, nobody over pensionable age is on income-related ESA, the screen matches the reference reading, and claiming Pension Credit never raises income-related ESA. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 policyengine_uk/tests/test_esa_income_pension_age_properties.py diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py new file mode 100644 index 0000000000..1a9a977fac --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -0,0 +1,217 @@ +"""Income-related ESA is never paid where pensionable age or Pension Credit bars it. + +Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached +pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and +(d) bar the income-related allowance where the claimant, or the other member +of the couple, is entitled to State Pension Credit. The model takes the couple +to be entitled where it meets the Pension Credit age conditions and would +claim it (meets_pension_credit_age_conditions and would_claim_pc), as Income +Support does for SSCBA 1992 s.124(1)(g). For every family drawn here: + +- no family gets both: where the model pays the couple Pension Credit, or + takes it to be on Pension Credit, the couple's income-related ESA + (claimant_or_partner_esa_income) is nil; +- nobody who has reached pensionable age is on income-related ESA + (is_on_income_related_esa), and a family whose every report comes from such + a member gets none (esa_income); +- esa_income_eligible equals the family-by-family reading of the law in + legacy_award_work_reference (esa_screen), given the model's pensionable age + (is_SP_age) and Pension Credit proxy, so the bars combine with the work and + capital tests as the law sets them; +- claiming Pension Credit never raises income-related ESA: the same families + with would_claim_pc false get at least as much, and exactly as much where + the couple does not meet the Pension Credit age conditions. + +The years are 2016, when a man born before 6 December 1953 could be over the +qualifying age for State Pension Credit but under his pensionable age of 65 +(Pensions Act 1995 Sch 4 para 1 rule 1), 2018, before the exclusion of +mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only +a mixed-age couple that keeps the SI 2019/37 art 4 saving +(has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. +Adults work some hours but have no pay, so only the other member's 24 hours +(para 6(1)(f)) and capital above £16,000 (para 6(1)(b)) bite among the older +tests; test_legacy_award_work_properties.py covers earnings. A member outside +the couple claims in their own right: the couple's Pension Credit does not bar +them, and pensionable age does. Everyone is a legacy claimant (would_claim_uc +false). Roles are given explicitly (is_claimant_or_partner). Each example +builds many families in one simulation, in separate benefit units. +""" + +import numpy as np +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st + +from legacy_award_work_reference import esa_screen +from policyengine_uk import Simulation + +YEARS = [2016, 2018, 2026] +# Either side of the State Pension Credit qualifying age and of pensionable +# age in each year drawn, including men aged 62 to 64 in 2016. +AGES = [30, 45, 58, 60, 61, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] +# 0 and 24 hours a week. +HOURS = [0, 0, 0, 1_248] + + +@st.composite +def adults(draw, outside=False): + adult = { + "age": draw(st.sampled_from(AGES)), + "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), + "gender": draw(st.sampled_from(["MALE", "FEMALE"])), + "esa_income_reported": draw(st.sampled_from([0, 3_000, 3_000])), + "hours_worked": draw(st.sampled_from(HOURS)), + } + if outside: + adult["current_education"] = "NOT_IN_EDUCATION" + return adult + + +@st.composite +def families(draw): + """A claimant, an optional partner, an optional member outside the couple, + capital and the couple's saving and Pension Credit take-up.""" + couple = [draw(adults())] + if draw(st.booleans()): + couple.append(draw(adults())) + others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] + benunit = { + "esa_income_assessable_capital": draw(st.sampled_from([0, 0, 0, 20_000])), + "has_mixed_age_couple_pension_credit_saving": draw(st.booleans()), + "would_claim_pc": draw(st.sampled_from([True, True, False])), + } + return couple, others, benunit + + +def situation(units, year): + people, benunits, households = {}, {}, {} + for i, (couple, others, benunit) in enumerate(units): + names = [] + for j, (inputs, claimant_or_partner) in enumerate( + [(m, True) for m in couple] + [(m, False) for m in others] + ): + name = f"p{i}_{j}" + people[name] = {k: {year: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + } + households[f"h{i}"] = {"members": names} + return {"people": people, "benunits": benunits, "households": households} + + +def label(units, year): + event(f"year {year}") + for couple, others, _ in units: + if len(couple) == 2: + event("couple") + if others: + event("member outside the couple") + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +FAMILIES = st.lists(families(), min_size=1, max_size=8) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_no_family_gets_pension_credit_and_income_related_esa(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + pension_credit = sim.calculate("pension_credit", year) + on_pension_credit = sim.calculate( + "meets_pension_credit_age_conditions", year + ) & sim.calculate("would_claim_pc", year) + couple_esa = sim.calculate("claimant_or_partner_esa_income", year) + if (on_pension_credit & (couple_esa == 0)).any(): + event("a couple on Pension Credit") + if (~on_pension_credit & (couple_esa > 0)).any(): + event("a couple paid income-related ESA") + # Pension Credit is paid only on the Pension Credit route, to a family + # that would claim it (is_pension_credit_eligible, pension_credit). + assert not ((pension_credit > 0) & ~on_pension_credit).any() + # Para 6(1)(c) and (d). + assert (couple_esa[on_pension_credit] == 0).all(), units + assert not ((pension_credit > 0) & (couple_esa > 0)).any(), units + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + pensionable_age = sim.calculate("is_SP_age", year) + on_esa = sim.calculate("is_on_income_related_esa", year) + esa = sim.calculate("esa_income", year) + if (pensionable_age & ~on_esa).any(): + event("someone over pensionable age") + # s.1(3)(c): the award is not payable to them. + assert not (pensionable_age & on_esa).any(), units + start = 0 + for i, (couple, others, _) in enumerate(units): + members = couple + others + flags = pensionable_age[start : start + len(members)] + reporters = [m["esa_income_reported"] > 0 for m in members] + if any(reporters) and all(f for f, r in zip(flags, reporters) if r): + event("every report from someone over pensionable age") + assert esa[i] == 0, units[i] + start += len(members) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_screen_matches_a_family_by_family_reading(units, year): + label(units, year) + sim = Simulation(situation=situation(units, year)) + eligible = sim.calculate("esa_income_eligible", year) + pensionable_age = sim.calculate("is_SP_age", year) + on_pension_credit = sim.calculate( + "meets_pension_credit_age_conditions", year + ) & sim.calculate("would_claim_pc", year) + parameters = sim.tax_benefit_system.parameters(year) + if eligible.any(): + event("some screen passes") + start = 0 + for i, (couple, others, benunit) in enumerate(units): + members = [ + dict(member, is_SP_age=pensionable_age[start + j]) + for j, member in enumerate(couple + others) + ] + expected = esa_screen( + members[: len(couple)], + members[len(couple) :], + benunit["esa_income_assessable_capital"], + parameters, + on_pension_credit=on_pension_credit[i], + ) + assert eligible[i] == expected, (year, units[i]) + start += len(members) + + +@SETTINGS +@given(FAMILIES, st.sampled_from(YEARS)) +def test_claiming_pension_credit_never_raises_income_related_esa(units, year): + label(units, year) + n = len(units) + + def claiming(claims): + return [ + (couple, others, dict(benunit, would_claim_pc=claims)) + for couple, others, benunit in units + ] + + sim = Simulation(situation=situation(claiming(True) + claiming(False), year)) + esa = sim.calculate("esa_income", year) + route = sim.calculate("meets_pension_credit_age_conditions", year) + assert (route[:n] == route[n:]).all() + if (esa[n:] > esa[:n]).any(): + event("claiming Pension Credit ended an award") + assert (esa[:n] <= esa[n:] + 0.01).all(), units + assert np.allclose(esa[:n][~route[:n]], esa[n:][~route[:n]], atol=0.01), units From 1ca890c2cfe0bb5e7873b3095e85600f0d32d6db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:28:00 -0400 Subject: [PATCH 024/168] Pin outside reporters beside a Pension Credit couple in the property tests Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 1a9a977fac..c7833628a3 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -38,7 +38,7 @@ """ import numpy as np -from hypothesis import HealthCheck, event, given, settings +from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st from legacy_award_work_reference import esa_screen @@ -111,6 +111,32 @@ def label(units, year): event("member outside the couple") +NO_REPORT = {"months_since_last_birthday": 0, "esa_income_reported": 0} +# A member outside the couple who reports income-related ESA beside a +# single claimant aged 70 on Pension Credit, and one aged 70 themselves. +OUTSIDE_REPORTERS = [ + ( + [{"age": 70, "gender": "FEMALE", "hours_worked": 0, **NO_REPORT}], + [ + { + "age": age, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 3_000, + "hours_worked": 0, + "current_education": "NOT_IN_EDUCATION", + } + ], + { + "esa_income_assessable_capital": 0, + "has_mixed_age_couple_pension_credit_saving": False, + "would_claim_pc": True, + }, + ) + for age in [40, 70] +] + + SETTINGS = settings( max_examples=12, deadline=None, @@ -144,6 +170,7 @@ def test_no_family_gets_pension_credit_and_income_related_esa(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS, 2026) def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): label(units, year) sim = Simulation(situation=situation(units, year)) @@ -167,6 +194,8 @@ def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS[:1], 2026) +@example(OUTSIDE_REPORTERS[1:], 2026) def test_screen_matches_a_family_by_family_reading(units, year): label(units, year) sim = Simulation(situation=situation(units, year)) @@ -197,6 +226,7 @@ def test_screen_matches_a_family_by_family_reading(units, year): @SETTINGS @given(FAMILIES, st.sampled_from(YEARS)) +@example(OUTSIDE_REPORTERS, 2026) def test_claiming_pension_credit_never_raises_income_related_esa(units, year): label(units, year) n = len(units) From d538f82ca6498aa1ea44d4006cfa94aa3b9ad4fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:38:43 -0400 Subject: [PATCH 025/168] Add changelog fragment for #2565 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2565.fixed.md diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md new file mode 100644 index 0000000000..153420156d --- /dev/null +++ b/changelog.d/2565.fixed.md @@ -0,0 +1 @@ +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple it takes to be on State Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). From 7e85a895b75af5bc100e050dcbb68b0240494870 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 13:33:48 -0400 Subject: [PATCH 026/168] Bar income-related ESA on Pension Credit worked out without it Sch 1 para 6(1)(c) and (d) bar the couple's income-related ESA on entitlement to State Pension Credit, which age and the claim alone do not establish. esa_income_eligible now reads pension_credit in a branch with no income-related ESA in the family (pension_credit_without_income_related_esa) and bars the couple's claim where that pays. The branch opens only when some couple has an open report, meets the Pension Credit age conditions and would claim. The YAML cases now cover each pairing of Pension Credit with and without the award, Sol's 2016 and 2018 witnesses, a saved couple built from facts (an older member born 1 March 1953) and the asymmetric cap payee. Co-Authored-By: Claude Opus 5.5 --- .../esa_income/esa_income_pension_age.yaml | 334 ++++++++++++++++-- .../variables/gov/dwp/esa_income_eligible.py | 141 +++++--- 2 files changed, 413 insertions(+), 62 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 5bfdbc17e7..da2a2e5b8b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -5,19 +5,22 @@ # Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the # claimant "is not entitled to state pension credit" and "is not a member of a # couple the other member of which is entitled to ... state pension credit". +# The model reads the couple's Pension Credit worked out with no +# income-related ESA in the family, B(0), and bars the couple's claim where it +# pays. B(e) is the Pension Credit the family would get with the award e. # A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 # (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving -# applies. Northern Ireland's Welfare Reform Act (Northern Ireland) 2007 sets -# the same conditions in the same words. +# applies. # Everyone here is a legacy claimant (would_claim_uc false), since a Universal # Credit claim ends the couple's income-related ESA anyway. No case has -# capital, so no tariff income: an award that passes is the 5_000 reported. +# capital, so no tariff income: an award that passes is the amount reported. # Pension Credit with no income is the guarantee credit's standard minimum # guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and # £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social # Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so -# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here reached -# pensionable age before 6 April 2016, so no savings credit is due. +# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here in 2026 reached +# pensionable age before 6 April 2016, so no savings credit is due (SPCA 2002 +# s.3(1)). - name: Case 1, a single claimant aged 70 who reports income-related ESA gets Pension Credit and no ESA. period: 2026 @@ -79,14 +82,18 @@ # 52 x 363.25. pension_credit: 18_889 -- name: Case 3, a saved mixed-age couple aged 70 and 60 get Pension Credit and no ESA on the younger member's report. +- name: Case 3, a saved mixed-age couple aged 73 and 60 get Pension Credit and no ESA on the younger member's report. period: 2026 absolute_error_margin: 0.01 input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true + # The couple has had Pension Credit since before 15 May 2019. + pension_credit_reported: 9_000 person2: age: 60 is_claimant_or_partner: true @@ -95,24 +102,29 @@ benunit: members: [person1, person2] would_claim_uc: false - # SI 2019/37 art 4(1): the couple was entitled to Pension Credit on - # 14 May 2019 and has been since, so s.4(1A) does not exclude it. - has_mixed_age_couple_pension_credit_saving: true households: household: members: [person1, person2] output: + # SI 2019/37 art 4(1): born 1 March 1953, the older member reached the + # qualifying age on 6 January 2016 (Pensions Act 1995 Sch 4 para 1 rule 3, + # table 1, via SPCA 2002 s.1(6)), so the couple could be entitled to + # Pension Credit on 14 May 2019, and it reports Pension Credit now, so + # s.4(1A) does not exclude it. + has_mixed_age_couple_pension_credit_saving: true # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not # bar them. is_SP_age: [true, false] meets_pension_credit_age_conditions: true - # Para 6(1)(d): the other member of the couple is entitled to State - # Pension Credit, so the 60-year-old gets no income-related ESA. + # Para 6(1)(d): B(0) = 52 x 363.25 = 18_889 is payable, so the other + # member of the couple is entitled to State Pension Credit and the + # 60-year-old gets no income-related ESA. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 is_on_income_related_esa: [false, false] - # 52 x 363.25. + # 52 x 363.25. He reached pensionable age at 65 on 1 March 2018 (rule + # 1), after 6 April 2016, so no savings credit is due (SPCA 2002 s.3(1)). pension_credit: 18_889 - name: Case 4, the same mixed-age couple without the saving keep income-related ESA and get no Pension Credit. @@ -121,7 +133,9 @@ input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true person2: age: 60 @@ -131,13 +145,14 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: false households: household: members: [person1, person2] output: - # SPCA 2002 s.4(1A): the 70-year-old is not entitled to Pension Credit - # while the partner is under the qualifying age. + # Neither member reports Pension Credit or pension-age Housing Benefit, + # so the couple keeps no saving, and SPCA 2002 s.4(1A) excludes the + # 73-year-old while the partner is under the qualifying age. + has_mixed_age_couple_pension_credit_saving: false meets_pension_credit_age_conditions: false pension_credit: 0 # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. @@ -152,8 +167,11 @@ input: people: person1: - age: 70 + age: 73 + date_of_birth: 19530301 + gender: MALE is_claimant_or_partner: true + pension_credit_reported: 9_000 person2: age: 60 is_claimant_or_partner: true @@ -162,12 +180,13 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: true would_claim_pc: false households: household: members: [person1, person2] output: + has_mixed_age_couple_pension_credit_saving: true + meets_pension_credit_age_conditions: true # Entitlement to Pension Credit needs a claim (Social Security # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. pension_credit: 0 @@ -229,10 +248,13 @@ # February to 5 March 1953 reached it on 6 January 2016 (rule 3, table 1). has_attained_state_pension_credit_qualifying_age: true meets_pension_credit_age_conditions: true - # Para 6(1)(c): he is entitled to State Pension Credit. + # Para 6(1)(c): with no income, B(0) is the minimum guarantee, 52 x + # 155.60 = 8_091.20 in 2016-17 (SPC Regs 2002 reg 6(1)(b)), so he is + # entitled to State Pension Credit. esa_income_eligible: false esa_income: 0 is_on_income_related_esa: false + pension_credit: 8_091.20 - name: Case 8, the same man in 2016 keeps income-related ESA if he would not claim Pension Credit. period: 2016 @@ -391,11 +413,14 @@ benunit: members: [person1, person2] would_claim_uc: false - has_mixed_age_couple_pension_credit_saving: false households: household: members: [person1, person2] output: + # Born in 1956, the 70-year-old reached the qualifying age after 14 May + # 2019, so the couple keeps no saving (SI 2019/37 art 4(1)). + has_mixed_age_couple_pension_credit_saving: false + meets_pension_credit_age_conditions: false # The 60-year-old can claim; the model makes the couple's two reports one # award of 5_000 + 5_000 = 10_000. esa_income_eligible: true @@ -404,3 +429,270 @@ # s.1(3)(c): the 70-year-old cannot be the claimant, so the award is not # payable to them. is_on_income_related_esa: [false, true] + +- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple's award brings neither benefit cap exception. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + esa_includes_support_component: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + esa_income: 10_000 + # s.1(3)(c): only the 60-year-old can be the claimant, so the award is + # paid to them, without the support component. + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the exception needs the award's support component, which + # belongs to the 70-year-old, who is not its claimant. + is_uc_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: false + +# Cases 15 to 19 take one case for each pairing of B(0) and B(e). Each is a +# man aged 63 in 2016, born 1 March 1953: over the qualifying age for State +# Pension Credit (from 6 January 2016) and under his pensionable age (65, +# on 1 March 2018), with support-group income-related ESA. In 2016-17 the +# minimum guarantee is £155.60 a week, 52 x 155.60 = 8_091.20 a year (SPC +# Regs 2002 reg 6(1)(b)), and Pension Credit disregards £5 a week of a single +# claimant's earnings (Sch VI para 5), or £20 a week while the claimant is on +# employment and support allowance (para 4(1)(a)(vii)). Below 65 no savings +# credit is due (SPCA 2002 s.3(1)). His income is below the personal +# allowance (£11,000) and his earnings below the National Insurance primary +# threshold (£155 a week), so neither takes anything off. + +- name: Case 15, where Pension Credit pays with or without the award, the man gets Pension Credit worked out without it and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 6_011.20 + esa_includes_support_component: true + # £50 a week for 10 hours. + employment_income: 2_600 + hours_worked: 520 + # £40 a week. + private_pension_income: 2_080 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): income 50 + 40 - 5 = 85 a week, so 155.60 - 85 = 70.60 a week. + # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) pays, so para 6(1)(c) + # bars the award and Pension Credit is B(0): 52 x 70.60. + esa_income_eligible: false + esa_income: 0 + pension_credit: 3_671.20 + pension_credit_earnings_disregard: 260 + +- name: Case 16, where Pension Credit pays nothing with or without the award, the man keeps income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + # £115.50 a week for 15 hours. + employment_income: 6_006 + hours_worked: 780 + # £61 a week. + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): 115.50 + 61 - 5 = 171.50 a week, over 155.60. B(e): 115.50 + 61 + # - 20 = 156.50, still over it. Pension Credit refuses him either way, so + # para 6(1)(c) does not bar the award: 52 x 94.60 = 4_919.20. + pension_credit: 0 + esa_income_eligible: true + esa_income: 4_919.20 + is_on_income_related_esa: true + +- name: Case 17, the same man keeps the same income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + pension_credit: 0 + esa_income: 4_919.20 + +- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets that Pension Credit too. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + # £50 a week. + private_pension_income: 2_600 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): + # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state + # is consistent: Pension Credit would end the award, and without the + # award Pension Credit pays nothing. B(0) pays nothing, so the award + # stands, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 = 525.20. + esa_income_eligible: true + esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit: 525.20 + +- name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + is_claimant_or_partner: true + # 20 hours a week. + employment_income: 10_000 + hours_worked: 1_040 + working_tax_credit_reported: 1 + person2: + age: 58 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Before 15 May 2019 the 66-year-old can claim Pension Credit for the + # couple. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard + # is £10 a week (Sch VI para 5), £20 with the award. The 66-year-old has + # reached pensionable age, so pays no primary Class 1 contributions + # (SSCBA 1992 s.6(3)), and 10_000 is under the £11,000 personal + # allowance, so his earnings count in full. + # Working tax credit: the basic and couple elements, 1_960 + 2_010 = + # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts + # as Pension Credit income (SPCA 2002 s.15(1)(b)). + # B(e): the award lifts the tax credit income test (TCA 2002 s.7(2)), so + # working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over + # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs + # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the + # £212.97 couple threshold: nil. + # B(0): Pension Credit on 3_970 would be nil too, so it gives no + # passport. The income test applies: 41% of income over £6,420 comes off, + # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - + # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) pays, so para + # 6(1)(d) bars the award, and the couple gets B(0). + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + working_tax_credit: 2_502.20 + pension_credit: 370.40 + +- name: Case 20, in 2018 a couple aged 66 and 58 whose income Pension Credit refuses keep support-group ESA and both benefit cap exceptions. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + date_of_birth: 19520701 + gender: MALE + is_claimant_or_partner: true + # £200 a week. + private_pension_income: 10_400 + person2: + age: 58 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 2_537.60 + esa_includes_support_component: true + # £124 a week for 15 hours. + employment_income: 6_448 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2018-19 the couple minimum guarantee is £248.80 a week. B(0): 200 + + # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so + # nil either way. (Each member's income is under the £11,850 personal + # allowance, and £124 a week is under the £162 National Insurance primary + # threshold, so neither takes anything off.) Para 6(1)(d) does not bar the + # award. + pension_credit: 0 + esa_income: 2_537.60 + claimant_or_partner_esa_income: 2_537.60 + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the award with its support component. + is_uc_benefit_cap_exempt_specified_benefit: true + is_housing_benefit_benefit_cap_exempt_specified_benefit: true + # So no cap applies: uc_benefit_cap is unlimited. + is_uc_benefit_cap_exempt: true diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 87650488be..8c805bb502 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,5 +1,58 @@ from policyengine_uk.model_api import * +# The branch in which the family has no income-related ESA. +NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" + + +def pension_credit_without_income_related_esa(benunit, period): + """Pension Credit worked out with no income-related ESA in the family. + + Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's + income-related ESA while the claimant, or the other member of the couple, + is entitled to State Pension Credit, and Pension Credit reads that award. + Its higher earnings disregard counts the couple's award + (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para + 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and + the tax credit income test reads the award (TCA 2002 s.7(2)), as does + Income Support's gate, which that test reads. Pension Credit's route test + (left_pension_route_at_legacy_closure) reads Universal Credit + entitlement, whose work capability test reads esa_income + (has_own_esa_award). Reading Pension Credit in the simulation itself + would be circular. + + This calculates Pension Credit in a branch in which esa_income, the + couple's award and esa_income_eligible are nil, so the screen never runs + there. The branch reads pension_credit, so the claim (would_claim_pc) + and an entered award count. Where it pays, the bar holds: the couple's + award is nil, and the simulation's own Pension Credit then equals the + branch's. Where it pays nothing, the award stands. If the award then + makes Pension Credit pay, through the higher earnings disregard, no + consistent state exists: Pension Credit would end the award, which would + end Pension Credit. The award then stands beside the Pension Credit + worked out on it. + + In a year with tax credits, this branch reaches the passport branch + (pension_credit_with_passported_tax_credits), and the passport branch + reaches this one. A branch reads the inputs its parents set, so a branch + of either keeps both. + """ + simulation = benunit.simulation + # get_branch returns an existing branch of that name, or this simulation + # if it has that name, so take a name in use by neither. + name = NO_INCOME_RELATED_ESA_BRANCH + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + branch.set_input(variable, period, benunit.empty_array()) + branch.set_input("esa_income_eligible", period, benunit.filled_array(False)) + # Through the population, so a microsimulation returns an + # unweighted array. + return branch.populations[benunit.entity.key]("pension_credit", period) + finally: + simulation.branches.pop(name, None) + class esa_income_eligible(Variable): value_type = bool @@ -17,31 +70,37 @@ class esa_income_eligible(Variable): "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " "Neither the claimant nor the other member of the couple may be entitled to " "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's income-related ESA, so the model cannot read the Pension Credit " - "award here. As for Income Support (income_support_eligible), it takes the " - "couple to be on Pension Credit where the couple meets the Pension Credit age " - "conditions (meets_pension_credit_age_conditions) and would claim it " - "(would_claim_pc). That leaves out Pension Credit's means test, so the screen " - "also fails for a couple that test would refuse. The couple's Pension Credit " - "bars only the couple's claim, not that of a member who claims in their own " - "right. The claimant must not engage in remunerative work (para 6(1)(e); " - "esa_income_claimant_remunerative_work). The other member of the couple must " - "not engage in remunerative work (para 6(1)(f)): paid work of 24 hours a week " - "or more (ESA Regs 2008 reg 42(1)), subject to the existing carer exception " - "(reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model reads as " - "is_carer_for_benefits. Assessable capital must remain within the existing " - "limit (para 6(1)(b); reg 110). The model applies neither para 6(1)(g) " - "(receiving education) nor the other member's income-based JSA under para " - "6(1)(d). The Welfare Reform Act (Northern Ireland) 2007 sets the same " - "conditions in the same words, and Northern Ireland's pensionable age " - "(Pensions (Northern Ireland) Order 1995 Sch 2 para 1) follows the same " - "timetable, so one rule covers the UK. The aggregate esa_income uses this " - "screen for its original couple's part; each outside reporter's claim is paid " - "separately on their own existing work/capital screen, pensionable age and " - "tariff. The benefit unit's capital is an explicit proxy for unobserved " - "outside-claim capital. Reports whose own award is closed are excluded before " - "choosing candidates; ESA also excludes the couple's reports on a UC claim. " - "This variable's candidate rules are unchanged by the separate aggregation." + "couple's award, so the screen reads Pension Credit worked out with no " + "income-related ESA in the family " + "(pension_credit_without_income_related_esa), which counts the claim " + "(would_claim_pc) and an entered award, and bars the couple's claim where it " + "pays more than nil. It works that out only for a population in which some " + "couple has an open report, meets the Pension Credit age conditions " + "(meets_pension_credit_age_conditions) and would claim, and bars only such a " + "couple. Where Pension Credit pays nothing without the award but pays with " + "it, through the higher earnings disregard the award brings (SPC Regs 2002 " + "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " + "both. The couple's Pension Credit bars only the couple's claim, not that of " + "a member who claims in their own right. The claimant must not engage in " + "remunerative work (para 6(1)(e); esa_income_claimant_remunerative_work). The " + "other member of the couple must not engage in remunerative work (para " + "6(1)(f)): paid work of 24 hours a week or more (ESA Regs 2008 reg 42(1)), " + "subject to the existing carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B " + "para 4), which the model reads as is_carer_for_benefits. Assessable capital " + "must remain within the existing limit (para 6(1)(b); reg 110). The model " + "applies neither para 6(1)(g) (receiving education) nor the other member's " + "income-based JSA under para 6(1)(d). The Welfare Reform Act (Northern " + "Ireland) 2007 sets the same conditions in s.1(3)(c) and Sch 1 para 6(1)(c) " + "and (d), but its s.1(6) takes pensionable age from the Pensions (Northern " + "Ireland) Order 1995 Sch 2 para 1, not the Pensions Act 1995 Sch 4 para 1. " + "The two give the same pensionable age for every date of birth, so one rule " + "covers the UK. The aggregate esa_income uses this screen for its original " + "couple's part; each outside reporter's claim is paid separately on their own " + "existing work/capital screen, pensionable age and tariff. The benefit unit's " + "capital is an explicit proxy for unobserved outside-claim capital. Reports " + "whose own award is closed are excluded before choosing candidates; ESA also " + "excludes the couple's reports on a UC claim. This variable's candidate rules " + "are unchanged by the separate aggregation." ) definition_period = YEAR reference = ( @@ -78,22 +137,22 @@ def formula(benunit, period, parameters): # born before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Reading the - # Pension Credit amount here would be circular: its higher earnings - # disregard reads this award (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), - # and so does the working tax credit income test (TCA 2002 s.7(2)), - # and working tax credit counts as Pension Credit income. As - # income_support_eligible does for SSCBA 1992 s.124(1)(g), the couple - # is taken to be on Pension Credit where it meets the Pension Credit - # age conditions and would claim it. That leaves out the means test: a - # couple Pension Credit's means test would refuse is still taken to be - # on it. A family that left the Pension Credit route when DWP moved it - # to Universal Credit claims Universal Credit, which already ends the - # couple's award (esa_income_report_closed). The couple's Pension - # Credit does not bar a member who claims in their own right. - on_pension_credit = benunit( - "meets_pension_credit_age_conditions", period - ) & benunit("would_claim_pc", period) + # Pension Credit, nor is the other member of the couple. Pension + # Credit reads the couple's award, so this reads Pension Credit worked + # out without it, for a couple with an open report that meets the + # Pension Credit age conditions and would claim, and only where some + # couple does. The couple's Pension Credit does not bar a member who + # claims in their own right. + could_be_on_pension_credit = ( + couple_reports + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + ) + on_pension_credit = could_be_on_pension_credit + if could_be_on_pension_credit.any(): + on_pension_credit = could_be_on_pension_credit & ( + pension_credit_without_income_related_esa(benunit, period) > 0 + ) pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) From e7f5b0a58182e47acd817d1b19cde9f46ed7fa3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:13:44 -0400 Subject: [PATCH 027/168] Test the Pension Credit bar against a simulation without the award The property test now builds, for each example, a second simulation of the same people with no income-related ESA reported, whose Pension Credit is the bar's B(0) with no branch. Where that pays nothing, take-up leaves the couple's ESA alone; where it pays, the couple's ESA is nil and Pension Credit equals it. The generator now draws earnings, pensions, savings and, in tax-credit years, working tax credit. Two tests count the branches: none opens without a couple that could be on Pension Credit, none outlives the calculation, and the passport branch inside it reads the award as nil. The four property tests built a fresh tax-benefit system per example (about a minute each under load); one combined test on the cloned system builds two simulations per example and five examples. The shared reference now accepts tax credit reports in years with tax credits, and the Income Support tests pass the ESA screen entitlement (the couple would claim and Pension Credit pays) in place of the proxy. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 36 +- .../test_esa_income_pension_age_properties.py | 530 +++++++++++++----- ...t_income_support_eligibility_properties.py | 29 +- ...t_gate_reads_claimant_or_partner_awards.py | 12 +- 4 files changed, 433 insertions(+), 174 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index e6da1c1c96..febea436dc 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -33,15 +33,17 @@ some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born before 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant - nor the other member of the couple may be entitled to it. The model reads - this as the couple meeting the Pension Credit age conditions and claiming - it; a caller passes the model's reading (on_pension_credit). Otherwise no - member reports Pension Credit or Housing Benefit, so no mixed-age couple - keeps the SI 2019/37 art 4 saving, the exclusion of mixed-age couples is in - force (SPCA 2002 s.4(1A)) and every couple would claim, and a couple is on - it when every member has reached the qualifying age, which from 2019-20 is - pensionable age for everyone (SPCA 2002 s.1(6)). It bars only the couple's - claim, not that of a member claiming in their own right. + nor the other member of the couple may be entitled to it. A caller that + knows passes whether the couple is entitled (on_pension_credit): it would + claim, and Pension Credit worked out with no income-related ESA in the + family pays. Otherwise no member reports Pension Credit or Housing + Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the + exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a + couple every member of which has reached the qualifying age can then be + entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 + s.1(6)), so s.1(3)(c) bars each of them whether or not Pension Credit + pays. It bars only the couple's claim, not that of a member claiming in + their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -180,8 +182,9 @@ def reached_pensionable_age(adult): def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing - Benefit reports in 2025 or 2026: every member has reached the qualifying - age, so the couple meets the age conditions and would claim.""" + Benefit reports in 2025 or 2026: a couple every member of which has + reached the qualifying age, each of whom has also reached pensionable + age, so s.1(3)(c) bars them whether or not Pension Credit pays.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) @@ -195,12 +198,13 @@ def legacy_benefits_closed(couple, others, parameters): Reports by other members neither close nor shield it (SI 2014/1230 regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a - notice/deadline model. No tax-credit reports are generated here. Ages - avoid the changing Pension Credit qualifying-age boundary. + notice/deadline model. Tax-credit reports are generated only in years + with tax credits, in which they close nothing. Ages avoid the changing + Pension Credit qualifying-age boundary. """ dwp = parameters.gov.dwp members = couple + others - assert not any( + assert dwp.tax_credits.active or not any( member.get("child_tax_credit_reported", 0) or member.get("working_tax_credit_reported", 0) for member in members @@ -242,8 +246,8 @@ def candidates(couple, others, reported, parameters): def esa_screen(couple, others, capital, parameters, on_pension_credit=None): """esa_income_eligible for a claimant and partner (couple) and other members of the benefit unit outside the family (others). on_pension_credit - is whether the couple is taken to be on State Pension Credit, if the - caller reads it from the model.""" + is whether the couple is entitled to State Pension Credit, if the caller + knows it.""" ESA = parameters.gov.dwp.ESA.income members = couple + others diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index c7833628a3..5c06afc49f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -1,26 +1,33 @@ -"""Income-related ESA is never paid where pensionable age or Pension Credit bars it. +"""Income-related ESA gives way to State Pension Credit only where Pension Credit pays. Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member -of the couple, is entitled to State Pension Credit. The model takes the couple -to be entitled where it meets the Pension Credit age conditions and would -claim it (meets_pension_credit_age_conditions and would_claim_pc), as Income -Support does for SSCBA 1992 s.124(1)(g). For every family drawn here: - -- no family gets both: where the model pays the couple Pension Credit, or - takes it to be on Pension Credit, the couple's income-related ESA - (claimant_or_partner_esa_income) is nil; -- nobody who has reached pensionable age is on income-related ESA - (is_on_income_related_esa), and a family whose every report comes from such - a member gets none (esa_income); +of the couple, is entitled to State Pension Credit. Pension Credit reads the +couple's award, so the model reads the couple's Pension Credit worked out with +no income-related ESA in the family, B(0), and bars the couple's claim where it +pays. + +Each example builds a second, separate simulation of the same people with no +income-related ESA reported. Its pension_credit is B(0), worked out with no +branch. Against it, for every family drawn here: + +- where B(0) pays nothing, whether the couple would claim Pension Credit + changes neither the couple's income-related ESA + (claimant_or_partner_esa_income) nor the family's (esa_income); +- where B(0) pays, the couple would claim it and has an open report, the + couple's income-related ESA is nil and the family's Pension Credit is B(0); +- claiming Pension Credit never raises income-related ESA; - esa_income_eligible equals the family-by-family reading of the law in legacy_award_work_reference (esa_screen), given the model's pensionable age - (is_SP_age) and Pension Credit proxy, so the bars combine with the work and - capital tests as the law sets them; -- claiming Pension Credit never raises income-related ESA: the same families - with would_claim_pc false get at least as much, and exactly as much where - the couple does not meet the Pension Credit age conditions. + (is_SP_age) and B(0); +- nobody who has reached pensionable age is on income-related ESA + (is_on_income_related_esa), and a family whose every report comes from such + a member gets none. + +A family whose Pension Credit pays only with the award, through the higher +earnings disregard the award brings, gets both: no state is consistent there. +The test counts such families and asserts nothing more of them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -28,220 +35,447 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults work some hours but have no pay, so only the other member's 24 hours -(para 6(1)(f)) and capital above £16,000 (para 6(1)(b)) bite among the older -tests; test_legacy_award_work_properties.py covers earnings. A member outside -the couple claims in their own right: the couple's Pension Credit does not bar -them, and pensionable age does. Everyone is a legacy claimant (would_claim_uc -false). Roles are given explicitly (is_claimant_or_partner). Each example -builds many families in one simulation, in separate benefit units. +Adults have earnings, pensions and household savings, so Pension Credit's +means test pays some families and refuses others, and in 2016 and 2018 some +report working tax credit, whose income test the award lifts (TCA 2002 +s.7(2)). A member outside the couple claims in their own right: the couple's +Pension Credit does not bar them, and pensionable age does. + +Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving +would otherwise read the ESA report, so the generator enters them, and no one +reports Housing Benefit, whose saving route also reads it. The test asserts +that the two simulations agree on the Pension Credit age conditions. Roles are +given explicitly (is_claimant_or_partner). Each example builds many families +in one simulation, in separate benefit units, with the take-up copies in the +same simulation. Simulations take most of the time here, so each example +builds two and the file builds few. + +Separate tests count the branches the model opens: a population with no couple +that could be on Pension Credit opens none, a branch never outlives the +calculation, and the tax credit passport branch inside it reads the couple's +award as nil. """ import numpy as np +import pytest from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st +from policyengine_core.simulations import Simulation as CoreSimulation -from legacy_award_work_reference import esa_screen from policyengine_uk import Simulation +from policyengine_uk.tests.legacy_award_work_reference import esa_screen +from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( + NO_INCOME_RELATED_ESA_BRANCH, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEARS = [2016, 2018, 2026] # Either side of the State Pension Credit qualifying age and of pensionable # age in each year drawn, including men aged 62 to 64 in 2016. -AGES = [30, 45, 58, 60, 61, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] -# 0 and 24 hours a week. -HOURS = [0, 0, 0, 1_248] +AGES = [30, 45, 58, 60, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] +# 0, 10, 15 and 24 hours a week. +HOURS = [0, 0, 520, 780, 1_248] +# £50, £100 and about £192 a week. +EARNINGS = [0, 0, 2_600, 5_200, 10_000] +# £40, £61 and £200 a week. +PENSIONS = [0, 0, 2_080, 3_172, 10_400] +SAVINGS = [0, 0, 8_000, 14_000, 40_000] @st.composite -def adults(draw, outside=False): +def adults(draw, outside=False, tax_credits=False): adult = { "age": draw(st.sampled_from(AGES)), "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), "gender": draw(st.sampled_from(["MALE", "FEMALE"])), - "esa_income_reported": draw(st.sampled_from([0, 3_000, 3_000])), + "esa_income_reported": draw(st.sampled_from([0, 2_500, 5_000])), + "esa_includes_support_component": draw(st.booleans()), "hours_worked": draw(st.sampled_from(HOURS)), + "employment_income": draw(st.sampled_from(EARNINGS)), + "private_pension_income": draw(st.sampled_from(PENSIONS)), } if outside: adult["current_education"] = "NOT_IN_EDUCATION" + elif tax_credits: + adult["working_tax_credit_reported"] = draw(st.sampled_from([0, 0, 0, 1])) return adult @st.composite -def families(draw): +def families(draw, tax_credits): """A claimant, an optional partner, an optional member outside the couple, - capital and the couple's saving and Pension Credit take-up.""" - couple = [draw(adults())] + the couple's saving and the household's savings. Members of the couple + report working tax credit only in a year with tax credits.""" + couple = [draw(adults(tax_credits=tax_credits))] if draw(st.booleans()): - couple.append(draw(adults())) + couple.append(draw(adults(tax_credits=tax_credits))) others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] - benunit = { - "esa_income_assessable_capital": draw(st.sampled_from([0, 0, 0, 20_000])), - "has_mixed_age_couple_pension_credit_saving": draw(st.booleans()), - "would_claim_pc": draw(st.sampled_from([True, True, False])), - } - return couple, others, benunit + saving = draw(st.booleans()) + return couple, others, saving, draw(st.sampled_from(SAVINGS)) + + +@st.composite +def examples(draw): + year = draw(st.sampled_from(YEARS)) + # Tax credits ended on 5 April 2025. + tax_credits = year < 2025 + units = draw(st.lists(families(tax_credits), min_size=4, max_size=12)) + return units, year -def situation(units, year): +def situation(units, year, would_claim_pc, reports=True): + """Each family once for each take-up in would_claim_pc. Without reports, + nobody reports income-related ESA.""" people, benunits, households = {}, {}, {} - for i, (couple, others, benunit) in enumerate(units): + copies = [(unit, claims) for claims in would_claim_pc for unit in units] + for i, ((couple, others, saving, savings), claims) in enumerate(copies): names = [] - for j, (inputs, claimant_or_partner) in enumerate( - [(m, True) for m in couple] + [(m, False) for m in others] - ): + members = [(m, True) for m in couple] + [(m, False) for m in others] + for j, (inputs, claimant_or_partner) in enumerate(members): name = f"p{i}_{j}" - people[name] = {k: {year: v} for k, v in inputs.items()} - people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} + values = dict(inputs, is_claimant_or_partner=claimant_or_partner) + if not reports: + values.pop("esa_income_reported") + people[name] = {k: {year: v} for k, v in values.items()} names.append(name) benunits[f"b{i}"] = { "members": names, "would_claim_uc": {year: False}, - **{k: {year: v} for k, v in benunit.items()}, + "claims_all_entitled_benefits": {year: False}, + "has_mixed_age_couple_pension_credit_saving": {year: saving}, + "would_claim_pc": {year: claims}, } - households[f"h{i}"] = {"members": names} + households[f"h{i}"] = {"members": names, "savings": {year: savings}} return {"people": people, "benunits": benunits, "households": households} -def label(units, year): - event(f"year {year}") - for couple, others, _ in units: - if len(couple) == 2: - event("couple") - if others: - event("member outside the couple") +def couple_reports(sim, year): + """Whether the couple has an open report of income-related ESA.""" + open_report = (sim.calculate("esa_income_reported", year) > 0) & ~sim.calculate( + "esa_income_report_closed", year + ) + couple = sim.calculate("is_claimant_or_partner", year) + return sim.populations["benunit"].sum(open_report & couple) > 0 -NO_REPORT = {"months_since_last_birthday": 0, "esa_income_reported": 0} -# A member outside the couple who reports income-related ESA beside a -# single claimant aged 70 on Pension Credit, and one aged 70 themselves. -OUTSIDE_REPORTERS = [ +# Each pairing of B(0) and B(e) in 2016, for a man aged 63 born 1 March 1953 +# (esa_income_pension_age.yaml cases 15, 16 and 18), the couple of case 19 +# whose working tax credit decides it, and a member outside the couple +# reporting beside a pensioner on Pension Credit. +MAN = { + "age": 63, + "months_since_last_birthday": 6, + "gender": "MALE", + "esa_includes_support_component": True, + "working_tax_credit_reported": 0, +} +ROWS = [ + ( + [ + dict( + MAN, + esa_income_reported=reported, + employment_income=earnings, + hours_worked=hours, + private_pension_income=pension, + ) + ], + [], + False, + 0, + ) + for reported, earnings, hours, pension in [ + (6_011.20, 2_600, 520, 2_080), + (4_919.20, 6_006, 780, 3_172), + (5_491.20, 6_006, 780, 2_600), + ] +] + [ + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 1_040, + "employment_income": 10_000, + "private_pension_income": 0, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 3_000, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), ( - [{"age": 70, "gender": "FEMALE", "hours_worked": 0, **NO_REPORT}], [ { - "age": age, + "age": 70, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + } + ], + [ + { + "age": 40, "months_since_last_birthday": 0, "gender": "MALE", "esa_income_reported": 3_000, + "esa_includes_support_component": False, "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, "current_education": "NOT_IN_EDUCATION", } ], - { - "esa_income_assessable_capital": 0, - "has_mixed_age_couple_pension_credit_saving": False, - "would_claim_pc": True, - }, - ) - for age in [40, 70] + False, + 0, + ), ] -SETTINGS = settings( - max_examples=12, +@settings( + max_examples=4, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -FAMILIES = st.lists(families(), min_size=1, max_size=8) - - -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -def test_no_family_gets_pension_credit_and_income_related_esa(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) +@given(examples()) +@example((ROWS, 2016)) +def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): + units, year = drawn + n = len(units) + event(f"year {year}") + sim = Simulation(situation=situation(units, year, [True, False])) + oracle = Simulation(situation=situation(units, year, [True], reports=False)) + route = sim.calculate("meets_pension_credit_age_conditions", year) + # The oracle's Pension Credit reads the same age conditions. + assert ( + route[:n] == oracle.calculate("meets_pension_credit_age_conditions", year) + ).all() + assert (route[:n] == route[n:]).all() + without_award = oracle.calculate("pension_credit", year) pension_credit = sim.calculate("pension_credit", year) - on_pension_credit = sim.calculate( - "meets_pension_credit_age_conditions", year - ) & sim.calculate("would_claim_pc", year) couple_esa = sim.calculate("claimant_or_partner_esa_income", year) - if (on_pension_credit & (couple_esa == 0)).any(): - event("a couple on Pension Credit") - if (~on_pension_credit & (couple_esa > 0)).any(): - event("a couple paid income-related ESA") - # Pension Credit is paid only on the Pension Credit route, to a family - # that would claim it (is_pension_credit_eligible, pension_credit). - assert not ((pension_credit > 0) & ~on_pension_credit).any() - # Para 6(1)(c) and (d). - assert (couple_esa[on_pension_credit] == 0).all(), units - assert not ((pension_credit > 0) & (couple_esa > 0)).any(), units - - -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS, 2026) -def test_nobody_over_pensionable_age_is_on_income_related_esa(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) - pensionable_age = sim.calculate("is_SP_age", year) - on_esa = sim.calculate("is_on_income_related_esa", year) esa = sim.calculate("esa_income", year) - if (pensionable_age & ~on_esa).any(): - event("someone over pensionable age") - # s.1(3)(c): the award is not payable to them. - assert not (pensionable_age & on_esa).any(), units - start = 0 - for i, (couple, others, _) in enumerate(units): - members = couple + others - flags = pensionable_age[start : start + len(members)] - reporters = [m["esa_income_reported"] > 0 for m in members] - if any(reporters) and all(f for f, r in zip(flags, reporters) if r): - event("every report from someone over pensionable age") - assert esa[i] == 0, units[i] - start += len(members) + reports = couple_reports(sim, year) + claims, declines = slice(0, n), slice(n, 2 * n) + + pays = without_award > 0 + barred = pays & reports[claims] + refused = ~pays + for label, mask in [ + ("Pension Credit pays without the award", barred), + ("Pension Credit refuses a couple with a report", refused & reports[claims]), + ( + "both awards paid", + (couple_esa[claims] > 0) & (pension_credit[claims] > 0), + ), + ]: + if mask.any(): + event(label) + # Sch 1 para 6(1)(c) and (d): where Pension Credit pays without the award, + # the couple's award is nil and the family gets that Pension Credit. + assert (couple_esa[claims][barred] == 0).all(), units + assert np.allclose( + pension_credit[claims][barred], without_award[barred], atol=0.01 + ), units + # Where it pays nothing, take-up changes no award. + assert np.allclose( + couple_esa[claims][refused], couple_esa[declines][refused], atol=0.01 + ), units + assert np.allclose(esa[claims][refused], esa[declines][refused], atol=0.01), units + # Claiming Pension Credit never raises income-related ESA. + assert (esa[claims] <= esa[declines] + 0.01).all(), units -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS[:1], 2026) -@example(OUTSIDE_REPORTERS[1:], 2026) -def test_screen_matches_a_family_by_family_reading(units, year): - label(units, year) - sim = Simulation(situation=situation(units, year)) + # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) pensionable_age = sim.calculate("is_SP_age", year) - on_pension_credit = sim.calculate( - "meets_pension_credit_age_conditions", year - ) & sim.calculate("would_claim_pc", year) + on_esa = sim.calculate("is_on_income_related_esa", year) + capital = sim.calculate("esa_income_assessable_capital", year) parameters = sim.tax_benefit_system.parameters(year) - if eligible.any(): - event("some screen passes") + # s.1(3)(c): the award is not payable to anyone over pensionable age. + assert not (pensionable_age & on_esa).any(), units start = 0 - for i, (couple, others, benunit) in enumerate(units): + for i, (unit, claimed) in enumerate( + [(unit, True) for unit in units] + [(unit, False) for unit in units] + ): + couple, others, _, _ = unit members = [ dict(member, is_SP_age=pensionable_age[start + j]) for j, member in enumerate(couple + others) ] + on_pension_credit = claimed and route[i % n] and pays[i % n] expected = esa_screen( members[: len(couple)], members[len(couple) :], - benunit["esa_income_assessable_capital"], + capital[i], parameters, - on_pension_credit=on_pension_credit[i], + on_pension_credit=on_pension_credit, ) - assert eligible[i] == expected, (year, units[i]) + assert eligible[i] == expected, (year, claimed, unit) + reporters = [m["esa_income_reported"] > 0 for m in members] + if any(reporters) and all( + m["is_SP_age"] for m, r in zip(members, reporters) if r + ): + assert esa[i] == 0, (year, unit) start += len(members) -@SETTINGS -@given(FAMILIES, st.sampled_from(YEARS)) -@example(OUTSIDE_REPORTERS, 2026) -def test_claiming_pension_credit_never_raises_income_related_esa(units, year): - label(units, year) - n = len(units) +class BranchLog: + """Records each branch Core creates, with the simulation it branches.""" + + def __init__(self, monkeypatch): + self.created = [] + get_branch = CoreSimulation.get_branch + + def logged(simulation, name="branch", clone_system=False): + new = name not in simulation.branches and name != simulation.branch_name + branch = get_branch(simulation, name, clone_system) + if new: + self.created.append((simulation, branch)) + return branch - def claiming(claims): + monkeypatch.setattr(CoreSimulation, "get_branch", logged) + + def bar_branches(self): return [ - (couple, others, dict(benunit, would_claim_pc=claims)) - for couple, others, benunit in units + (parent, branch) + for parent, branch in self.created + if branch.branch_name.startswith(NO_INCOME_RELATED_ESA_BRANCH) ] - sim = Simulation(situation=situation(claiming(True) + claiming(False), year)) - esa = sim.calculate("esa_income", year) - route = sim.calculate("meets_pension_credit_age_conditions", year) - assert (route[:n] == route[n:]).all() - if (esa[n:] > esa[:n]).any(): - event("claiming Pension Credit ended an award") - assert (esa[:n] <= esa[n:] + 0.01).all(), units - assert np.allclose(esa[:n][~route[:n]], esa[n:][~route[:n]], atol=0.01), units + +def household(members, year, **benunit): + names = [f"p{j}" for j in range(len(members))] + return { + "people": { + name: {k: {year: v} for k, v in inputs.items()} + for name, inputs in zip(names, members) + }, + "benunits": { + "b": { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + } + }, + "households": {"h": {"members": names}}, + } + + +def combine(*situations): + combined = {"people": {}, "benunits": {}, "households": {}} + for i, part in enumerate(situations): + for entity, units in part.items(): + for name, unit in units.items(): + unit = dict(unit) + if "members" in unit: + unit["members"] = [f"{m}_{i}" for m in unit["members"]] + combined[entity][f"{name}_{i}"] = unit + return combined + + +def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( + monkeypatch, +): + """In 2026 no couple here has an open report and meets the Pension Credit + age conditions: a working-age claimant reporting the award, a couple aged + 70 and 60 without the saving (case 4's kind), a pensioner without a report, + and a member outside the couple reporting beside a pensioner.""" + log = BranchLog(monkeypatch) + year = 2026 + adult = {"is_claimant_or_partner": True} + sim = Simulation( + situation=combine( + household([dict(adult, age=45, esa_income_reported=5_000)], year), + household( + [dict(adult, age=70), dict(adult, age=60, esa_income_reported=5_000)], + year, + ), + household([dict(adult, age=75)], year), + household( + [ + dict(adult, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 5_000, + }, + ], + year, + ), + ) + ) + assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000] + sim.calculate("household_net_income", year) + assert log.bar_branches() == [] + + +def test_the_branch_closes_and_its_passport_branch_reads_no_award(monkeypatch): + """In 2016 the couple of esa_income_pension_age.yaml case 19 meets the + Pension Credit age conditions with an open report, so the model opens the + branch, and its working tax credit opens the passport branch inside it + (pension_credit_with_passported_tax_credits). That branch reads the + award as nil, set on its parent, and no branch outlives the calculation.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=household( + [ + { + "age": 66, + "is_claimant_or_partner": True, + "employment_income": 10_000, + "hours_worked": 1_040, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "is_claimant_or_partner": True, + "esa_income_reported": 3_000, + }, + ], + year, + ) + ) + assert sim.calculate("esa_income", year).tolist() == [0] + assert sim.calculate("pension_credit", year) == pytest.approx([370.40], abs=0.01) + bar_branches = log.bar_branches() + assert bar_branches + passports = [ + branch + for parent, branch in log.created + if any(parent is bar for _, bar in bar_branches) + and branch.branch_name.startswith("tax_credits_pension_credit_passport") + ] + assert passports + for passport in passports: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + assert passport.calculate(variable, year).tolist() == [0], variable + for parent, branch in bar_branches: + assert all(other is not branch for other in parent.branches.values()) + assert not [ + name for name in sim.branches if name.startswith(NO_INCOME_RELATED_ESA_BRANCH) + ] diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index f9ba9889ec..c494b0446e 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -58,9 +58,12 @@ model's reported or proxy inputs; it reads the qualifying age (has_attained_state_pension_credit_qualifying_age) and the model's proxy for being on Pension Credit (meets_pension_credit_age_conditions and -would_claim_pc) from the model, and so, for the income-related ESA screen, -pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and the same Pension Credit -proxy (Sch 1 para 6(1)(c) and (d)); and the means test is out of scope. No +would_claim_pc) from the model, and, for the income-related ESA screen, +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether Pension Credit +pays the couple (Sch 1 para 6(1)(c) and (d)): no one drawn here has earnings, +and in 2025 no one has tax credits, so the model's pension_credit is the same +with or without the couple's income-related ESA; and the means test is out of +scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit (only some added adults do), so in 2025 no family with a claimant or partner under the qualifying age meets the Pension Credit age conditions. The @@ -398,8 +401,14 @@ def reference_eligibility( attained_qualifying_age, on_pension_credit, parameters, + entitled_to_pension_credit=False, ): - """The model's Income Support gate, read family by family.""" + """The model's Income Support gate, read family by family. + + on_pension_credit is the model's proxy for s.124(1)(g); + entitled_to_pension_credit is whether Pension Credit pays the couple, + which the income-related ESA screen reads (WRA 2007 Sch 1 para 6(1)(c) + and (d)).""" IS = parameters.gov.dwp.income_support WORK = IS.eligibility.remunerative_work JSA = parameters.gov.dwp.JSA.income @@ -451,7 +460,11 @@ def is_claimant(i): capital, parameters.gov.dwp.ESA.income.capital, ) and esa_screen( - adults, [], capital, parameters, on_pension_credit=on_pension_credit + adults, + [], + capital, + parameters, + on_pension_credit=entitled_to_pension_credit, ) if jsa_income is not None: # Within half a penny of zero is no award. @@ -515,6 +528,9 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) + # Entitlement for the ESA screen: Pension Credit pays. Without earnings + # or tax credits, it pays the same with the couple's award as without. + entitled = on_pension_credit & (sim.calculate("pension_credit", YEAR) > 0) pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 @@ -548,7 +564,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): others, capital, parameters, - on_pension_credit=on_pension_credit[i], + on_pension_credit=entitled[i], ), ): entered_esa = esa_income[i] @@ -574,6 +590,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): attained_qualifying_age[start : start + len(adults)], on_pension_credit[i], parameters, + entitled_to_pension_credit=entitled[i], ) assert eligible[i] == expected, units[i] # The docstring's claim: no family drawn here with a claimant or diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index ca34dbf5c3..86734bb1fb 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -79,10 +79,14 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen - # Sch 1 para 6(1)(c) and (d), through the model's proxy for the couple - # being on State Pension Credit. It never bars an outside member's claim. - on_pension_credit = benunit("meets_pension_credit_age_conditions", period) & ( - benunit("would_claim_pc", period) + # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension Credit + # and it pays. No one here has earnings, and in 2025 no one has tax + # credits, so Pension Credit pays the same with the couple's award as + # without. It never bars an outside member's claim. + on_pension_credit = ( + benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + & (benunit("pension_credit", period) > 0) ) def passes(members, unit, couple): From 1971645a3acd51ba3ea11d3d49e813ddc0a385d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:17:42 -0400 Subject: [PATCH 028/168] Cite pensionable-age rules by their numbers and restate the changelog Pensions Act 1995 Sch 4 para 1 gives 66 to a person born from 6 October 1954 to 5 April 1960 under rule 6, 66 and some months under rule 7 and 67 under rule 8. The changelog now states the bar on Pension Credit worked out without the award. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- policyengine_uk/tests/legacy_award_work_reference.py | 2 +- .../benefit/family/esa_income/esa_income_pension_age.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index 153420156d..3ddcaa3308 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple it takes to be on State Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit, worked out without that award, pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index febea436dc..b92009199a 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -30,7 +30,7 @@ caller that reads the model's pensionable age passes it as is_SP_age; otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone aged 64 or under was born after 5 October 1960 and reaches it at 66 and - some months or at 67 (rules 8 and 9), and anyone aged 70 or over was born + some months or at 67 (rules 7 and 8), and anyone aged 70 or over was born before 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index da2a2e5b8b..3c113c0deb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -39,7 +39,7 @@ household: members: [person1] output: - # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 7). + # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 6). is_SP_age: true # s.1(3)(c) bars the claim: 0, not the 5_000 reported. esa_income_eligible: false @@ -70,7 +70,7 @@ members: [person1, person2] output: # Both born after 5 October 1954 and before 6 April 1960: pensionable - # age 66 (rule 7). + # age 66 (rule 6). is_SP_age: [true, true] meets_pension_credit_age_conditions: true # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it From d37e42de921f2593e86343c869c62bdb458abe54 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 14:55:40 -0400 Subject: [PATCH 029/168] Open the Pension Credit branch only for a claim the bar could end The bar can change the screen only for a couple whose claim passes the age, work and capital tests, so the branch now opens only where such a couple meets the Pension Credit age conditions and would claim. Each family's result is unchanged. The Universal Credit modifier reads ESA for later years when the simulation is built, and in those years many reporters have reached pensionable age; the branch no longer opens for them. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 14 +++-- .../variables/gov/dwp/esa_income_eligible.py | 53 +++++++++---------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 5c06afc49f..3b37946e52 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -399,10 +399,12 @@ def combine(*situations): def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( monkeypatch, ): - """In 2026 no couple here has an open report and meets the Pension Credit - age conditions: a working-age claimant reporting the award, a couple aged - 70 and 60 without the saving (case 4's kind), a pensioner without a report, - and a member outside the couple reporting beside a pensioner.""" + """In 2026 no couple here has a claim that passes the other tests and + meets the Pension Credit age conditions: a working-age claimant reporting + the award, a couple aged 70 and 60 without the saving (case 4's kind), a + pensioner without a report, a member outside the couple reporting beside + a pensioner, and a pensioner reporting the award, whose claim pensionable + age bars whatever Pension Credit pays.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -426,9 +428,11 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( ], year, ), + household([dict(adult, age=75, esa_income_reported=5_000)], year), ) ) - assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000] + assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000, 0] + assert sim.calculate("meets_pension_credit_age_conditions", year)[-1] sim.calculate("household_net_income", year) assert log.bar_branches() == [] diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 8c805bb502..ec3c9a1767 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -75,9 +75,10 @@ class esa_income_eligible(Variable): "(pension_credit_without_income_related_esa), which counts the claim " "(would_claim_pc) and an entered award, and bars the couple's claim where it " "pays more than nil. It works that out only for a population in which some " - "couple has an open report, meets the Pension Credit age conditions " - "(meets_pension_credit_age_conditions) and would claim, and bars only such a " - "couple. Where Pension Credit pays nothing without the award but pays with " + "couple's claim passes the other tests and the couple meets the Pension " + "Credit age conditions (meets_pension_credit_age_conditions) and would claim, " + "and bars only such a couple. Where Pension Credit pays nothing without the " + "award but pays with " "it, through the higher earnings disregard the award brings (SPC Regs 2002 " "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " "both. The couple's Pension Credit bars only the couple's claim, not that of " @@ -136,24 +137,6 @@ def formula(benunit, period, parameters): # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man # born before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) - # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads Pension Credit worked - # out without it, for a couple with an open report that meets the - # Pension Credit age conditions and would claim, and only where some - # couple does. The couple's Pension Credit does not bar a member who - # claims in their own right. - could_be_on_pension_credit = ( - couple_reports - & benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - ) - on_pension_credit = could_be_on_pension_credit - if could_be_on_pension_credit.any(): - on_pension_credit = could_be_on_pension_credit & ( - pension_credit_without_income_related_esa(benunit, period) > 0 - ) - pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) # Para 6(1)(f) and reg 42(1): the other member of the couple works 24 @@ -167,11 +150,25 @@ def formula(benunit, period, parameters): other_member_works = claimant_or_partner & ( (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) - claimant = ( - candidate - & ~pensionable_age - & ~pension_credit_bars - & ~claimant_works - & ~other_member_works + passes = candidate & ~pensionable_age & ~claimant_works & ~other_member_works + within_capital = capital <= ESA.capital.limit + # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State + # Pension Credit, nor is the other member of the couple. Pension + # Credit reads the couple's award, so this reads Pension Credit worked + # out without it, for a couple whose claim passes the other tests and + # that meets the Pension Credit age conditions and would claim, and + # only where some couple does. The couple's Pension Credit does not + # bar a member who claims in their own right. + could_be_on_pension_credit = ( + benunit.any(passes & claimant_or_partner) + & within_capital + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) ) - return benunit.any(claimant) & (capital <= ESA.capital.limit) + on_pension_credit = could_be_on_pension_credit + if could_be_on_pension_credit.any(): + on_pension_credit = could_be_on_pension_credit & ( + pension_credit_without_income_related_esa(benunit, period) > 0 + ) + pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) + return benunit.any(passes & ~pension_credit_bars) & within_capital From 0d157f0781c02ed33cb2cf65aa9fb5a0f2fc4913 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 16:25:03 -0400 Subject: [PATCH 030/168] Name the actor in the Pension Credit bar's comments and changelog Comments, docstrings, the YAML header and the changelog now say what the model does in active voice. No formula changes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- .../tests/legacy_award_work_reference.py | 4 +-- .../esa_income/esa_income_pension_age.yaml | 4 +-- .../test_esa_income_pension_age_properties.py | 6 ++-- .../variables/gov/dwp/esa_income_eligible.py | 33 ++++++++++--------- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index 3ddcaa3308..c99d5ac52c 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit, worked out without that award, pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit without that award pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index b92009199a..efbe9685ec 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -35,8 +35,8 @@ - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would - claim, and Pension Credit worked out with no income-related ESA in the - family pays. Otherwise no member reports Pension Credit or Housing + claim, and the Pension Credit it would get with no income-related ESA in + the family pays. Otherwise no member reports Pension Credit or Housing Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 3c113c0deb..bde90721ca 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -5,7 +5,7 @@ # Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the # claimant "is not entitled to state pension credit" and "is not a member of a # couple the other member of which is entitled to ... state pension credit". -# The model reads the couple's Pension Credit worked out with no +# The model reads the Pension Credit the couple would get with no # income-related ESA in the family, B(0), and bars the couple's claim where it # pays. B(e) is the Pension Credit the family would get with the award e. # A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 @@ -474,7 +474,7 @@ # allowance (£11,000) and his earnings below the National Insurance primary # threshold (£155 a week), so neither takes anything off. -- name: Case 15, where Pension Credit pays with or without the award, the man gets Pension Credit worked out without it and no income-related ESA. +- name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 3b37946e52..e8d9ddb554 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -4,13 +4,13 @@ pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and (d) bar the income-related allowance where the claimant, or the other member of the couple, is entitled to State Pension Credit. Pension Credit reads the -couple's award, so the model reads the couple's Pension Credit worked out with +couple's award, so the model reads the Pension Credit the couple would get with no income-related ESA in the family, B(0), and bars the couple's claim where it pays. Each example builds a second, separate simulation of the same people with no -income-related ESA reported. Its pension_credit is B(0), worked out with no -branch. Against it, for every family drawn here: +income-related ESA reported. Its pension_credit gives B(0) without a branch. +Against it, for every family drawn here: - where B(0) pays nothing, whether the couple would claim Pension Credit changes neither the couple's income-related ESA diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index ec3c9a1767..4a3e156fcd 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -5,11 +5,12 @@ def pension_credit_without_income_related_esa(benunit, period): - """Pension Credit worked out with no income-related ESA in the family. + """The Pension Credit the family would get with no income-related ESA. Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's income-related ESA while the claimant, or the other member of the couple, - is entitled to State Pension Credit, and Pension Credit reads that award. + has an entitlement to State Pension Credit, and Pension Credit reads that + award. Its higher earnings disregard counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and @@ -18,18 +19,18 @@ def pension_credit_without_income_related_esa(benunit, period): (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income (has_own_esa_award). Reading Pension Credit in the simulation itself - would be circular. + would close a cycle. - This calculates Pension Credit in a branch in which esa_income, the - couple's award and esa_income_eligible are nil, so the screen never runs + This calculates Pension Credit in a branch that sets esa_income, the + couple's award and esa_income_eligible to nil, so the screen never runs there. The branch reads pension_credit, so the claim (would_claim_pc) - and an entered award count. Where it pays, the bar holds: the couple's - award is nil, and the simulation's own Pension Credit then equals the - branch's. Where it pays nothing, the award stands. If the award then - makes Pension Credit pay, through the higher earnings disregard, no - consistent state exists: Pension Credit would end the award, which would - end Pension Credit. The award then stands beside the Pension Credit - worked out on it. + and an entered award count. Where it pays, the bar holds: the screen + refuses the couple's award, and the simulation's own Pension Credit then + equals the branch's. Where it pays nothing, the award stands. If the + award then makes Pension Credit pay, through the higher earnings + disregard, no consistent state exists: Pension Credit would end the + award, which would end Pension Credit. The award then stands beside the + Pension Credit the model works out on it. In a year with tax credits, this branch reaches the passport branch (pension_credit_with_passported_tax_credits), and the passport branch @@ -70,8 +71,8 @@ class esa_income_eligible(Variable): "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " "Neither the claimant nor the other member of the couple may be entitled to " "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's award, so the screen reads Pension Credit worked out with no " - "income-related ESA in the family " + "couple's award, so the screen reads the Pension Credit the couple would get " + "with no income-related ESA in the family " "(pension_credit_without_income_related_esa), which counts the claim " "(would_claim_pc) and an entered award, and bars the couple's claim where it " "pays more than nil. It works that out only for a population in which some " @@ -154,8 +155,8 @@ def formula(benunit, period, parameters): within_capital = capital <= ESA.capital.limit # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads Pension Credit worked - # out without it, for a couple whose claim passes the other tests and + # Credit reads the couple's award, so this reads the couple's Pension + # Credit without it, for a couple whose claim passes the other tests and # that meets the Pension Credit age conditions and would claim, and # only where some couple does. The couple's Pension Credit does not # bar a member who claims in their own right. From 196fea803bceb68354f25deb708f48dc6e3a8b87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:26:15 -0400 Subject: [PATCH 031/168] Count Universal Credit carer-element awards in the severe disability carer test and Scottish working-age CTR carer premium The severe disability premium, the pension-age premium and the Pension Credit severe disability addition now treat a person as cared for when a carer's Universal Credit award includes the carer element, read from the claim and the award's conditions (has_uc_award_with_carer_element) because the award itself depends on Pension Credit. Scotland's working-age council tax reduction pays the carer premium for an award of Universal Credit which includes the carer element (SSI 2021/249 Sch 1 para 6(1)(c)) from 1 April 2022, through council_tax_reduction_carer_premium. Co-Authored-By: Claude Opus 5.5 --- ...carer_premium_counts_uc_carer_element.yaml | 15 ++++ ...e_disability_minimum_guarantee_addition.py | 4 +- .../has_uc_award_with_carer_element.py | 46 ++++++++++++ .../council_tax_reduction_carer_premium.py | 73 +++++++++++++++++++ .../council_tax_reduction_premiums.py | 16 +++- .../household/demographic/carer_premium.py | 11 ++- ...is_cared_for_by_carer_benefit_recipient.py | 67 +++++++++++------ .../pension_age_severe_disability_premium.py | 5 +- .../demographic/severe_disability_premium.py | 8 +- 9 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml new file mode 100644 index 0000000000..ad47b6bfb2 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml @@ -0,0 +1,15 @@ +description: Scotland's working-age Council Tax Reduction scheme pays the carer premium to an applicant or partner whose award of Universal Credit includes the carer element if this is true, and only on entitlement to Carer's Allowance or Carer Support Payment otherwise. +values: + 2013-04-01: false + 2022-04-01: true +metadata: + unit: bool + period: year + label: Scottish working-age Council Tax Reduction carer premium counts the Universal Credit carer element + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 6(1)(c) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 1 (in force 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/1 + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 1 para. 14 (persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 56cf7b0bca..cb7369c9ef 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -27,7 +27,9 @@ class severe_disability_minimum_guarantee_addition(Variable): "listed on is_counted_resident_for_severe_disability_addition. Whom " "a carer benefit is paid for caring for is attributed by " "is_cared_for_by_carer_benefit_recipient, which the legacy severe " - "disability premium shares. Blindness " + "disability premium shares; a carer benefit there includes an award " + "of Universal Credit which includes the carer element (para 1(4)), " + "read from the award's conditions rather than its amount. Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py new file mode 100644 index 0000000000..e442d0cb7b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class has_uc_award_with_carer_element(Variable): + value_type = bool + entity = Person + label = "has a Universal Credit award with the carer element, read before the award" + documentation = ( + "Whether this person has an award of Universal Credit which includes " + "the carer element (Universal Credit Regulations 2013 reg 29) in " + "respect of them, read from the conditions of the award rather than " + "its amount: the family claims Universal Credit " + "(claims_universal_credit), meets the age, pension-route and capital " + "conditions (is_uc_eligible), and the award includes the carer " + "element in respect of this claimant " + "(uc_carer_element_included_for_claimant). It is the carer condition " + "of the legacy severe disability premium (HB Regs 2006 Sch 3 para " + "14(2)(a)(iii) and (b)) and of the Pension Credit severe disability " + "addition (SPC Regs 2002 Sch I para 1(1) and (4)), through " + "is_cared_for_by_carer_benefit_recipient. Those cannot read the award " + "itself: the housing costs element's non-dependant deduction reads " + "Pension Credit, which reads the addition. A claimant whose income " + "tapers the award to nil fails the financial conditions (Welfare " + "Reform Act 2012 s.5(1)(b) and (2)(b); the minimum is one penny, reg " + "17), so has no award in law, but still counts here. So does a family " + "that forgoes Universal Credit for Tax-Free Childcare " + "(forgoes_universal_credit_for_tax_free_childcare reads the award). " + "receives_uc_carer_element is the same test on a positive award, for " + "readers that can read it." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + ) + + def formula(person, period, parameters): + benunit = person.benunit + on_claim = benunit("claims_universal_credit", period) & benunit( + "is_uc_eligible", period + ) + included = person("uc_carer_element_included_for_claimant", period) + return on_claim & included diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py new file mode 100644 index 0000000000..5579a2b6af --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -0,0 +1,73 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_carer_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR carer premium" + documentation = ( + "The carer premium in a working-age Council Tax Reduction applicable " + "amount. Outside Scotland's working-age scheme it is carer_premium. " + "Scotland's scheme (SSI 2021/249 Sch 1 paras 5 and 6, from 1 April " + "2022) pays it in respect of each applicant or partner who has " + "regular and substantial caring responsibilities for a severely " + "disabled person. Para 6(1) defines those as (a) receiving Carer's " + "Allowance or Carer Support Payment, (b) being entitled to one that " + "the overlapping benefits rules reduce to nil, or (c) having an award " + "of Universal Credit which includes the carer element. Limbs (a) and " + "(b) are carer_premium's test (is_entitled_to_carer_benefit); limb (c) " + "is receives_uc_carer_element, which needs a positive award, since an " + "income that tapers the award to nil leaves no award (Welfare Reform " + "Act 2012 s.5(1)(b), UC Regs 2013 reg 17). A person with both an " + "allowance and a carer element qualifies once: the premium is for each " + "partner who qualifies (para 5(3)), not for each award. Partners " + "caring for the same severely disabled person get one premium (para " + "5(3) and (4); partners_care_for_same_severely_disabled_person), as in " + "carer_premium. Para 5(4) then pays it in respect of the one whose " + "Universal Credit award includes the carer element, then one in " + "receipt of an allowance, then one entitled to an allowance reduced " + "to nil; that choice does not change the amount. Before 1 April 2022 " + "the scheme for persons under pension age, SSI 2012/303 Sch 1 para " + "14, tested entitlement to Carer's Allowance only " + "(carer_premium_counts_uc_carer_element)." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/1", + "https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + ) + + def formula(benunit, period, parameters): + carer_premium = benunit("carer_premium", period) + scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.working_age + if not scotland.carer_premium_counts_uc_carer_element: + return carer_premium + scottish_working_age = ( + benunit.household("country", period) == Country.SCOTLAND + ) & ~benunit("council_tax_reduction_pensioner", period) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + # SSI 2021/249 Sch 1 para 6(1)(a) and (b), as in carer_premium, or + # (c) an award of Universal Credit which includes the carer element. + caring = person("is_entitled_to_carer_benefit", period) | person( + "receives_uc_carer_element", period + ) + qualifying_carers = benunit.sum(claimant_or_partner & caring) + same_person = benunit("partners_care_for_same_severely_disabled_person", period) + qualifying_carers = where( + same_person, min_(qualifying_carers, 1), qualifying_carers + ) + amount = parameters(period).gov.dwp.carer_premium.single + return where( + scottish_working_age, + qualifying_carers * amount * WEEKS_IN_YEAR, + carer_premium, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index d6bc318621..ae8018c0e3 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -22,6 +22,9 @@ class council_tax_reduction_premiums(Variable): "her non-dependant (everyone else in the household, since she has no " "partner or children, unless they receive a qualifying benefit or are " "blind); and the carer premium if she is entitled to a carer benefit. " + "At working age the benefit unit's carer premium is " + "council_tax_reduction_carer_premium, which in Scotland also counts " + "an award of Universal Credit which includes the carer element. " "Whom a carer benefit is paid for caring for comes from " "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " "own benefit unit to her but a carer in another benefit unit only to " @@ -40,13 +43,16 @@ class council_tax_reduction_premiums(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3", "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", ) def formula(benunit, period, parameters): pensioner = benunit("council_tax_reduction_pensioner", period) # The working-age severe disability premium takes Council Tax # Reduction's own family test (council_tax_reduction_severe_disability_ - # premium, which differs from the shared premium in Scotland). + # premium), and the working-age carer premium its own caring test + # (council_tax_reduction_carer_premium); both differ from the shared + # premiums only in Scotland. benefit_unit_premiums = where( pensioner, benunit("pension_age_benefits_premiums", period), @@ -54,6 +60,10 @@ def formula(benunit, period, parameters): + ( benunit("council_tax_reduction_severe_disability_premium", period) - benunit("severe_disability_premium", period) + ) + + ( + benunit("council_tax_reduction_carer_premium", period) + - benunit("carer_premium", period) ), ) head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) @@ -86,6 +96,10 @@ def head_has(condition): ) & (others_counted == 0) ) * disability.severe_single + # A head who applies alone holds no Universal Credit award of her own + # (the benefit unit's award is its claimant's and partner's), so + # Scotland's carer element limb (council_tax_reduction_carer_premium) + # never reaches her. carer_premium = ( head_has(person("is_entitled_to_carer_benefit", period)) * carer.single ) diff --git a/policyengine_uk/variables/household/demographic/carer_premium.py b/policyengine_uk/variables/household/demographic/carer_premium.py index fa9c6e1d24..bc9adbff80 100644 --- a/policyengine_uk/variables/household/demographic/carer_premium.py +++ b/policyengine_uk/variables/household/demographic/carer_premium.py @@ -25,7 +25,10 @@ class carer_premium(Variable): "person caps the benefit unit at one amount. The premium enters the " "Income Support, Housing Benefit and council tax reduction applicable " "amounts; the model takes income-based JSA and income-related ESA " - "from reported awards." + "from reported awards. Scotland's working-age council tax reduction " + "also pays it for an award of Universal Credit which includes the " + "carer element (council_tax_reduction_carer_premium); no other " + "scheme's carer premium has that limb." ) definition_period = YEAR reference = ( @@ -57,8 +60,10 @@ def formula(benunit, period, parameters): # tests caring responsibilities and pays each partner who qualifies. # Para 6(1) defines those as (a) receiving either allowance, (b) being # entitled to one reduced to nil by the overlapping benefits rules, or - # (c) a Universal Credit award with the carer element; the model - # applies (a) and (b) but not (c) (#2124). + # (c) a Universal Credit award with the carer element. This premium + # applies (a) and (b), which every scheme shares; Scottish working-age + # council tax reduction reads council_tax_reduction_carer_premium, + # which adds (c). claimant_or_partner = benunit.members("is_claimant_or_partner", period) entitled = benunit.members("is_entitled_to_carer_benefit", period) qualifying_carers = benunit.sum(claimant_or_partner & entitled) diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 6772dfa69b..3598aa6d40 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -7,17 +7,20 @@ class is_cared_for_by_carer_benefit_recipient(Variable): label = "Someone receives a carer benefit for caring for this person" documentation = ( "Someone is entitled to and in receipt of Carer's Allowance or Carer " - "Support Payment in respect of caring for this person: the carer " + "Support Payment, or has an award of Universal Credit which includes " + "the carer element, in respect of caring for this person: the carer " "condition of the legacy severe disability premium (HB Regs 2006 Sch 3 " "para 14(2)(a)(iii) and (2)(b); IS, ESA and JSA alike) and of the " "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " - "1(1)). The data say who receives a carer benefit but not whom they " - "care for, so each award is attributed. A carer benefit is paid for " - "caring for a person who is severely disabled for Carer's Allowance " - "(is_severely_disabled_for_carers_allowance), a carer has one award " - "(SSCBA 1992 s.70(7)), and only one person is paid for caring for the " - "same severely disabled person (s.70(7ZA) to (7ZC); both rules were " - "in s.70(7) before 16 November 2023). No one is their own carer. A " + "1(1) and (4)). The data say who receives a carer benefit but not whom " + "they care for, so each award is attributed. A carer benefit or carer " + "element is paid for caring for a person who is severely disabled for " + "Carer's Allowance (is_severely_disabled_for_carers_allowance; UC Regs " + "2013 reg 30), a carer has one award (SSCBA 1992 s.70(7)), and only " + "one person has an allowance or a carer element for caring for the " + "same severely disabled person (s.70(7ZA) to (7ZC) and (7A); both " + "rules were in s.70(7) before 16 November 2023). No one is their own " + "carer. A " "carer cares for someone in their own benefit unit if they can: a " "claimant or partner first, then another member such as a disabled " "child. Every other carer in the household, including one left over " @@ -37,18 +40,24 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "first, then person order; the number cared for in a benefit unit " "does not depend on that choice. Carers outside the household are not " "observed, and nor is a carer whose allowance runs on for up to eight " - "weeks after the person they cared for has died (s.70(1A)). A " - "Universal Credit award that includes the carer element also counts " - "in law but is not attributed here: " - "reading Universal Credit would be circular, because the Universal " - "Credit non-dependant deduction exemption reads Pension Credit. Such a " - "carer living in the household already bars both the premium and the " - "addition through their residence conditions unless their presence " - "is ignored. Receipt is receives_carer_benefit: a positive Carer's " - "Allowance or Carer Support Payment after the overlapping-benefit " - "adjustment. A carer whose award an overlapping benefit, such as a " - "higher State Pension, reduces to nil is entitled but not in receipt, " - "so is not attributed." + "weeks after the person they cared for has died (s.70(1A)). Receipt " + "is receives_carer_benefit: a positive Carer's Allowance or Carer " + "Support Payment after the overlapping-benefit adjustment. A carer " + "whose allowance an overlapping benefit, such as a higher State " + "Pension, reduces to nil is entitled but not in receipt, so is not " + "attributed for the allowance. A carer element award is " + "has_uc_award_with_carer_element, read from the claim and the award's " + "conditions rather than its amount, because the Universal Credit " + "non-dependant deduction exemption reads Pension Credit, which reads " + "the addition. So a carer whose income tapers the award to nil, who " + "has no award in law (Welfare Reform Act 2012 s.5), is still " + "attributed, and can withhold a premium or addition the law would " + "pay. A carer with both an allowance and a carer element is one " + "carer. Partners who care for the same severely disabled person " + "(partners_care_for_same_severely_disabled_person) have one relevant " + "entitlement between them (s.70(7ZA) and (7A)), so where one of them " + "receives a carer benefit, the other's carer element is not a second " + "carer." ) definition_period = YEAR reference = ( @@ -58,12 +67,28 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", "https://www.legislation.gov.uk/uksi/2008/794/schedule/4/paragraph/6", "https://www.legislation.gov.uk/uksi/1996/207/schedule/1/paragraph/15", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", ) def formula(person, period, parameters): claimant_or_partner = person("is_claimant_or_partner", period) could_be_cared_for = person("is_severely_disabled_for_carers_allowance", period) - carer = person("receives_carer_benefit", period) + receives = person("receives_carer_benefit", period) + # An award of Universal Credit which includes the carer element counts + # as a carer too. A partner's carer element is not a second carer for + # a couple caring for the same person where one of them receives a + # carer benefit: they have one relevant entitlement between them + # (SSCBA 1992 s.70(7ZA) and (7A)). + same_person = person.benunit( + "partners_care_for_same_severely_disabled_person", period + ) + partner_receives = person.benunit.any(claimant_or_partner & receives) + carer_element = person("has_uc_award_with_carer_element", period) & ~( + same_person & partner_receives + ) + carer = receives | carer_element age = person("age", period) # Who takes a carer when not everyone can: someone who is not a carer # themselves, then person order (get_rank breaks ties by position). diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 5a41077d36..4e0fb09af5 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -20,8 +20,9 @@ class pension_age_severe_disability_premium(Variable): "qualifies and the other is blind, the qualifying partner is taken to " "claim, as a couple may agree under HB(SPC) reg 63(1) and the CTR " "schemes (England SI 2012/2885 Sch 8 para 4(1)); carers are attributed " - "by is_cared_for_by_carer_benefit_recipient, which does not count " - "Universal Credit carer-element awards. The Pension Credit severe " + "by is_cared_for_by_carer_benefit_recipient, which counts a Universal " + "Credit award with the carer element from the award's conditions " + "rather than its amount. The Pension Credit severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index d748f85fb4..0147125bfc 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -21,9 +21,11 @@ class severe_disability_premium(Variable): "when one is paid for only one of them, and nothing when carers are " "paid for both. Whom a carer benefit is paid for caring for is " "attributed by is_cared_for_by_carer_benefit_recipient, shared with " - "the Pension Credit severe disability addition; the law also counts " - "a Universal Credit award with the carer element, which the model " - "does not. Not modelled: a carer's " + "the Pension Credit severe disability addition. It counts an award " + "of Universal Credit which includes the carer element from the " + "award's conditions rather than its amount " + "(has_uc_award_with_carer_element), so a carer whose income tapers " + "the award to nil still bars the premium. Not modelled: a carer's " "allowance or carer element that would be paid but for a loss of " "benefit restriction under section 6B or 7 of the Social Security " "Fraud Act 2001 also counts (HB Regs 2006 Sch 3 para 14(7), IS Regs " From d08ed19f6caa916b209ee4c8568123e65ed3ebec Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:39:04 -0400 Subject: [PATCH 032/168] Read no change where a head is not finite at baseline The counted-once check no longer feeds NaN to set_input or fails a component for a head that is NaN at baseline. It sets each value that is not finite at baseline to 0 wherever it raises or holds a variable, judges no reading that rests on those values, names each variable not finite at baseline once (recorded as a finite: failure), and treats NaN as equal to NaN in the same place when it checks the reset. Co-Authored-By: Claude Opus 5.5 --- .../utils/aggregate_impact/guard.py | 425 ++++++++++++++---- .../utils/aggregate_impact/measure.py | 32 +- .../utils/aggregate_impact/render.py | 16 +- 3 files changed, 370 insertions(+), 103 deletions(-) diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 4a460d37aa..08c0d1d83f 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -62,7 +62,27 @@ (``households_not_held``); a problem does the same for the households that rest on the measurement the check could not make. A declared double count whose container's response is not finite fails: the check cannot - show that the container still contains it. + show that the container still contains it; +- a head, a model total or a component that is not finite at baseline has + no change to read where it is not finite, so the check cannot confirm it + there. It names each such variable once, with the households, in + ``not_finite_at_baseline``, which ``measure`` records as a failure of a + ``finite:`` check, and it fails no component for it. No rule reads a + head, or a model total, in the households where it, or one of its heads, + is not finite at baseline. The check raises no unit of a component that + is not finite at baseline, and judges nothing in the households that + hold one. The engine refuses NaN as an input, so wherever the check sets + a variable, raising it or holding it, it sets each value that is not + finite at baseline to 0. That changes values only in the households that + hold one, which no rule judges for any reading those values reach: the + component's own (it judges nothing there), and the held measurements + (neither judges a household where a head it holds is not finite at + baseline). Where each formula reads only its own household's values, + every reading in every other household is the one it would be with each + such value left as it was. A problem in a household the held measurements + could not judge stays, and names the heads, ``heads_not_held_at_baseline`` + beside ``heads_not_held``. Every other head and component gets the whole + check. What it cannot see: a head that adds a component back while tapering it away pound for pound (``max(0, a - c) + c``) does not move at the margin, so it is @@ -77,7 +97,8 @@ only after discarding formula results, so a value some formula computed earlier is never frozen as an input. Between perturbations it discards every formula result, and restores any input it overrode; at the end it recomputes -the baseline and requires it to match the first computation exactly. +the baseline and requires it to match the first computation exactly, with +NaN matching only NaN in the same place. """ from __future__ import annotations @@ -348,6 +369,15 @@ def _held_values(at_first, baseline, units): return np.where(units, baseline, at_first) +def _settable(values): + """``values`` in its layout (see ``_perturb``), with each value that is not + finite at 0: the engine refuses NaN as an input, and an infinite amount is + no level to hold. Finite values keep every bit.""" + if isinstance(values, list): + return [(month, np.where(np.isfinite(v), v, 0.0)) for month, v in values] + return np.where(np.isfinite(values), values, 0.0) + + def _households_text(households) -> str: """A count of households as messages give it (``count_text``); None is a small count that the published guard removed.""" @@ -404,18 +434,27 @@ def _unheld_where(households, of_them=False) -> str: return f"{households:,} {what}" -def _unheld_words(heads, component, name=str) -> str: - """The check could not hold ``heads``, which are not finite there.""" - names = [name(v) for v in heads] +def _listed(names) -> tuple: + """``names`` as a list in words, and how a relative clause refers to it.""" if len(names) == 1: - listed, which = names[0], "which is" - else: - listed = f"{', '.join(names[:-1])} and {names[-1]}" - which = "one or more of which is" - return ( - f"the check could not hold {listed}, {which} not finite there once " - f"{name(component)} rises" - ) + return names[0], "which is" + return f"{', '.join(names[:-1])} and {names[-1]}", "one or more of which is" + + +def _unheld_words(heads, component, name=str, at_baseline=()) -> str: + """The check could not hold ``heads``, which are not finite there once + ``component`` rises, or ``at_baseline``, which are not finite there at + baseline.""" + groups = [] + if heads: + listed, which = _listed([name(v) for v in heads]) + groups.append( + f"{listed}, {which} not finite there once {name(component)} rises" + ) + if at_baseline: + listed, which = _listed([name(v) for v in at_baseline]) + groups.append(f"{listed}, {which} not finite there at baseline") + return "the check could not hold " + ", and ".join(groups) def not_held_note(component, head, heads, households, name=str) -> str: @@ -429,6 +468,17 @@ def not_held_note(component, head, heads, households, name=str) -> str: ) +def not_finite_note(variable, households, name=str) -> str: + """What the check says about ``variable``, not finite at baseline in + ``households`` households with weight (None: a small count that the + published guard removed).""" + return ( + f"{name(variable)} is not finite at baseline in " + f"{_unheld_where(households)}, so the counted-once check could not " + "confirm it there" + ) + + def _problem( total, head, @@ -440,15 +490,19 @@ def _problem( nan, not_held=(), unjudged=None, + at_baseline=(), ): """One failing (total, head, kind), with the households it fails in. ``nan`` marks the households where a measurement behind the problem saw a response that is not finite. ``unjudged`` marks the households of - ``where`` that rest on the second held measurement, which the check - could not make there because it could not hold ``not_held``. The + ``where`` that rest on a held measurement, which the check could not + make there because it could not hold ``not_held`` (not finite once the + component rises) or ``at_baseline`` (not finite at baseline). The direct-effect step reads ``where``, ``nan`` and ``unjudged``, and - ``counted_once`` drops them before it reports the result. + ``counted_once`` drops them before it reports the result. The problem + carries ``heads_not_held_at_baseline`` only when ``at_baseline`` names a + head. """ # Only households with weight count towards a published count. weighted = weights > 0 @@ -458,21 +512,23 @@ def _problem( not_finite = bool((where & nan).any()) not_finite_weighted = bool((where & nan & weighted).any()) unjudged = np.zeros_like(where) if unjudged is None else unjudged & where - unheld = int(np.count_nonzero(unjudged & weighted)) if not_held else 0 + unheld = ( + int(np.count_nonzero(unjudged & weighted)) if not_held or at_baseline else 0 + ) text = problem_text(kind, head, component, count, not_finite, not_finite_weighted) message = f"{total}: {text}" + words = _unheld_words(list(not_held), component, at_baseline=list(at_baseline)) if kind == "unconfirmed": message += ( - f" where {_unheld_words(list(not_held), component)}, so it could " - f"not confirm whether {head} contains {component}" + f" where {words}, so it could not confirm whether {head} contains " + f"{component}" ) - elif not_held: + elif not_held or at_baseline: message += ( - f" (in {_unheld_where(unheld, of_them=True)} " - f"{_unheld_words(list(not_held), component)}, so it could not show " - f"that {head} responds only through other heads)" + f" (in {_unheld_where(unheld, of_them=True)} {words}, so it could not " + f"show that {head} responds only through other heads)" ) - return { + out = { "total": total, "head": head, "kind": kind, @@ -482,11 +538,11 @@ def _problem( "not_finite": not_finite, "not_finite_weighted": not_finite_weighted, "heads_not_held": list(not_held), - "households_not_held": unheld, - "where": where, - "nan": nan, - "unjudged": unjudged, } + if at_baseline: + out["heads_not_held_at_baseline"] = list(at_baseline) + out.update(households_not_held=unheld, where=where, nan=nan, unjudged=unjudged) + return out def judge_total( @@ -505,13 +561,20 @@ def judge_total( model_scale: Optional[np.ndarray], allowed_readers: Iterable[str], second_changes: Optional[Mapping[str, np.ndarray]] = None, + unread: Optional[Mapping[str, np.ndarray]] = None, + model_unread: Optional[np.ndarray] = None, ) -> dict: """Apply the counted-once rules, household by household, to one total. ``head_changes`` maps each head variable of the total to its change by household when 1 per unit was added to ``component`` (``expected`` by household); ``second_changes`` gives the changes when 2 per unit were - added, where they were measured. + added, where they were measured. ``unread`` marks, for a head, the + households where its change cannot be read (it, or the component, is not + finite at baseline there), and ``model_unread`` the same for the model's + total. No rule judges a head there, nor the model's total where it or + any head of the total cannot be read, and a head's response is its mean + over the households where it can be read. - The component's own head must change by exactly ``expected`` in every household. @@ -534,19 +597,30 @@ def judge_total( mask ``nan`` of households whose judged response is not finite, and the mask ``unjudged`` (empty here). """ - added = float(np.sum(weights * expected)) allowed = set(allowed_readers) perturbed = expected > 0 responses, problems, screens, own_problem = {}, [], [], None + nowhere = np.zeros(len(expected), dtype=bool) + unread = unread or {} def problem(head, kind, where, nan): return _problem(total, head, kind, component, where, weights, perturbed, nan) + def mean_response(change, skip): + """The mean change per unit added, over households not in ``skip``.""" + added = float(np.sum(weights * np.where(skip, 0.0, expected))) + return ( + float(np.sum(weights * np.where(skip, 0.0, change))) / added + if added + else 0.0 + ) + for variable, change in head_changes.items(): - responses[variable] = float(np.sum(weights * change)) / added if added else 0.0 + skip = unread.get(variable, nowhere) + responses[variable] = mean_response(change, skip) slack = _slack(head_scales[variable]) if variable == own_variable: - off = (np.abs(change - expected) > slack) | ~np.isfinite(change) + off = ((np.abs(change - expected) > slack) | ~np.isfinite(change)) & ~skip if off.any(): own_problem = problem(variable, "own_head", off, ~np.isfinite(change)) continue @@ -562,7 +636,12 @@ def problem(head, kind, where, nan): # Another head's response can add to a containing head's # slope, so any other linear response is measured again with # the other heads held. - linear = perturbed & (np.abs(slope) > 2 * response_slack) & ~containing + linear = ( + perturbed + & (np.abs(slope) > 2 * response_slack) + & ~containing + & ~skip + ) if linear.any(): screens.append( problem(variable, "contains", linear, ~np.isfinite(slope)) @@ -572,31 +651,33 @@ def problem(head, kind, where, nan): containing = perturbed & _contains(change, expected) # A response that is not finite fails wherever it is, as any # other head's does. - containing = containing | ~np.isfinite(slope) + containing = (containing | ~np.isfinite(slope)) & ~skip if containing.any(): problems.append( problem(variable, "contains", containing, ~np.isfinite(slope)) ) elif is_tax: - moved = _moves(change, expected) + moved = _moves(change, expected) & ~skip if moved.any(): problems.append(problem(variable, "moves", moved, ~np.isfinite(change))) else: - rises = _rises(change, slack) + rises = _rises(change, slack) & ~skip if rises.any(): problems.append(problem(variable, "rises", rises, ~np.isfinite(change))) partition_coefficient = float(sum(responses.values())) model_coefficient = None if model_change is not None: - model_coefficient = ( - float(np.sum(weights * model_change)) / added if added else 0.0 - ) + # The model's total cannot be read where it, or a head, cannot. + skip = nowhere if model_unread is None else model_unread + for variable in head_changes: + skip = skip | unread.get(variable, nowhere) + model_coefficient = mean_response(model_change, skip) heads_change = sum(head_changes.values()) if declared_double_count: heads_change = heads_change + expected not_finite = ~np.isfinite(model_change) | ~np.isfinite(heads_change) beyond = np.abs(model_change - heads_change) > _slack(model_scale) - mismatch = beyond | not_finite + mismatch = (beyond | not_finite) & ~skip if mismatch.any(): problems.append(problem(total, "model_total", mismatch, not_finite)) return { @@ -679,25 +760,32 @@ def still_breaks(kind, where, expected, slack, first, second=None): return where -def _held_breaks(kind, where, expected, slack, first, second, slope_held, unjudged): +def _held_breaks( + kind, where, expected, slack, first, second, slope_held, unjudged, neither=None +): """Households of ``where`` where a held measurement that judged them still breaks ``kind`` (see ``still_breaks`` and ``breaks_on_slope``). ``slope_held`` is the slope with the other heads held at their values - after the first point; it judges no household of ``unjudged``. + after the first point; it judges no household of ``unjudged``. Neither + measurement judges a household of ``neither``, where a head it holds is + not finite at baseline; ``unjudged`` holds those households too. """ - held_at_baseline = still_breaks(kind, where, expected, slack, first, second) + at_baseline = where if neither is None else where & ~neither + held_at_baseline = still_breaks(kind, at_baseline, expected, slack, first, second) judged = where & ~unjudged return held_at_baseline | breaks_on_slope(kind, judged, expected, slack, slope_held) -def _held_nan(kind, first, second, slope_held, unjudged): +def _held_nan(kind, first, second, slope_held, unjudged, neither=None): """Households where a held measurement that judges ``kind`` (see ``_held_breaks``) saw a response that is not finite.""" - nan = ~np.isfinite(first) | (~np.isfinite(slope_held) & ~unjudged) + nan = ~np.isfinite(first) if kind == "contains" and second is not None: nan = nan | ~np.isfinite(second) - return nan + if neither is not None: + nan = nan & ~neither + return nan | (~np.isfinite(slope_held) & ~unjudged) DIRECT_KINDS = ("contains", "rises", "moves") @@ -715,7 +803,11 @@ def counted_once( """Perturb each component (or those in ``variables_to_check``). ``tolerance`` is kept for reports of per-unit responses; the verdicts - themselves are household by household (see ``judge_total``). + themselves are household by household (see ``judge_total``). The result + carries ``not_finite_at_baseline`` only when a head, a model total or a + component it raised is not finite at baseline: one entry for each, with + the head's id and label (None for a variable that is no head), the + households with weight where it is not finite, and a message. """ if not discard_formula_results(sim): return { @@ -742,6 +834,15 @@ def counted_once( ] else: baseline[variable] = _raw(sim, variable, year).astype(np.float64) + # Where the check sets a variable, it sets these values: the baseline, + # with each value that is not finite at 0 (see the module docstring). + settable = {variable: _settable(values) for variable, values in baseline.items()} + # The households where each head, and each model total, has no change to + # read: it is not finite there at baseline. + unread = {v: ~np.isfinite(heads0[v]) for v in all_heads} + model_unread = {t: ~np.isfinite(model0[t]) for t in model_totals} + # The same for each component the check raises, by the units it holds. + blind_in = {} declared = {dc.variable: dc for dc in partition.double_counts} head_by_variable = {h.variable: h for h in partition.heads} @@ -782,19 +883,43 @@ def counted_once( if monthly else baseline[variable] ) - nonzero = year_values != 0 + # A unit that is not finite at baseline is never raised, and the + # households holding one are judged for nothing: their readings + # rest on the value the check sets there. + broken = _not_finite_units(baseline[variable]) + nonzero = (year_values != 0) & ~broken zero_baseline = not bool(nonzero.any()) - mask = np.ones_like(nonzero) if zero_baseline else nonzero + mask = ~broken if zero_baseline else nonzero added = mask.astype(np.float64) - expected = _household(sim, added, entity) + blind = _household(sim, broken, entity) > 0 + if blind.any(): + blind_in[variable] = blind + expected = np.where(blind, 0.0, _household(sim, added, entity)) # Perturbed units in households with weight: a zero-weight record # adds nothing to any published figure. record["units"] = int(round(float(np.sum(expected[hw > 0])))) if not float(np.sum(hw * expected)) > 0: + weighted_blind = bool((blind & (hw > 0)).any()) record.update( status="skipped", - detail="non-zero only in households with no weight", + detail=( + "the check could raise it only in households with no " + "weight or where it is not finite at baseline" + if weighted_blind + else "non-zero only in households with no weight" + ), ) + if weighted_blind and variable in declared: + dc = declared[variable] + double_count_results.append( + _unchecked_double_count( + dc, + f"the check could raise {variable} only in households " + "with no weight or where it is not finite at " + f"baseline, so it cannot show that {dc.contained_in} " + f"still contains {variable}", + ) + ) results.append(record) continue own = declared[variable].contained_in if variable in declared else variable @@ -809,7 +934,7 @@ def counted_once( for total in in_totals } - _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + _perturb(sim, variable, year, settable, added, monthly, touched=touched) heads1 = _head_arrays(sim, year, needed) model1 = { t: _raw(sim, t, year).astype(np.float64) @@ -832,7 +957,7 @@ def counted_once( heads2 = None if may_respond: _perturb( - sim, variable, year, baseline, 2 * added, monthly, touched=touched + sim, variable, year, settable, 2 * added, monthly, touched=touched ) heads2 = _head_arrays(sim, year, sorted(may_respond)) problems, screens = [], [] @@ -857,6 +982,10 @@ def counted_once( second_changes=None if heads2 is None else {v: heads2[v] - heads0[v] for v in heads2}, + unread={v: unread[v] | blind for v in heads_of[total]}, + model_unread=( + model_unread[total] | blind if total in model_unread else None + ), ) record["coefficients"][total] = verdict["coefficient"] if verdict["model_coefficient"] is not None: @@ -883,11 +1012,18 @@ def counted_once( + [screen["head"] for screen in screens] ): held = [v for v in all_heads if v not in (variable, own, head)] + # The households where a head held here is not finite at + # baseline: neither held measurement can hold it at its + # baseline value there, so neither judges them. + at_baseline_in = {v: unread[v] for v in held if unread[v].any()} + neither = np.zeros(len(hw), dtype=bool) + for v in at_baseline_in: + neither = neither | at_baseline_in[v] _perturb( sim, variable, year, - baseline, + settable, added, monthly, hold=held, @@ -903,7 +1039,7 @@ def counted_once( sim, variable, year, - baseline, + settable, 2 * added, monthly, hold=held, @@ -920,8 +1056,9 @@ def counted_once( # an infinite amount is no level to hold, so a unit where another # head is not finite after the first point keeps that head at its # baseline value, and this test judges no household the unit - # belongs to. - _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + # belongs to. A household where that head is not finite at + # baseline too is one neither test judges, and counts there. + _perturb(sim, variable, year, settable, added, monthly, touched=touched) at_first = _capture(sim, held, year, baseline) head_first = _head_arrays(sim, year, [head])[head] held_at, unheld_in = {}, {} @@ -929,16 +1066,21 @@ def counted_once( units = _not_finite_units(at_first[v]) held_at[v] = at_first[v] if units.any(): - held_at[v] = _held_values(at_first[v], baseline[v], units) + held_at[v] = _held_values(at_first[v], settable[v], units) # Every household a unit belongs to, through the mapping # that sums each head to households. entity_key = definitions[v].entity.key - unheld_in[v] = _household(sim, units, entity_key) > 0 + raised = _household(sim, units, entity_key) > 0 + if v in at_baseline_in: + raised = raised & ~at_baseline_in[v] + if not raised.any(): + continue + unheld_in[v] = raised _perturb( sim, variable, year, - baseline, + settable, 2 * added, monthly, hold=held, @@ -957,11 +1099,25 @@ def counted_once( record["households_not_held"][head] = int( np.count_nonzero(unjudged & (hw > 0)) ) + # A problem that rests on a held head not finite at baseline + # names it; the component's result names the head once, in + # ``not_finite_at_baseline``. + unjudged = unjudged | neither def unheld(where): - """The heads the check could not hold in ``where``.""" + """The heads the check could not hold in ``where`` once the + component rises.""" return [v for v in not_held if bool((unheld_in[v] & where).any())] + def unheld_at_baseline(where): + """The heads the check could not hold in ``where`` at their + baseline values.""" + return [ + v + for v in at_baseline_in + if bool((at_baseline_in[v] & where).any()) + ] + slack = _slack(heads0[head]) kept = [] for p in problems: @@ -977,6 +1133,7 @@ def unheld(where): second, slope_held, unjudged, + neither, ) # The second measurement clears no household it could not # judge: there the problem rests on that measurement. @@ -1023,10 +1180,16 @@ def unheld(where): perturbed_units, p["nan"] | _held_nan( - p["kind"], first, second, slope_held, unjudged + p["kind"], + first, + second, + slope_held, + unjudged, + neither, ), unheld(rests), rests, + unheld_at_baseline(rests), ) ) # A linear response that was not one for one may hide a @@ -1043,13 +1206,16 @@ def unheld(where): second, slope_held, unjudged, + neither, ) # Where the second measurement judged no household, the # check could not confirm the reader there either way. unconfirmed = screen["where"] & unjudged & ~confirmed - nan = _held_nan("contains", first, second, slope_held, unjudged) + nan = _held_nan( + "contains", first, second, slope_held, unjudged, neither + ) if confirmed.any(): - not_held_there, rests = (), None + not_held_there, rests, at_baseline_there = (), None, () same = next( ( k @@ -1062,9 +1228,10 @@ def unheld(where): if same is not None: kept.remove(same) confirmed = confirmed | same["where"] - not_held_there, rests = ( + not_held_there, rests, at_baseline_there = ( same["heads_not_held"], same["unjudged"], + same.get("heads_not_held_at_baseline", ()), ) kept.append( _problem( @@ -1078,6 +1245,7 @@ def unheld(where): nan if same is None else nan | same["nan"], not_held_there, rests, + at_baseline_there, ) ) if unconfirmed.any(): @@ -1093,6 +1261,7 @@ def unheld(where): nan, unheld(unconfirmed), unconfirmed, + unheld_at_baseline(unconfirmed), ) ) problems = kept @@ -1103,36 +1272,17 @@ def unheld(where): record["mediated"] = mediated record["heads_held_skipped"] = bool(record["heads_not_held"]) if variable in declared: - dc = declared[variable] - container_change = heads1[own] - heads0[own] - k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) - finite = bool(np.isfinite(container_change).all()) - present = finite and not bool( - (np.abs(container_change - expected) > _slack(heads0[own])).any() - ) - row = { - "variable": variable, - "contained_in": dc.contained_in, - "coefficient": k, - "issue": dc.issue, - "status": "pass" if present else "fail", - "not_finite": not finite, - "detail": "" - if present - else ( - f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: " - "the model no longer counts it twice, so remove the " - "declaration and give it its own head" - ), - } - if not finite: - # Not a stale declaration: the check cannot tell. - row["detail"] = ( - f"{dc.contained_in} is not finite when {variable} rises, " - f"so the check cannot show that {dc.contained_in} still " - f"contains {variable}" + double_count_results.append( + _double_count_row( + declared[variable], + heads1[own] - heads0[own], + expected, + # The households where the container's change can be read. + ~(unread[own] | blind), + hw, + heads0[own], ) - double_count_results.append(row) + ) if problems: record.update( status="fail", @@ -1156,8 +1306,11 @@ def unheld(where): discard_formula_results(sim) heads_again = _head_arrays(sim, year, all_heads) - reproduces = all(np.array_equal(heads_again[v], heads0[v]) for v in all_heads) - return { + # NaN matches NaN in the same place, and nothing else. + reproduces = all( + np.array_equal(heads_again[v], heads0[v], equal_nan=True) for v in all_heads + ) + result = { "status": "ran", "year": year, "tolerance": tolerance, @@ -1165,3 +1318,83 @@ def unheld(where): "double_counts": double_count_results, "reset_reproduces_baseline": bool(reproduces), } + # Each variable the check could not read somewhere, named once: the heads, + # the model totals, then the other components it raised. + where_not_finite = { + **{v: unread[v] for v in all_heads if unread[v].any()}, + **{t: model_unread[t] for t in model_totals if model_unread[t].any()}, + } + for variable, blind in blind_in.items(): + where_not_finite.setdefault(variable, blind) + if where_not_finite: + result["not_finite_at_baseline"] = [] + for variable, households in where_not_finite.items(): + head = head_by_variable.get(variable) + count = int(np.count_nonzero(households & (hw > 0))) + result["not_finite_at_baseline"].append( + { + "variable": variable, + "head": None if head is None else head.id, + "label": None if head is None else head.label, + "households": count, + "message": not_finite_note(variable, count), + } + ) + return result + + +def _double_count_row(dc, change, expected, readable, weights, scale) -> dict: + """Whether the container of a declared double count still moves by exactly + what was added to it, in each household where its ``change`` can be read + (``readable``).""" + variable, container = dc.variable, dc.contained_in + base = float(np.sum(weights * np.where(readable, expected, 0.0))) + if not base > 0: + return _unchecked_double_count( + dc, + f"{container} is not finite at baseline in every household with " + f"weight where the check raised {variable}, so it cannot show that " + f"{container} still contains {variable}", + ) + k = float(np.sum(weights * np.where(readable, change, 0.0))) / base + finite = bool(np.isfinite(change[readable]).all()) + present = finite and not bool( + ((np.abs(change - expected) > _slack(scale)) & readable).any() + ) + row = { + "variable": variable, + "contained_in": container, + "coefficient": k, + "issue": dc.issue, + "status": "pass" if present else "fail", + "not_finite": not finite, + "detail": "" + if present + else ( + f"{container} moves by {k:.4f} per unit of {variable}: the model no " + "longer counts it twice, so remove the declaration and give it its " + "own head" + ), + } + if not finite: + # Not a stale declaration: the check cannot tell. + row["detail"] = ( + f"{container} is not finite when {variable} rises, so the check " + f"cannot show that {container} still contains {variable}" + ) + return row + + +def _unchecked_double_count(dc, detail) -> dict: + """A declared double count the check could not judge anywhere: a + container or component not finite at baseline wherever it would read it. + It is skipped, not passed, and a ``finite:`` check names the cause.""" + return { + "variable": dc.variable, + "contained_in": dc.contained_in, + "coefficient": None, + "issue": dc.issue, + "status": "skipped", + "not_finite": False, + "detail": detail, + } diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index eb880b5fc8..11f2cd6b08 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -1169,8 +1169,10 @@ def publishable_guard(guard: dict) -> dict: no count and no share. A count of households where the check could not hold a head (``households_not_held``) below ``MIN_CELL`` goes too. Messages already say "fewer than 10". The heads the check could not hold - (``heads_not_held``) and the ``not_finite`` flags hold no figures, so - they stay. + (``heads_not_held``, ``heads_not_held_at_baseline``) and the + ``not_finite`` flags hold no figures, so they stay. A count below + ``MIN_CELL`` of households where a variable is not finite at baseline + (``not_finite_at_baseline``) goes too. """ out = dict(guard) components = [] @@ -1215,8 +1217,9 @@ def publishable_guard(guard: dict) -> dict: row = dict(row) if small(units.get(row.get("variable")) or 0): row["coefficient"] = None - # A container that is not finite has a detail with no figures. - if row.get("status") != "pass" and not row.get("not_finite"): + # A container that is not finite, or a double count the check + # skipped, has a detail with no figures. + if row.get("status") == "fail" and not row.get("not_finite"): row["detail"] = ( f"{row['contained_in']} no longer moves one for one with " f"{row['variable']} (fewer than {MIN_CELL} units): the model " @@ -1226,6 +1229,14 @@ def publishable_guard(guard: dict) -> dict: double_counts.append(row) if "double_counts" in guard: out["double_counts"] = double_counts + if "not_finite_at_baseline" in guard: + out["not_finite_at_baseline"] = [ + { + **item, + "households": None if small(item["households"]) else item["households"], + } + for item in guard["not_finite_at_baseline"] + ] return out @@ -1272,10 +1283,21 @@ def record_guard_checks(rec: Recorder, guard: dict) -> None: rec.check( f"double_count_present:{result['variable']}", f"{result['variable']} is still contained in {result['contained_in']}", - result["status"] == "pass", + None if result["status"] == "skipped" else result["status"] == "pass", result["detail"], section="counted_once", ) + # A variable not finite at baseline fails a finite: check, which blocks + # whatever the base did: its own for a head, else finite:metrics. + for item in guard.get("not_finite_at_baseline") or []: + detail = f"{fiscal_year_label(guard['year'])}: {item['message']}" + scope = f"counted_once:{guard['year']}:{item['variable']}" + if item["head"] is None: + check_id, label = "finite:metrics", "Every reported figure is finite" + else: + check_id = f"finite:{item['head']}" + label = f"{item['label']} is finite for every unit" + rec.check(check_id, label, False, detail, section="finite", scope=scope) if "reset_reproduces_baseline" in guard: rec.check( "counted_once:reset_reproduces_baseline", diff --git a/policyengine_uk/utils/aggregate_impact/render.py b/policyengine_uk/utils/aggregate_impact/render.py index 3198fc70db..125437fa0b 100644 --- a/policyengine_uk/utils/aggregate_impact/render.py +++ b/policyengine_uk/utils/aggregate_impact/render.py @@ -6,7 +6,7 @@ from typing import Mapping, Optional from . import COMMENT_MARKER -from .guard import not_held_note, problem_text +from .guard import not_finite_note, not_held_note, problem_text from .spec import Spec, fiscal_year_label SECTIONS = ( @@ -291,6 +291,13 @@ def _guard(summary) -> str: f"response contains the component. {len(failed)} failed and the check " f"skipped {len(skipped)}." ] + code = "`{}`".format + # A variable not finite at baseline fails no component; a finite: check + # names it. + for item in guard.get("not_finite_at_baseline") or []: + lines.append( + "- " + not_finite_note(item["variable"], item.get("households"), code) + ) for c in failed: problems = c.get("problems") or [{"message": c.get("detail", "")}] lines.append( @@ -298,7 +305,6 @@ def _guard(summary) -> str: ) for c in skipped: lines.append(f"- `{c['variable']}` skipped: {c.get('detail', '')}") - code = "`{}`".format for c in tested: counts = c.get("households_not_held") or {} for head, heads in (c.get("heads_not_held") or {}).items(): @@ -322,6 +328,12 @@ def _guard(summary) -> str: f"{moved}; {m['total']}; not a failure)" ) for dc in guard.get("double_counts", []): + if dc.get("status") == "skipped": + lines.append( + f"- declared double count `{dc['variable']}` not checked: " + f"{dc['detail']} ({dc['issue']})" + ) + continue if dc.get("not_finite"): lines.append( f"- declared double count `{dc['variable']}`: `{dc['contained_in']}` " From 0220a0c643729e10661a9c4297465282a2fd895a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:13:25 -0400 Subject: [PATCH 033/168] Hold a head not finite at baseline by its own formula In the held measurements the check no longer sets a head that is not finite at baseline: it leaves the head to its formula and judges each household where the head came back at the value the measurement holds it at, NaN matching only NaN. A head that ignores the component is then held exactly everywhere, so it unjudges no household and fails no component. A head with no readable household gets no response or coefficient. Add tests for the issue's cases, the reset check, a Hypothesis property over random heads with extra heads not finite at baseline, and a differential against the check as it stood at 89bd11fec. Co-Authored-By: Claude Opus 5.5 --- .../counted_once_base_reference.py | 1073 +++++++++++++++++ .../test_aggregate_impact_guard.py | 7 +- .../test_aggregate_impact_guard_baseline.py | 807 +++++++++++++ .../utils/aggregate_impact/guard.py | 201 +-- 4 files changed, 2006 insertions(+), 82 deletions(-) create mode 100644 policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py create mode 100644 policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py diff --git a/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py b/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py new file mode 100644 index 0000000000..89f3cdfa88 --- /dev/null +++ b/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py @@ -0,0 +1,1073 @@ +"""The counted-once check as it stood at 89bd11fec, without commentary. + +Keep this independent of the production tool: it is the differential oracle +for #2773, whose fix must publish exactly what this did for every model whose +heads and totals are finite at baseline. It holds the check (``guard.py``), +how ``measure`` records and publishes its result (``record_guard_checks``, +``publishable_guard``) and how ``render`` describes it (``_guard``). +""" + +from __future__ import annotations + +import math +from typing import Iterable, Mapping, Optional + +import numpy as np + +from policyengine_uk.utils.aggregate_impact.partition import Partition +from policyengine_uk.utils.aggregate_impact.privacy import MIN_CELL, count_text, small +from policyengine_uk.utils.aggregate_impact.spec import fiscal_year_label + + +def _raw(sim, variable, period): + try: + result = sim.calculate(variable, period, unweighted=True) + except TypeError: + result = sim.calculate(variable, period) + return np.asarray(getattr(result, "values", result)) + + +def discard_formula_results(sim) -> bool: + reset = getattr(sim, "_invalidate_all_caches", None) + if reset is None: + return False + reset() + return True + + +class _InputSnapshot(dict): + def __init__(self, values, *, owner, origins): + super().__init__(values) + self.owner = owner + self.origins = origins + + +def _visible_branches(sim): + branches = [] + branch = sim + while branch is not None: + if branch.branch_name not in branches: + branches.append(branch.branch_name) + branch = getattr(branch, "parent_branch", None) + if "default" not in branches: + branches.append("default") + return branches + + +def _capture_input_origins(sim, variables): + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + names = set(variables) & SUPPLIED_INPUT_VARIABLES + if not names: + return {} + branches = set(_visible_branches(sim)) + keys = set(getattr(sim, "_user_input_keys", None) or ()) + model = getattr(sim, "_model_values", None) or {} + origins = {} + for name in names: + if name not in sim.tax_benefit_system.variables: + continue + holder = sim.get_holder(name) + slots = [] + for key in keys | set(model): + variable, branch, period = key + if variable != name or branch not in branches: + continue + value = holder._get_array_from_storage(period, branch) + if value is None: + continue + marker = model.get(key) + is_model = marker is not None and np.array_equal(marker, value) + if key in keys or is_model: + slots.append((branch, period, np.array(value, copy=True), is_model)) + origins[name] = tuple(slots) + return origins + + +def snapshot_inputs(sim, variables) -> dict: + variables = tuple(variables) + discard_formula_results(sim) + origins = _capture_input_origins(sim, variables) + snapshot = _InputSnapshot({}, owner=sim, origins=origins) + for variable in variables: + if variable not in sim.tax_benefit_system.variables: + continue + holder = sim.get_holder(variable) + periods = holder.get_known_periods() + if periods: + snapshot[variable] = [ + (period, np.array(holder.get_array(period), copy=True)) + for period in periods + ] + return snapshot + + +def _guard_snapshot(sim, snapshot, variables): + if snapshot is None: + return snapshot_inputs(sim, variables) + if isinstance(snapshot, _InputSnapshot): + if snapshot.owner is not sim: + raise ValueError("Input snapshot belongs to a different simulation") + return snapshot + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + origins = _capture_input_origins(sim, variables) + for name in set(snapshot) & SUPPLIED_INPUT_VARIABLES: + for period, value in snapshot[name]: + if not any( + ( + saved_period == period and np.array_equal(saved_value, value) + for _, saved_period, saved_value, _ in origins.get(name, ()) + ) + ): + raise ValueError("Plain input snapshot has no matching original slot") + return _InputSnapshot(snapshot, owner=sim, origins=origins) + + +def _set_guard_value(sim, variable, period, value, touched=None): + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + set_model_value, + ) + + if touched is not None: + touched.add(variable) + if variable in SUPPLIED_INPUT_VARIABLES: + set_model_value(sim, variable, period, value) + else: + sim.set_input(variable, period, value) + + +def _restore(sim, variable, snapshot): + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + drop_missing_model_values, + drop_missing_supplied_inputs, + set_model_value, + ) + + if variable not in SUPPLIED_INPUT_VARIABLES: + sim.delete_arrays(variable) + for period, values in snapshot.get(variable, []): + sim.set_input(variable, period, values) + return + if not isinstance(snapshot, _InputSnapshot) or snapshot.owner is not sim: + raise ValueError("Registered restoration needs its original input snapshot") + if variable not in snapshot.origins: + raise ValueError("Registered variable is absent from original origin capture") + holder = sim.get_holder(variable) + holder.delete_arrays(branch_name=sim.branch_name) + drop_missing_supplied_inputs(sim, variable) + drop_missing_model_values(sim, variable) + for branch, period, value, is_model in snapshot.origins[variable]: + if branch == sim.branch_name: + if is_model: + set_model_value(sim, variable, period, value) + else: + sim.set_input(variable, period, value) + + +def _months(year): + from policyengine_core.periods import period as make_period + + return make_period(str(year)).get_subperiods("month") + + +def _household(sim, values, entity): + values = np.asarray(values, dtype=np.float64) + if entity == "household": + return values + return np.asarray(sim.map_result(values, entity, "household"), dtype=np.float64) + + +def _head_arrays(sim, year, variables) -> dict: + definitions = sim.tax_benefit_system.variables + return { + v: _household(sim, _raw(sim, v, year), definitions[v].entity.key) + for v in variables + } + + +ABSOLUTE_SLACK_GBP = 0.02 +RELATIVE_SLACK = 2e-06 + + +def _slack(scale: np.ndarray) -> np.ndarray: + return ABSOLUTE_SLACK_GBP + RELATIVE_SLACK * np.abs(scale) + + +def _response_slack(response: np.ndarray, expected: np.ndarray) -> np.ndarray: + return ABSOLUTE_SLACK_GBP * np.abs(expected) + RELATIVE_SLACK * np.abs(response) + + +def _moves( + response: np.ndarray, expected: np.ndarray, allowances: int = 1 +) -> np.ndarray: + beyond = np.abs(response) > allowances * _response_slack(response, expected) + return beyond | ~np.isfinite(response) + + +def _contains( + response: np.ndarray, expected: np.ndarray, allowances: int = 1 +) -> np.ndarray: + close = np.abs(response - expected) <= allowances * _response_slack( + response, expected + ) + return close | ~np.isfinite(response) + + +def _rises(response: np.ndarray, slack: np.ndarray) -> np.ndarray: + return (response > slack) | ~np.isfinite(response) + + +def _not_finite_units(values) -> np.ndarray: + if isinstance(values, list): + return np.logical_or.reduce([~np.isfinite(v) for _, v in values]) + return ~np.isfinite(values) + + +def _held_values(at_first, baseline, units): + if isinstance(at_first, list): + return [ + (month, np.where(units, start, value)) + for (month, value), (_, start) in zip(at_first, baseline) + ] + return np.where(units, baseline, at_first) + + +def _households_text(households) -> str: + if households is None: + return f"fewer than {MIN_CELL} households" + return count_text(households, "households") + + +def problem_text(kind, head, component, households, not_finite=False, weighted=True): + clause = ", or is not finite," if not_finite and weighted else "" + if kind == "own_head": + text = f"{head} does not move by exactly what was added to {component}{clause}" + elif kind == "contains": + text = f"{head} rises one for one with {component}" + ( + clause or ", so it contains it," + ) + elif kind == "unconfirmed": + text = f"{head} responds to {component}{clause}" + elif kind == "rises": + text = f"{head} rises{clause} when {component} does" + elif kind == "moves": + text = f"{head} moves{clause} when {component} does" + elif kind == "mediated": + text = f"{head} responds only through other heads{clause}" + else: + text = f"the model's {head} moves by other than its heads{clause} when {component} does" + text += f" in {_households_text(households)}" + if not_finite and (not weighted): + text += ", and is not finite in households with no weight" + return text + + +def _unheld_where(households, of_them=False) -> str: + if households == 0: + return "households with no weight" + what = "of them" if of_them else "households" + if households is None or small(households): + return f"fewer than {MIN_CELL} {what}" + return f"{households:,} {what}" + + +def _unheld_words(heads, component, name=str) -> str: + names = [name(v) for v in heads] + if len(names) == 1: + listed, which = (names[0], "which is") + else: + listed = f"{', '.join(names[:-1])} and {names[-1]}" + which = "one or more of which is" + return f"the check could not hold {listed}, {which} not finite there once {name(component)} rises" + + +def not_held_note(component, head, heads, households, name=str) -> str: + return f"in {_unheld_where(households)} {_unheld_words(heads, component, name)}, so it measured {name(head)} again there only with the other heads at their baseline values" + + +def _problem( + total, + head, + kind, + component, + where, + weights, + perturbed, + nan, + not_held=(), + unjudged=None, +): + weighted = weights > 0 + count = int(np.count_nonzero(where & weighted)) + base = float(np.sum(weights * perturbed)) + share = float(np.sum(weights * where)) / base if base else 0.0 + not_finite = bool((where & nan).any()) + not_finite_weighted = bool((where & nan & weighted).any()) + unjudged = np.zeros_like(where) if unjudged is None else unjudged & where + unheld = int(np.count_nonzero(unjudged & weighted)) if not_held else 0 + text = problem_text(kind, head, component, count, not_finite, not_finite_weighted) + message = f"{total}: {text}" + if kind == "unconfirmed": + message += f" where {_unheld_words(list(not_held), component)}, so it could not confirm whether {head} contains {component}" + elif not_held: + message += f" (in {_unheld_where(unheld, of_them=True)} {_unheld_words(list(not_held), component)}, so it could not show that {head} responds only through other heads)" + return { + "total": total, + "head": head, + "kind": kind, + "households": count, + "share": share, + "message": message, + "not_finite": not_finite, + "not_finite_weighted": not_finite_weighted, + "heads_not_held": list(not_held), + "households_not_held": unheld, + "where": where, + "nan": nan, + "unjudged": unjudged, + } + + +def judge_total( + *, + total: str, + is_tax: bool, + component: str, + own_variable: str, + declared_double_count: bool, + zero_baseline: bool, + weights: np.ndarray, + expected: np.ndarray, + head_changes: Mapping[str, np.ndarray], + head_scales: Mapping[str, np.ndarray], + model_change: Optional[np.ndarray], + model_scale: Optional[np.ndarray], + allowed_readers: Iterable[str], + second_changes: Optional[Mapping[str, np.ndarray]] = None, +) -> dict: + added = float(np.sum(weights * expected)) + allowed = set(allowed_readers) + perturbed = expected > 0 + responses, problems, screens, own_problem = ({}, [], [], None) + + def problem(head, kind, where, nan): + return _problem(total, head, kind, component, where, weights, perturbed, nan) + + for variable, change in head_changes.items(): + responses[variable] = float(np.sum(weights * change)) / added if added else 0.0 + slack = _slack(head_scales[variable]) + if variable == own_variable: + off = (np.abs(change - expected) > slack) | ~np.isfinite(change) + if off.any(): + own_problem = problem(variable, "own_head", off, ~np.isfinite(change)) + continue + reader = variable in allowed or (zero_baseline and (not is_tax)) + if reader: + if second_changes is not None: + slope = second_changes[variable] - change + response_slack = _response_slack(slope, expected) + containing = perturbed & _contains(slope, expected, 2) + linear = perturbed & (np.abs(slope) > 2 * response_slack) & ~containing + if linear.any(): + screens.append( + problem(variable, "contains", linear, ~np.isfinite(slope)) + ) + else: + slope = change + containing = perturbed & _contains(change, expected) + containing = containing | ~np.isfinite(slope) + if containing.any(): + problems.append( + problem(variable, "contains", containing, ~np.isfinite(slope)) + ) + elif is_tax: + moved = _moves(change, expected) + if moved.any(): + problems.append(problem(variable, "moves", moved, ~np.isfinite(change))) + else: + rises = _rises(change, slack) + if rises.any(): + problems.append(problem(variable, "rises", rises, ~np.isfinite(change))) + partition_coefficient = float(sum(responses.values())) + model_coefficient = None + if model_change is not None: + model_coefficient = ( + float(np.sum(weights * model_change)) / added if added else 0.0 + ) + heads_change = sum(head_changes.values()) + if declared_double_count: + heads_change = heads_change + expected + not_finite = ~np.isfinite(model_change) | ~np.isfinite(heads_change) + beyond = np.abs(model_change - heads_change) > _slack(model_scale) + mismatch = beyond | not_finite + if mismatch.any(): + problems.append(problem(total, "model_total", mismatch, not_finite)) + return { + "responses": responses, + "coefficient": partition_coefficient, + "model_coefficient": model_coefficient, + "own_problem": own_problem, + "problems": problems, + "screens": screens, + } + + +def _perturb( + sim, variable, year, baseline, added, monthly, hold=(), held_at=None, touched=None +): + discard_formula_results(sim) + if monthly: + for month, values in baseline[variable]: + _set_guard_value(sim, variable, month, values + added / 12, touched=touched) + else: + _set_guard_value( + sim, variable, year, baseline[variable] + added, touched=touched + ) + held_at = baseline if held_at is None else held_at + for held in hold: + if isinstance(held_at[held], list): + for month, values in held_at[held]: + _set_guard_value(sim, held, month, values, touched=touched) + else: + _set_guard_value(sim, held, year, held_at[held], touched=touched) + + +def _capture(sim, variables, year, layout) -> dict: + out = {} + for v in variables: + if isinstance(layout[v], list): + out[v] = [ + (month, _raw(sim, v, month).astype(np.float64)) + for month, _ in layout[v] + ] + else: + out[v] = _raw(sim, v, year).astype(np.float64) + return out + + +def breaks_on_slope(kind, where, expected, slack, slope): + if kind == "contains": + return where & _contains(slope, expected, allowances=2) + if kind == "rises": + return where & _rises(slope, 2 * slack) + if kind == "moves": + return where & _moves(slope, expected, allowances=2) + return where + + +def still_breaks(kind, where, expected, slack, first, second=None): + if kind == "contains": + if second is not None: + return where & _contains(second - first, expected, allowances=2) + return where & _contains(first, expected) + if kind == "rises": + return where & _rises(first, slack) + if kind == "moves": + return where & _moves(first, expected) + return where + + +def _held_breaks(kind, where, expected, slack, first, second, slope_held, unjudged): + held_at_baseline = still_breaks(kind, where, expected, slack, first, second) + judged = where & ~unjudged + return held_at_baseline | breaks_on_slope(kind, judged, expected, slack, slope_held) + + +def _held_nan(kind, first, second, slope_held, unjudged): + nan = ~np.isfinite(first) | ~np.isfinite(slope_held) & ~unjudged + if kind == "contains" and second is not None: + nan = nan | ~np.isfinite(second) + return nan + + +DIRECT_KINDS = ("contains", "rises", "moves") + + +def counted_once( + sim, + partition: Partition, + year: int, + tolerance: float, + snapshot: Optional[Mapping] = None, + active: Iterable[str] = (), + variables_to_check: Optional[Iterable[str]] = None, +) -> dict: + if not discard_formula_results(sim): + return { + "status": "skipped", + "reason": "this policyengine-core has no way to discard formula results", + } + snapshot = _guard_snapshot(sim, snapshot, partition.component_variables()) + active = set(active) + definitions = sim.tax_benefit_system.variables + totals = [t for t in partition.totals if partition.heads_in(t, active)] + heads_of = {t: [h.variable for h in partition.heads_in(t, active)] for t in totals} + all_heads = list(dict.fromkeys((v for t in totals for v in heads_of[t]))) + model_totals = [t for t in totals if t in definitions] + hw = _raw(sim, "household_weight", year).astype(np.float64) + heads0 = _head_arrays(sim, year, all_heads) + model0 = {t: _raw(sim, t, year).astype(np.float64) for t in model_totals} + baseline = {} + for variable in partition.component_variables(): + if definitions[variable].definition_period == "month": + baseline[variable] = [ + (month, _raw(sim, variable, month).astype(np.float64)) + for month in _months(year) + ] + else: + baseline[variable] = _raw(sim, variable, year).astype(np.float64) + declared = {dc.variable: dc for dc in partition.double_counts} + head_by_variable = {h.variable: h for h in partition.heads} + selected = None if variables_to_check is None else set(variables_to_check) + results, double_count_results = ([], []) + touched = set() + try: + for variable in partition.component_variables(): + if selected is not None and variable not in selected: + continue + definition = definitions[variable] + entity = definition.entity.key + in_totals = [ + t for t in totals if variable in partition.components(t, active) + ] + record = { + "variable": variable, + "totals": in_totals, + "coefficients": {}, + "model_coefficients": {}, + "responses": {}, + "problems": [], + "mediated": [], + "heads_held_skipped": False, + "heads_not_held": {}, + "households_not_held": {}, + "units": 0, + "status": "pass", + "detail": "", + } + if definition.value_type is not float: + record.update(status="skipped", detail="not a money variable") + results.append(record) + continue + monthly = definition.definition_period == "month" + year_values = ( + sum((values for _, values in baseline[variable])) + if monthly + else baseline[variable] + ) + nonzero = year_values != 0 + zero_baseline = not bool(nonzero.any()) + mask = np.ones_like(nonzero) if zero_baseline else nonzero + added = mask.astype(np.float64) + expected = _household(sim, added, entity) + record["units"] = int(round(float(np.sum(expected[hw > 0])))) + if not float(np.sum(hw * expected)) > 0: + record.update( + status="skipped", + detail="non-zero only in households with no weight", + ) + results.append(record) + continue + own = declared[variable].contained_in if variable in declared else variable + own_head = head_by_variable[own] + needed = list(dict.fromkeys((v for t in in_totals for v in heads_of[t]))) + readers_of = { + total: [ + v + for v in heads_of[total] + if own_head.id in head_by_variable[v].reads + ] + for total in in_totals + } + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + heads1 = _head_arrays(sim, year, needed) + model1 = { + t: _raw(sim, t, year).astype(np.float64) + for t in in_totals + if t in model0 + } + may_respond = { + v + for total in in_totals + for v in heads_of[total] + if v != own + and ( + v in readers_of[total] + or (zero_baseline and (not partition.is_tax_total(total))) + ) + } + heads2 = None + if may_respond: + _perturb( + sim, variable, year, baseline, 2 * added, monthly, touched=touched + ) + heads2 = _head_arrays(sim, year, sorted(may_respond)) + problems, screens = ([], []) + for total in in_totals: + verdict = judge_total( + total=total, + is_tax=partition.is_tax_total(total), + component=variable, + own_variable=own, + declared_double_count=variable in declared + and total in declared[variable].totals, + zero_baseline=zero_baseline, + weights=hw, + expected=expected, + head_changes={v: heads1[v] - heads0[v] for v in heads_of[total]}, + head_scales={v: heads0[v] for v in heads_of[total]}, + model_change=model1[total] - model0[total] + if total in model1 + else None, + model_scale=model0.get(total), + allowed_readers=readers_of[total], + second_changes=None + if heads2 is None + else {v: heads2[v] - heads0[v] for v in heads2}, + ) + record["coefficients"][total] = verdict["coefficient"] + if verdict["model_coefficient"] is not None: + record["model_coefficients"][total] = verdict["model_coefficient"] + record["responses"][total] = { + v: r + for v, r in verdict["responses"].items() + if not abs(r) <= tolerance + } + problems += verdict["problems"] + screens += verdict["screens"] + if verdict["own_problem"] and variable not in declared: + problems.append(verdict["own_problem"]) + mediated = [] + perturbed_units = expected > 0 + for head in dict.fromkeys( + [p["head"] for p in problems if p["kind"] in DIRECT_KINDS] + + [screen["head"] for screen in screens] + ): + held = [v for v in all_heads if v not in (variable, own, head)] + _perturb( + sim, + variable, + year, + baseline, + added, + monthly, + hold=held, + touched=touched, + ) + first = _head_arrays(sim, year, [head])[head] - heads0[head] + second = None + if any( + ( + p["head"] == head and p["kind"] == "contains" + for p in problems + screens + ) + ): + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + touched=touched, + ) + second = _head_arrays(sim, year, [head])[head] - heads0[head] + for v in held: + _restore(sim, v, snapshot) + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + at_first = _capture(sim, held, year, baseline) + head_first = _head_arrays(sim, year, [head])[head] + held_at, unheld_in = ({}, {}) + for v in held: + units = _not_finite_units(at_first[v]) + held_at[v] = at_first[v] + if units.any(): + held_at[v] = _held_values(at_first[v], baseline[v], units) + entity_key = definitions[v].entity.key + unheld_in[v] = _household(sim, units, entity_key) > 0 + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + held_at=held_at, + touched=touched, + ) + slope_held = _head_arrays(sim, year, [head])[head] - head_first + for v in held: + _restore(sim, v, snapshot) + not_held = list(unheld_in) + unjudged = np.zeros(len(hw), dtype=bool) + for v in not_held: + unjudged = unjudged | unheld_in[v] + if not_held: + record["heads_not_held"][head] = not_held + record["households_not_held"][head] = int( + np.count_nonzero(unjudged & (hw > 0)) + ) + + def unheld(where): + return [v for v in not_held if bool((unheld_in[v] & where).any())] + + slack = _slack(heads0[head]) + kept = [] + for p in problems: + if p["head"] != head or p["kind"] not in DIRECT_KINDS: + kept.append(p) + continue + breaks = _held_breaks( + p["kind"], + p["where"], + expected, + slack, + first, + second, + slope_held, + unjudged, + ) + rests = p["where"] & unjudged & ~breaks + direct = breaks | rests + cleared = p["where"] & ~direct + if cleared.any(): + via = [ + v + for v in needed + if v not in (variable, own, head) + and bool( + ( + np.abs(heads1[v] - heads0[v])[cleared] + > _slack(heads0[v])[cleared] + ).any() + ) + ] + nan = cleared & p["nan"] + mediated.append( + { + "total": p["total"], + "head": head, + "kind": p["kind"], + "households": int(np.count_nonzero(cleared & (hw > 0))), + "via": via, + "not_finite": bool(nan.any()), + "not_finite_weighted": bool((nan & (hw > 0)).any()), + } + ) + if direct.any(): + kept.append( + _problem( + p["total"], + head, + p["kind"], + variable, + direct, + hw, + perturbed_units, + p["nan"] + | _held_nan( + p["kind"], first, second, slope_held, unjudged + ), + unheld(rests), + rests, + ) + ) + for screen in screens: + if screen["head"] != head: + continue + confirmed = _held_breaks( + "contains", + screen["where"], + expected, + slack, + first, + second, + slope_held, + unjudged, + ) + unconfirmed = screen["where"] & unjudged & ~confirmed + nan = _held_nan("contains", first, second, slope_held, unjudged) + if confirmed.any(): + not_held_there, rests = ((), None) + same = next( + ( + k + for k in kept + if (k["total"], k["head"], k["kind"]) + == (screen["total"], head, "contains") + ), + None, + ) + if same is not None: + kept.remove(same) + confirmed = confirmed | same["where"] + not_held_there, rests = ( + same["heads_not_held"], + same["unjudged"], + ) + kept.append( + _problem( + screen["total"], + head, + "contains", + variable, + confirmed, + hw, + perturbed_units, + nan if same is None else nan | same["nan"], + not_held_there, + rests, + ) + ) + if unconfirmed.any(): + kept.append( + _problem( + screen["total"], + head, + "unconfirmed", + variable, + unconfirmed, + hw, + perturbed_units, + nan, + unheld(unconfirmed), + unconfirmed, + ) + ) + problems = kept + problems = [ + {k: v for k, v in p.items() if k not in ("where", "nan", "unjudged")} + for p in problems + ] + record["mediated"] = mediated + record["heads_held_skipped"] = bool(record["heads_not_held"]) + if variable in declared: + dc = declared[variable] + container_change = heads1[own] - heads0[own] + k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) + finite = bool(np.isfinite(container_change).all()) + present = finite and ( + not bool( + ( + np.abs(container_change - expected) > _slack(heads0[own]) + ).any() + ) + ) + row = { + "variable": variable, + "contained_in": dc.contained_in, + "coefficient": k, + "issue": dc.issue, + "status": "pass" if present else "fail", + "not_finite": not finite, + "detail": "" + if present + else f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: the model no longer counts it twice, so remove the declaration and give it its own head", + } + if not finite: + row["detail"] = ( + f"{dc.contained_in} is not finite when {variable} rises, so the check cannot show that {dc.contained_in} still contains {variable}" + ) + double_count_results.append(row) + if problems: + record.update( + status="fail", + problems=problems, + detail="; ".join((p["message"] for p in problems)), + ) + else: + record["detail"] = "; ".join( + ( + not_held_note( + variable, head, heads, record["households_not_held"][head] + ) + for head, heads in record["heads_not_held"].items() + ) + ) + results.append(record) + _restore(sim, variable, snapshot) + finally: + for variable in sorted(touched): + _restore(sim, variable, snapshot) + discard_formula_results(sim) + heads_again = _head_arrays(sim, year, all_heads) + reproduces = all((np.array_equal(heads_again[v], heads0[v]) for v in all_heads)) + return { + "status": "ran", + "year": year, + "tolerance": tolerance, + "components": results, + "double_counts": double_count_results, + "reset_reproduces_baseline": bool(reproduces), + } + + +def publishable_guard(guard: dict) -> dict: + out = dict(guard) + components = [] + for row in guard.get("components") or []: + row = dict(row) + few_units = small(row.get("units") or 0) + if few_units: + row["units"] = None + for key in ("responses", "coefficients", "model_coefficients"): + if key in row: + row[key] = {} + for key in ("problems", "mediated"): + cleaned = [] + for item in row.get(key) or []: + item = dict(item) + if few_units or small(item.get("households") or 0): + item.pop("share", None) + if small(item.get("households") or 0): + item["households"] = None + if small(item.get("households_not_held") or 0): + item["households_not_held"] = None + cleaned.append(item) + row[key] = cleaned + if "households_not_held" in row: + row["households_not_held"] = { + head: None if small(count or 0) else count + for head, count in row["households_not_held"].items() + } + components.append(row) + if "components" in guard: + out["components"] = components + units = { + row.get("variable"): row.get("units") for row in guard.get("components") or [] + } + double_counts = [] + for row in guard.get("double_counts") or []: + row = dict(row) + if small(units.get(row.get("variable")) or 0): + row["coefficient"] = None + if row.get("status") != "pass" and (not row.get("not_finite")): + row["detail"] = ( + f"{row['contained_in']} no longer moves one for one with {row['variable']} (fewer than {MIN_CELL} units): the model no longer counts it twice, so remove the declaration and give it its own head" + ) + double_counts.append(row) + if "double_counts" in guard: + out["double_counts"] = double_counts + return out + + +def record_guard_checks(rec, guard: dict) -> None: + for result in guard.get("components", []): + check_id = f"counted_once:{result['variable']}" + label = f"Raising {result['variable']} by £1 raises its own head by £1, moves no other tax head and raises no other benefit" + if result["status"] == "skipped": + rec.check( + check_id, + label, + None, + result["detail"], + section="counted_once", + scope="", + ) + elif result["status"] == "pass": + rec.check( + check_id, + label, + True, + section="counted_once", + scope="", + note=result["detail"], + ) + for item in result.get("problems") or []: + rec.check( + check_id, + label, + False, + item["message"], + section="counted_once", + scope=f"{item['total']}:{item['head']}:{item['kind']}", + ) + for result in publishable_guard(guard).get("double_counts", []): + rec.check( + f"double_count_present:{result['variable']}", + f"{result['variable']} is still contained in {result['contained_in']}", + result["status"] == "pass", + result["detail"], + section="counted_once", + ) + if "reset_reproduces_baseline" in guard: + rec.check( + "counted_once:reset_reproduces_baseline", + "The counted-once check leaves the baseline unchanged", + bool(guard["reset_reproduces_baseline"]), + "recomputed baseline totals differ from the first run", + section="counted_once", + ) + rec.check( + "counted_once:ran", + "The counted-once check ran", + guard.get("status") == "ran", + guard.get("reason", ""), + severity="warn", + section="counted_once", + ) + + +def _finite(value) -> bool: + return isinstance(value, (int, float)) and math.isfinite(value) + + +def _guard(summary) -> str: + guard = (summary.get("head") or {}).get("guard") or {} + if guard.get("status") != "ran": + return f"Counted-once check not run: {guard.get('reason', 'unknown reason')}." + components = guard.get("components", []) + tested = [c for c in components if c["status"] != "skipped"] + failed = [c for c in tested if c["status"] == "fail"] + skipped = [c for c in components if c["status"] == "skipped"] + lines = [ + f"The check raised each of {len(tested)} components by £1 per unit in {fiscal_year_label(guard['year'])} and read every head of its totals household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A response that is not finite breaks every rule. The check measures a head that breaks a rule twice more: once holding every other head at its baseline value, and once holding every other head at its value after the first £1 while the component rises again. The head fails where either measurement still shows the response. The check cannot hold a head at a value that is not finite. In each unit where another head is not finite after the first £1, it holds that head at its baseline value, and the second measurement judges no household the unit belongs to. There a head fails wherever it first broke a rule, or wherever the check could not confirm whether its response contains the component. {len(failed)} failed and the check skipped {len(skipped)}." + ] + for c in failed: + problems = c.get("problems") or [{"message": c.get("detail", "")}] + lines.append( + f"- `{c['variable']}`: " + "; ".join((p["message"] for p in problems)) + ) + for c in skipped: + lines.append(f"- `{c['variable']}` skipped: {c.get('detail', '')}") + code = "`{}`".format + for c in tested: + counts = c.get("households_not_held") or {} + for head, heads in (c.get("heads_not_held") or {}).items(): + note = not_held_note(c["variable"], head, heads, counts.get(head), code) + lines.append(f"- `{c['variable']}`: {note}") + for c in tested: + for m in c.get("mediated") or []: + moved = ", ".join((code(v) for v in m["via"])) or "none" + text = problem_text( + "mediated", + code(m["head"]), + code(c["variable"]), + m.get("households"), + m.get("not_finite", False), + m.get("not_finite_weighted", True), + ) + lines.append( + f"- `{c['variable']}`: {text} (other heads that moved there: {moved}; {m['total']}; not a failure)" + ) + for dc in guard.get("double_counts", []): + if dc.get("not_finite"): + lines.append( + f"- declared double count `{dc['variable']}`: `{dc['contained_in']}` is NOT FINITE when `{dc['variable']}` rises, so the check cannot show that `{dc['contained_in']}` still contains `{dc['variable']}` ({dc['issue']})" + ) + continue + state = "still contained" if dc["status"] == "pass" else "NO LONGER contained" + coefficient = ( + f"coefficient {dc['coefficient']:.3f}" + if _finite(dc.get("coefficient")) + else "coefficient not shown, fewer than 10 units" + ) + lines.append( + f"- declared double count `{dc['variable']}` is {state} in `{dc['contained_in']}` ({coefficient}; {dc['issue']})" + ) + return "\n".join(lines) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py index d33dc14140..db5fec11e5 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py @@ -776,12 +776,13 @@ def test_two_heads_that_turn_not_finite_fail_by_name(response, route): assert result["reset_reproduces_baseline"] -def core_counted_once(formulas, part, monthly): +def core_counted_once(formulas, part, monthly, **options): """counted_once on a policyengine-core simulation of one household. ``formulas`` maps each head to None (an input, 5 in every period) or a function of a reader by name and the period. Heads are monthly or - yearly; gov_tax and household_weight are yearly. + yearly; gov_tax and household_weight are yearly. ``options`` go to + counted_once. """ from policyengine_core.country_template import CountryTaxBenefitSystem from policyengine_core.country_template.entities import Household @@ -829,7 +830,7 @@ def household_variable(name, formula=None, default=0.0, definition_period=None): }, ) with np.errstate(invalid="ignore"): - return sim, counted_once(sim, part, YEAR, 1e-6) + return sim, counted_once(sim, part, YEAR, 1e-6, **options) @pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py new file mode 100644 index 0000000000..1a7f838772 --- /dev/null +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py @@ -0,0 +1,807 @@ +"""The counted-once check where a head, a model total or a component is not +finite at baseline (#2773), on the fake simulations of +``test_aggregate_impact_guard`` and on policyengine-core. + +Such a value has no change to read. The check names each variable it could +not read once, as a failure of a ``finite:`` check; it fails no component for +it, never gives the engine NaN, and judges every other reading as before. +``counted_once_base_reference`` holds the check as it stood before: every +model whose heads and totals are finite at baseline must publish exactly what +it published. +""" + +import json +from types import SimpleNamespace + +import counted_once_base_reference as reference +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from test_aggregate_impact_guard import ( + GATED, + HEADS_OF, + MEDIATED_NAN, + MEDIATED_NAN_READS, + OVERLAP, + READS, + SHAPES, + YEAR, + EntitySim, + FakeSim, + by_variable, + core_counted_once, + declared_reads, + gated_taxes, + household_by_household, + kinds, + mediated_in_two_totals, + messages, + model, + overlap_model, + partition, + run_not_finite, + shapes_formulas, + two_tax_totals, +) + +from policyengine_uk.utils.aggregate_impact import guard, measure, render +from policyengine_uk.utils.aggregate_impact.compare import ( + ALWAYS_BLOCKING_PREFIXES, + classify_check, +) + +NOT_FINITE_VALUES = [np.nan, np.inf, -np.inf] + + +def named(result): + """{variable: head id} of what the result says is not finite at baseline.""" + return { + item["variable"]: item["head"] + for item in result.get("not_finite_at_baseline", []) + } + + +def note(variable, where="fewer than 10 households"): + return ( + f"{variable} is not finite at baseline in {where}, so the counted-once " + "check could not confirm it there" + ) + + +def recorded(result): + rec = measure.Recorder() + measure.record_guard_checks(rec, result) + return rec.checks + + +# tax_a is sqrt(-1): NaN at baseline, whatever tax_b does. +NAN_TAX_A = { + "tax_b": None, + "tax_a": lambda f, period: np.sqrt(-1 + 0 * f("tax_b")), +} + + +@pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) +def test_a_component_nan_at_baseline_is_named_and_never_given_to_the_engine( + monthly, +): + """Issue #2773, case 1, on policyengine-core. Raising tax_a, NaN at + baseline, gave the engine NaN, and the check stopped with "ValueError: + Unable to set value for variable "tax_a", as the input contains NaN + values." Now the check raises no unit where tax_a is not finite, so it + skips tax_a, which has no other unit, and passes tax_b. It names tax_a, + and gov_tax, which adds it, once each.""" + sim, result = core_counted_once(NAN_TAX_A, partition(["tax_a", "tax_b"]), monthly) + rows = by_variable(result) + assert list(rows) == ["tax_a", "tax_b"] + assert rows["tax_a"]["status"] == "skipped" + assert rows["tax_a"]["detail"] == ( + "the check could raise it only in households with no weight or where it " + "is not finite at baseline" + ) + assert rows["tax_b"]["status"] == "pass" and rows["tax_b"]["detail"] == "" + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert [item["message"] for item in result["not_finite_at_baseline"]] == [ + note("tax_a"), + note("gov_tax"), + ] + assert result["reset_reproduces_baseline"] + assert np.isnan(sim.calculate("tax_a", YEAR)).all() + + +def test_holding_a_head_nan_at_baseline_raises_nothing(): + """Issue #2773, case 2: tax_c is tax_b, so it moves with tax_b. To measure + tax_c again, the check held tax_a at its baseline, NaN, and the engine + raised the same ValueError. Now it leaves tax_a to its formula, which + gives it back NaN, so tax_a is held at its baseline and the check finds + tax_c moving with tax_b, as it does.""" + formulas = {**NAN_TAX_A, "tax_c": lambda f, period: f("tax_b")} + _, result = core_counted_once( + formulas, + partition(["tax_a", "tax_b", "tax_c"]), + False, + variables_to_check=["tax_b"], + ) + row = by_variable(result)["tax_b"] + assert messages(row) == { + ("tax_c", "moves"): ( + "gov_tax: tax_c moves when tax_b does in fewer than 10 households" + ) + } + (problem,) = row["problems"] + assert problem["heads_not_held"] == [] and problem["households_not_held"] == 0 + assert "heads_not_held_at_baseline" not in problem + assert not problem["not_finite"] and not row["heads_held_skipped"] + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["reset_reproduces_baseline"] + + +def test_a_head_nan_at_baseline_fails_no_component_it_ignores(): + """Issue #2773, cases 3 and 4. Checking tax_b alone, the check read tax_a's + change as NaN - NaN and failed tax_b with "gov_tax: tax_a moves, or is + not finite, when tax_b does in fewer than 10 households" and "gov_tax: + the model's gov_tax moves by other than its heads, or is not finite, when + tax_b does in fewer than 10 households", and its reset check returned + False. Now no rule reads tax_a, or gov_tax, which adds it, in that + household: tax_b passes, its own head rising by exactly 1. No response or + coefficient comes from a household that cannot be read, and the reset + check finds tax_a NaN where it started.""" + _, result = core_counted_once( + NAN_TAX_A, + partition(["tax_a", "tax_b"]), + False, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert row["status"] == "pass" and row["problems"] == [] + assert row["responses"] == {"gov_tax": {"tax_b": 1.0}} + assert row["coefficients"] == {"gov_tax": 1.0} + assert row["model_coefficients"] == {} + assert result["reset_reproduces_baseline"] is True + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + + +class LastComputationSim(FakeSim): + """FakeSim whose ``watched`` variable takes ``after`` the ``at``-th time + it is computed: with ``at`` the count of a run that changed nothing, the + final recomputation, which the reset check reads.""" + + def __init__(self, formulas, inputs, weights, watched, after=None, at=None): + super().__init__(formulas, inputs, weights) + self.watched, self.after, self.at, self.computations = watched, after, at, 0 + + def calculate(self, variable, period, unweighted=True, map_to=None): + key = (variable, str(period)) + if variable == self.watched and key not in self.inputs | self.cache: + self.computations += 1 + if self.computations == self.at: + self.cache[key] = np.asarray(self.after, dtype=float) + return super().calculate(variable, period, unweighted, map_to) + + +@pytest.mark.parametrize( + "before, after, same", + [ + ([np.nan, 1.0], [np.nan, 1.0], True), + ([np.inf, 1.0], [np.inf, 1.0], True), + ([1.0, 1.0], [np.nan, 1.0], False), + ([np.nan, 1.0], [1.0, np.nan], False), + ([np.nan, 1.0], [0.0, 1.0], False), + ([np.inf, 1.0], [-np.inf, 1.0], False), + ([np.nan, 1.0], [np.nan, 1.5], False), + ], +) +def test_the_reset_check_matches_nan_only_with_nan_in_the_same_place( + before, after, same +): + """The reset check treats NaN as equal to NaN in the same household and to + nothing else, so a value that turns NaN after the reset, or a NaN that + moves, still counts as a change.""" + formulas = { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "tax_a": lambda f: np.array(before) + 0 * f("x"), + "gov_tax": lambda f: f("tax_a") + f("tax_b"), + } + part = partition(["tax_a", "tax_b"]) + + def check(sim): + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(invalid="ignore"): + return guard.counted_once(sim, part, YEAR, 1e-6, snapshot) + + probe = LastComputationSim(formulas, {"x": (1, 1)}, (1, 1), "tax_a") + assert check(probe)["reset_reproduces_baseline"] + sim = LastComputationSim( + formulas, {"x": (1, 1)}, (1, 1), "tax_a", after, probe.computations + ) + assert check(sim)["reset_reproduces_baseline"] is same + + +# Three households of weights 1.5, 2 and 0.5; ``FIRST`` marks the first. +WEIGHTS = (1.5, 2.0, 0.5) +FIRST = np.array([True, False, False]) + + +def run_three(formulas, part, **options): + sim = FakeSim(formulas, {"x": (1000, 2000, 3000)}, WEIGHTS) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + return guard.counted_once(sim, part, YEAR, 1e-6, snapshot, **options) + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_head_not_finite_in_one_household_is_judged_in_the_others(bad): + """tax_a contains tax_b, and is not finite at baseline in the first + household. The check fails tax_b for tax_a in the other two, with their + share of the weight, and reads neither tax_a nor gov_tax in the first.""" + formulas = model(contains=True) + formulas["tax_a"] = lambda f: np.where(FIRST, bad, f("tax_b") + 0 * f("x")) + result = run_three(formulas, partition(["tax_a", "tax_b"])) + row = by_variable(result)["tax_b"] + assert messages(row) == { + ("tax_a", "moves"): ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households" + ) + } + (problem,) = row["problems"] + assert problem["households"] == 2 and problem["share"] == pytest.approx(2.5 / 4) + assert row["responses"]["gov_tax"] == {"tax_a": 1.0, "tax_b": 1.0} + assert row["model_coefficients"] == {"gov_tax": 2.0} + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["not_finite_at_baseline"][0]["households"] == 1 + assert result["reset_reproduces_baseline"] + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_component_not_finite_in_one_household_is_checked_in_the_others(bad): + """tax_a is not finite at baseline in the first household, and tax_c + holds tax_a, or 3 where tax_a is not finite. Raising tax_a, the check + sets the first household's tax_a to 0, which moves tax_c there by -3; + it judges nothing there, and fails tax_a for tax_c in the other two.""" + formulas = model(contains=False) + formulas["tax_a"] = lambda f: np.where(FIRST, bad, 0.1 * f("x")) + formulas["tax_c"] = lambda f: np.where(np.isfinite(f("tax_a")), f("tax_a"), 3.0) + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + f("tax_c") + result = run_three( + formulas, partition(["tax_a", "tax_b", "tax_c"]), variables_to_check=["tax_a"] + ) + (row,) = result["components"] + assert row["units"] == 2 + assert kinds(row) == {("tax_c", "moves")} + (problem,) = row["problems"] + assert problem["households"] == 2 and problem["share"] == 1.0 + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["reset_reproduces_baseline"] + + +def mediated_with(tax_n): + """tax_a responds to tax_b only through tax_d, which declares its read of + tax_b; tax_n, a tax no head reads, is ``tax_n``.""" + return { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "tax_d": lambda f: 0.2 * f("tax_b"), + "tax_a": lambda f: 0.5 * f("tax_d"), + "tax_n": tax_n, + "gov_tax": lambda f: f("tax_a") + f("tax_b") + f("tax_d") + f("tax_n"), + } + + +MEDIATED = partition(["tax_b", "tax_d", "tax_a", "tax_n"], reads={"tax_d": ["tax_b"]}) + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_held_head_not_finite_at_baseline_is_held_by_its_formula(bad): + """tax_n, a tax no head reads, is not finite at baseline in the first + household. Its formula gives it back its baseline in every held + measurement, so the check holds it exactly and clears tax_a, which + responds only through tax_d, in all three households: tax_b's result is + the one it gets with tax_n finite.""" + result = run_three( + mediated_with(lambda f: np.where(FIRST, bad, 7.0)), + MEDIATED, + variables_to_check=["tax_b"], + ) + finite = run_three( + mediated_with(lambda f: 7.0 + 0 * f("x")), + MEDIATED, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert row["status"] == "pass" and row["mediated"][0]["households"] == 3 + assert row == finite["components"][0] + assert named(result) == {"tax_n": "tax_n", "gov_tax": None} + assert "not_finite_at_baseline" not in finite + + +MOVES_WITH_TAX_B = ( + "the check could not hold tax_n, which is not finite at baseline in some " + "households and moves there with tax_b" +) + + +def test_a_held_head_that_moves_leaves_only_those_households_unjudged(): + """tax_n is NaN at baseline in the first household and tax_b in the + others, so it moves with tax_b there. Left to its formula in the held + measurements, it gives back its baseline only in the first household, + which the check judges: tax_a, carried by tax_d, is mediated there. In + the other two, tax_a's failure stays, and the check could not confirm + tax_d, a declared reader with a slope of 0.2; both name tax_n. tax_n + fails as it contains tax_b.""" + result = run_three( + mediated_with(lambda f: np.where(FIRST, np.nan, f("tax_b"))), + MEDIATED, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert messages(row) == { + ("tax_n", "moves"): ( + "gov_tax: tax_n moves when tax_b does in fewer than 10 households" + ), + ("tax_a", "moves"): ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households (in " + f"fewer than 10 of them {MOVES_WITH_TAX_B}, so it could not show that " + "tax_a responds only through other heads)" + ), + ("tax_d", "unconfirmed"): ( + "gov_tax: tax_d responds to tax_b in fewer than 10 households where " + f"{MOVES_WITH_TAX_B}, so it could not confirm whether tax_d contains " + "tax_b" + ), + } + assert {p["head"]: p["households"] for p in row["problems"]} == { + "tax_n": 2, + "tax_a": 2, + "tax_d": 2, + } + for problem in row["problems"]: + if problem["head"] != "tax_n": + assert problem["heads_not_held_at_baseline"] == ["tax_n"] + assert problem["households_not_held"] == 2 + (mediated,) = row["mediated"] + assert (mediated["head"], mediated["households"]) == ("tax_a", 1) + assert row["heads_not_held"] == {} and row["heads_held_skipped"] + + +def test_a_problem_names_heads_the_check_could_not_hold_for_either_reason(): + """One household: ben_c turns NaN once tax_b rises, and tax_n, NaN at + baseline, turns 0. tax_a's failure names both, each with its reason.""" + formulas = mediated_with(lambda f: np.where(f("tax_b") > 5.5, 0.0, np.nan)) + formulas["ben_c"] = lambda f: np.sqrt(5 - f("tax_b")) + formulas["gov_spending"] = lambda f: f("ben_c") + part = partition( + ["tax_b", "tax_d", "tax_a", "tax_n"], ["ben_c"], reads={"tax_d": ["tax_b"]} + ) + _, result = run_not_finite(formulas, part, x=(1,), weights=(1,)) + row = by_variable(result)["tax_b"] + assert messages(row)[("tax_a", "moves")] == ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households (in " + "fewer than 10 of them the check could not hold ben_c, which is not " + "finite there once tax_b rises, and tax_n, which is not finite at " + "baseline in some households and moves there with tax_b, so it could " + "not show that tax_a responds only through other heads)" + ) + problem = next(p for p in row["problems"] if p["head"] == "tax_a") + assert problem["heads_not_held"] == ["ben_c"] + assert problem["heads_not_held_at_baseline"] == ["tax_n"] + assert row["heads_not_held"]["tax_a"] == ["ben_c"] + assert all(p["head"] != "tax_n" for p in row["problems"]) + + +def test_a_model_total_not_finite_at_baseline_is_named_and_fails_no_component(): + """gov_tax adds a NaN term in the first household that no head holds. The + check reads gov_tax in the other two only, fails no component for it, + names it, and records it under finite:metrics.""" + formulas = model(contains=False) + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + np.where(FIRST, np.nan, 0) + result = run_three(formulas, partition(["tax_a", "tax_b"])) + assert all(row["status"] == "pass" for row in result["components"]) + assert named(result) == {"gov_tax": None} + checks = recorded(result) + assert checks["finite:metrics"]["failing"] == { + f"counted_once:{YEAR}:gov_tax": f"2026-27: {note('gov_tax')}" + } + assert checks["finite:metrics"]["label"] == "Every reported figure is finite" + + +def declared_double_count(container): + """tax_a contains tax_b, as the partition declares, and is ``container`` + of tax_b and x.""" + formulas = model(contains=True) + formulas["tax_a"] = container + return formulas, partition(["tax_a"], [], [("tax_b", "tax_a")]) + + +def test_a_container_not_finite_in_one_household_is_judged_in_the_others(): + formulas, part = declared_double_count( + lambda f: np.where(FIRST, np.nan, 0.1 * f("x") + f("tax_b")) + ) + result = run_three(formulas, part) + (row,) = result["double_counts"] + assert row["status"] == "pass" and row["coefficient"] == pytest.approx(1.0) + assert by_variable(result)["tax_b"]["status"] == "pass" + + +def test_a_container_not_finite_everywhere_skips_the_double_count(): + """The check cannot show that tax_a still contains tax_b, and says so; a + finite: check names tax_a.""" + formulas, part = declared_double_count(lambda f: np.nan + f("tax_b")) + result = run_three(formulas, part) + (row,) = result["double_counts"] + assert row["status"] == "skipped" and row["coefficient"] is None + assert row["detail"] == ( + "tax_a is not finite at baseline in every household with weight where " + "the check raised tax_b, so it cannot show that tax_a still contains tax_b" + ) + checks = recorded(result) + assert checks["double_count_present:tax_b"]["status"] == "skipped" + assert checks["finite:tax_a"]["status"] == "fail" + text = render._guard({"head": {"guard": measure.publishable_guard(result)}}) + assert ( + f"- declared double count `tax_b` not checked: {row['detail']} " + f"({row['issue']})" in text + ) + + +def test_a_double_count_not_finite_everywhere_is_skipped_and_named(): + """tax_b, the declared double count, is NaN at baseline everywhere: the + check skips it, says why, and names it under finite:metrics.""" + formulas, part = declared_double_count(lambda f: 0.1 * f("x") + f("tax_b")) + formulas["tax_b"] = lambda f: np.nan + 0 * f("x") + result = run_three(formulas, part) + assert by_variable(result)["tax_b"]["status"] == "skipped" + (row,) = result["double_counts"] + assert row["status"] == "skipped" and row["detail"] == ( + "the check could raise tax_b only in households with no weight or where " + "it is not finite at baseline, so it cannot show that tax_a still " + "contains tax_b" + ) + assert named(result) == {"tax_a": "tax_a", "gov_tax": None, "tax_b": None} + assert set(recorded(result)["finite:metrics"]["failing"]) == { + f"counted_once:{YEAR}:gov_tax", + f"counted_once:{YEAR}:tax_b", + } + + +def test_a_benefit_unit_not_finite_at_baseline_leaves_only_its_household(): + """ben_u, a benefit of each benefit unit, is NaN at baseline in the second + household's second unit; ben_w, a household benefit, adds each unit's + finite ben_u. Raising ben_u, the check judges neither the second + household nor ben_u's change there, and fails ben_u for ben_w in the + first and third households alone.""" + people = [(0, 0), (1, 1), (2, 1), (3, 2)] + bad = np.array([False, False, True, False]) + formulas = { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "gov_tax": lambda f: f("tax_b"), + "ben_u": lambda f: np.where(bad, np.nan, 100.0), + "ben_w": lambda f: sim.map_result( + np.nan_to_num(f("ben_u")), "benunit", "household" + ), + "gov_spending": lambda f: ( + sim.map_result(f("ben_u"), "benunit", "household") + f("ben_w") + ), + } + sim = EntitySim(formulas, {"x": (1, 1, 1)}, (1, 2, 4), {"ben_u": "benunit"}, people) + part = partition(["tax_b"], ["ben_u", "ben_w"], entities={"ben_u": "benunit"}) + result = guard.counted_once( + sim, part, YEAR, 1e-6, guard.snapshot_inputs(sim, part.component_variables()) + ) + row = by_variable(result)["ben_u"] + assert row["units"] == 2 + (problem,) = row["problems"] + assert (problem["head"], problem["kind"], problem["households"]) == ( + "ben_w", + "rises", + 2, + ) + assert problem["share"] == 1.0 + assert result["not_finite_at_baseline"][0] == { + "variable": "ben_u", + "head": "ben_u", + "label": "ben_u", + "households": 1, + "message": note("ben_u"), + } + + +def test_the_named_result_blocks_whatever_the_base_did(): + """A head not finite at baseline fails its own finite: check in a scope + naming the year and the head, which a comparison blocks on even when + the base fails it too. No counted-once check fails for it.""" + _, result = core_counted_once(NAN_TAX_A, partition(["tax_a", "tax_b"]), False) + checks = recorded(result) + scope = f"counted_once:{YEAR}:tax_a" + assert checks["finite:tax_a"]["failing"] == {scope: f"2026-27: {note('tax_a')}"} + assert checks["finite:tax_a"]["label"] == "tax_a is finite for every unit" + assert checks["finite:tax_a"]["section"] == "finite" + assert all( + check["status"] != "fail" + for check_id, check in checks.items() + if check_id.startswith("counted_once:") + ) + for check_id, check in checks.items(): + if check["status"] == "fail": + assert check_id.startswith(ALWAYS_BLOCKING_PREFIXES) + assert ( + classify_check("fail", "fail", True, check["failing"], check["failing"]) + == "regression" + ) + + +@pytest.mark.parametrize("households", [3, 12]) +def test_the_published_guard_keeps_no_small_count_of_not_finite_households( + households, +): + """The published guard and the report keep a count of 10 or more + households, and say "fewer than 10" for fewer.""" + formulas = model(contains=False) + some = np.arange(12) < households + formulas["tax_a"] = lambda f: np.where(some, np.nan, 0.1 * f("x")) + sim = FakeSim(formulas, {"x": (1,) * 12}, (1,) * 12) + part = partition(["tax_a", "tax_b"]) + result = guard.counted_once( + sim, part, YEAR, 1e-6, guard.snapshot_inputs(sim, part.component_variables()) + ) + published = measure.publishable_guard(result) + shown = None if households < 10 else households + assert [item["households"] for item in published["not_finite_at_baseline"]] == [ + shown, + shown, + ] + where = "fewer than 10 households" if households < 10 else "12 households" + text = render._guard({"head": {"guard": published}}) + assert f"- {note('`tax_a`', where)}" in text.splitlines() + assert f"- {note('`gov_tax`', where)}" in text.splitlines() + + +# The property's extra heads: no head reads them, and each is not finite at +# baseline in any set of households (none, for a finite head). +EXTRA_TOTAL = {"tax_n": "gov_tax", "ben_n": "gov_spending"} + + +@st.composite +def extra_heads(draw): + shapes = draw(st.lists(SHAPES, min_size=1, max_size=3)) + extra = {} + for head in sorted(draw(st.sets(st.sampled_from(sorted(EXTRA_TOTAL))))): + where = draw( + st.lists(st.booleans(), min_size=len(shapes), max_size=len(shapes)) + ) + extra[head] = (np.array(where), draw(st.sampled_from(NOT_FINITE_VALUES))) + return shapes, extra + + +def with_extra_heads(shapes, extra, reads): + """The properties' model and partition, with ``extra`` heads.""" + formulas = shapes_formulas(shapes) + heads_of = {total: list(heads) for total, heads in HEADS_OF.items()} + for head, (where, value) in extra.items(): + formulas[head] = lambda f, where=where, value=value: np.where(where, value, 7.0) + heads_of[EXTRA_TOTAL[head]].append(head) + for total, heads in heads_of.items(): + formulas[total] = lambda f, heads=heads: sum(f(v) for v in heads) + part = partition(heads_of["gov_tax"], heads_of["gov_spending"], reads=reads) + return formulas, part + + +NAN_IN_FIRST = {"tax_n": (np.array([True, False]), np.nan)} + + +@settings(max_examples=40, deadline=None) +@given(case=extra_heads(), reads=READS) +@example(case=([MEDIATED_NAN, MEDIATED_NAN], NAN_IN_FIRST), reads=MEDIATED_NAN_READS) +@example( + case=( + [("half", "flat", "flat", "flat"), ("flat", "flat", "flat", "flat")], + {"ben_n": (np.array([True, True]), -np.inf)}, + ), + reads=[True, False, False, False, False, False], +) +def test_heads_not_finite_at_baseline_are_named_and_change_nothing_else(case, reads): + """For any heads, with any of the extra heads (which no head reads, and + which respond to nothing) not finite at baseline in any households, the + check returns, names exactly those heads, and the totals that add them, + in blocking finite: checks, and fails no component for them. In each + household it finds what it finds with those heads removed, except that + it reads no total that adds a head not finite there.""" + shapes, extra = case + households = len(shapes) + declared = declared_reads(reads) + bad = {head: where for head, (where, _) in extra.items() if where.any()} + formulas, part = with_extra_heads(shapes, extra, declared) + kept = {head: spec for head, spec in extra.items() if head not in bad} + formulas_without, part_without = with_extra_heads(shapes, kept, declared) + _, result = run_not_finite( + formulas, part, x=(1,) * households, weights=(1,) * households + ) + bad_totals = {EXTRA_TOTAL[head] for head in bad} + assert named(result) == { + **{head: head for head in bad}, + **{total: None for total in bad_totals}, + } + checks = recorded(result) + failing = { + c + for c, check in checks.items() + if check["status"] == "fail" and c.startswith(ALWAYS_BLOCKING_PREFIXES) + } + assert failing == {f"finite:{head}" for head in bad} | ( + {"finite:metrics"} if bad_totals else set() + ) + for row in result["components"]: + assert all(p["head"] not in bad for p in row["problems"]) + if row["variable"] in bad: + assert row["status"] != "fail" + assert row["heads_held_skipped"] == bool(row["heads_not_held"]) or any( + p.get("heads_not_held_at_baseline") for p in row["problems"] + ) + assert result["reset_reproduces_baseline"] + + found = household_by_household(formulas, part, households) + without = household_by_household(formulas_without, part_without, households) + for k in range(households): + here = {head for head, where in bad.items() if where[k]} + for component, before in without[k].items(): + after = found[k][component] + if not here: + assert after == before + continue + totals_here = {EXTRA_TOTAL[head] for head in here} + assert after == { + **before, + "problems": [ + p + for p in before["problems"] + if not (p[2] == "model_total" and p[0] in totals_here) + ], + } + + +def published(check, record, publish, describe, formulas, part, x, weights): + """What one implementation publishes for a model, as JSON: the check's + result, the checks it records, the published guard and the report's + section.""" + sim = FakeSim(formulas, {"x": x}, weights) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + result = check(sim, part, YEAR, 1e-6, snapshot) + rec = measure.Recorder() + record(rec, result) + publishable = publish(result) + return json.dumps( + { + "result": result, + "checks": list(rec.checks.values()), + "published": publishable, + "report": describe({"head": {"guard": publishable}}), + } + ) + + +NOW = ( + guard.counted_once, + measure.record_guard_checks, + measure.publishable_guard, + render._guard, +) +BEFORE = ( + reference.counted_once, + reference.record_guard_checks, + reference.publishable_guard, + reference._guard, +) + + +def assert_publishes_as_before(formulas, part, x, weights): + assert published(*NOW, formulas, part, x, weights) == published( + *BEFORE, formulas, part, x, weights + ) + + +def stale_double_count(): + return declared_double_count(lambda f: 0.1 * f("x")) + + +# The guard tests' own models, on their three households. +MODELS = { + "containment": lambda: (model(contains=True), partition(["tax_a", "tax_b"])), + "means test": lambda: ( + model(contains=False), + partition(["tax_a"], ["ben_c", "ben_d"]), + ), + "rising benefit": lambda: ( + model(interaction=0.5), + partition(["tax_a"], ["ben_c", "ben_d"]), + ), + "declared double count": lambda: declared_double_count( + lambda f: 0.1 * f("x") + f("tax_b") + ), + "stale double count": stale_double_count, + "overlapping benefits": lambda: (overlap_model(), OVERLAP), + "overlap containing ib": lambda: (overlap_model(uc_contains_ib=1.0), OVERLAP), + "gated containment": lambda: ( + { + **gated_taxes(), + "ben_c": lambda f: 100 + 0 * f("x"), + "gov_spending": lambda f: f("ben_c"), + }, + GATED, + ), +} + + +@pytest.mark.parametrize("name", MODELS) +def test_every_finite_model_publishes_what_it_did_before(name): + """Differential against ``counted_once_base_reference``: with every head + and total finite at baseline, the check, the checks it records, the + published guard and the report say exactly what they said before, in + the same order.""" + formulas, part = MODELS[name]() + assert_publishes_as_before(formulas, part, (1000, 2000, 3000), (1.5, 2.0, 0.5)) + + +@pytest.mark.parametrize("nan_in", [(), (4,)], ids=["finite", "NaN once raised"]) +def test_twelve_households_in_two_totals_publish_what_they_did_before(nan_in): + assert_publishes_as_before( + mediated_in_two_totals(nan_in), two_tax_totals(), (1,) * 12, (1,) * 12 + ) + + +@settings(max_examples=60, deadline=None) +@given( + shapes=st.lists(SHAPES, min_size=1, max_size=3), + reads=READS, + weights=st.lists(st.sampled_from([0.0, 0.5, 1.0, 2.0]), min_size=3, max_size=3), +) +@example(shapes=[MEDIATED_NAN], reads=MEDIATED_NAN_READS, weights=[1.0, 1.0, 1.0]) +def test_finite_baselines_publish_what_they_did_before(shapes, reads, weights): + """Differential against ``counted_once_base_reference`` for the guard + tests' random heads, which are finite at baseline and may turn infinite + or NaN once raised, on up to three households of any weights.""" + households = len(shapes) + declared = declared_reads(reads) + part = partition(HEADS_OF["gov_tax"], HEADS_OF["gov_spending"], reads=declared) + assert_publishes_as_before( + shapes_formulas(shapes), + part, + (1,) * households, + tuple(weights[:households]), + ) + + +@settings(max_examples=100, deadline=None) +@given( + values=st.lists( + st.one_of( + st.floats(allow_nan=False, allow_infinity=False), + st.sampled_from(NOT_FINITE_VALUES), + ), + min_size=1, + max_size=6, + ), + monthly=st.booleans(), +) +def test_the_check_sets_finite_values_bit_for_bit_and_others_at_zero(values, monthly): + values = np.array(values) + layout = [("2026-01", values), ("2026-02", values[::-1])] if monthly else values + settable = guard._settable(layout) + pairs = zip(layout, settable) if monthly else [((None, values), (None, settable))] + for (_, before), (_, after) in pairs: + finite = np.isfinite(before) + assert np.isfinite(after).all() + assert (after[~finite] == 0).all() + assert before[finite].tobytes() == after[finite].tobytes() + + +def test_a_finite_held_head_keeps_the_words_it_had(): + assert guard._unheld_words(["ben_c"], "tax_b") == reference._unheld_words( + ["ben_c"], "tax_b" + ) + assert guard._unheld_words(["ben_c", "tax_g"], "tax_b") == ( + reference._unheld_words(["ben_c", "tax_g"], "tax_b") + ) + assert guard._unheld_words([], "tax_b", at_baseline=["tax_n", "tax_m"]) == ( + "the check could not hold tax_n and tax_m, one or more of which is not " + "finite at baseline in some households and moves there with tax_b" + ) diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 08c0d1d83f..1905c1a23a 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -69,20 +69,20 @@ ``not_finite_at_baseline``, which ``measure`` records as a failure of a ``finite:`` check, and it fails no component for it. No rule reads a head, or a model total, in the households where it, or one of its heads, - is not finite at baseline. The check raises no unit of a component that - is not finite at baseline, and judges nothing in the households that - hold one. The engine refuses NaN as an input, so wherever the check sets - a variable, raising it or holding it, it sets each value that is not - finite at baseline to 0. That changes values only in the households that - hold one, which no rule judges for any reading those values reach: the - component's own (it judges nothing there), and the held measurements - (neither judges a household where a head it holds is not finite at - baseline). Where each formula reads only its own household's values, - every reading in every other household is the one it would be with each - such value left as it was. A problem in a household the held measurements - could not judge stays, and names the heads, ``heads_not_held_at_baseline`` - beside ``heads_not_held``. Every other head and component gets the whole - check. + is not finite at baseline. The engine refuses NaN as an input, so the + check never sets such a value. To raise a component that is not finite + at baseline, it sets each such unit to 0 and raises the others; the + households holding one are the only ones whose values change, and it + judges nothing there for that component. To hold a head that is not + finite at baseline, it does not set the head at all: it leaves it to its + formula. Wherever the head does not respond to the component, its + formula gives it back the value the measurement holds it at (its + baseline, NaN where it was NaN, or its value after the first point), so + it is held there exactly and every other head reads what it would read + with the head held. The check compares the head with that value, unit by + unit and NaN matching only NaN, and judges the household only where they + match. A problem in a household it could not judge stays, and names the + head in ``heads_not_held_at_baseline``, beside ``heads_not_held``. What it cannot see: a head that adds a component back while tapering it away pound for pound (``max(0, a - c) + c``) does not move at the margin, so it is @@ -443,8 +443,8 @@ def _listed(names) -> tuple: def _unheld_words(heads, component, name=str, at_baseline=()) -> str: """The check could not hold ``heads``, which are not finite there once - ``component`` rises, or ``at_baseline``, which are not finite there at - baseline.""" + ``component`` rises, or ``at_baseline``, which it left to their formulas + as they are not finite at baseline somewhere, and which moved there.""" groups = [] if heads: listed, which = _listed([name(v) for v in heads]) @@ -453,7 +453,10 @@ def _unheld_words(heads, component, name=str, at_baseline=()) -> str: ) if at_baseline: listed, which = _listed([name(v) for v in at_baseline]) - groups.append(f"{listed}, {which} not finite there at baseline") + groups.append( + f"{listed}, {which} not finite at baseline in some households and " + f"moves there with {name(component)}" + ) return "the check could not hold " + ", and ".join(groups) @@ -498,7 +501,8 @@ def _problem( a response that is not finite. ``unjudged`` marks the households of ``where`` that rest on a held measurement, which the check could not make there because it could not hold ``not_held`` (not finite once the - component rises) or ``at_baseline`` (not finite at baseline). The + component rises) or ``at_baseline`` (left to their formulas, as they are + not finite at baseline somewhere, and moved there). The direct-effect step reads ``where``, ``nan`` and ``unjudged``, and ``counted_once`` drops them before it reports the result. The problem carries ``heads_not_held_at_baseline`` only when ``at_baseline`` names a @@ -573,8 +577,9 @@ def judge_total( households where its change cannot be read (it, or the component, is not finite at baseline there), and ``model_unread`` the same for the model's total. No rule judges a head there, nor the model's total where it or - any head of the total cannot be read, and a head's response is its mean - over the households where it can be read. + any head of the total cannot be read. A head's response, and the model + total's coefficient, is its mean over the households where it can be + read, and is left out where no household with weight can be. - The component's own head must change by exactly ``expected`` in every household. @@ -607,17 +612,18 @@ def problem(head, kind, where, nan): return _problem(total, head, kind, component, where, weights, perturbed, nan) def mean_response(change, skip): - """The mean change per unit added, over households not in ``skip``.""" + """The mean change per unit added, over households not in ``skip``; + None when no household with weight is left to read.""" added = float(np.sum(weights * np.where(skip, 0.0, expected))) - return ( - float(np.sum(weights * np.where(skip, 0.0, change))) / added - if added - else 0.0 - ) + if not added: + return None + return float(np.sum(weights * np.where(skip, 0.0, change))) / added for variable, change in head_changes.items(): skip = unread.get(variable, nowhere) - responses[variable] = mean_response(change, skip) + response = mean_response(change, skip) + if response is not None: + responses[variable] = response slack = _slack(head_scales[variable]) if variable == own_variable: off = ((np.abs(change - expected) > slack) | ~np.isfinite(change)) & ~skip @@ -730,6 +736,21 @@ def _capture(sim, variables, year, layout) -> dict: return out +def _moved(sim, variable, year, layout) -> np.ndarray: + """Households holding a unit where ``variable`` now differs from its value + in ``layout`` (see ``_perturb``) in any period, NaN matching only NaN.""" + now, then = _capture(sim, [variable], year, layout)[variable], layout[variable] + if isinstance(now, list): + pairs = [(a, b) for (_, a), (_, b) in zip(now, then)] + else: + pairs = [(now, then)] + differs = np.logical_or.reduce( + [~((a == b) | (np.isnan(a) & np.isnan(b))) for a, b in pairs] + ) + entity = sim.tax_benefit_system.variables[variable].entity.key + return _household(sim, differs, entity) > 0 + + def breaks_on_slope(kind, where, expected, slack, slope): """Households of ``where`` whose head's slope, with the other heads held at their values after the first point, breaks ``kind``.""" @@ -761,30 +782,40 @@ def still_breaks(kind, where, expected, slack, first, second=None): def _held_breaks( - kind, where, expected, slack, first, second, slope_held, unjudged, neither=None + kind, + where, + expected, + slack, + first, + second, + slope_held, + unjudged, + unjudged_at_baseline=None, ): """Households of ``where`` where a held measurement that judged them still breaks ``kind`` (see ``still_breaks`` and ``breaks_on_slope``). ``slope_held`` is the slope with the other heads held at their values - after the first point; it judges no household of ``unjudged``. Neither - measurement judges a household of ``neither``, where a head it holds is - not finite at baseline; ``unjudged`` holds those households too. + after the first point; it judges no household of ``unjudged``. ``first`` + and ``second``, with the other heads held at their baseline values, + judge no household of ``unjudged_at_baseline``. """ - at_baseline = where if neither is None else where & ~neither + at_baseline = where + if unjudged_at_baseline is not None: + at_baseline = where & ~unjudged_at_baseline held_at_baseline = still_breaks(kind, at_baseline, expected, slack, first, second) judged = where & ~unjudged return held_at_baseline | breaks_on_slope(kind, judged, expected, slack, slope_held) -def _held_nan(kind, first, second, slope_held, unjudged, neither=None): +def _held_nan(kind, first, second, slope_held, unjudged, unjudged_at_baseline=None): """Households where a held measurement that judges ``kind`` (see ``_held_breaks``) saw a response that is not finite.""" nan = ~np.isfinite(first) if kind == "contains" and second is not None: nan = nan | ~np.isfinite(second) - if neither is not None: - nan = nan & ~neither + if unjudged_at_baseline is not None: + nan = nan & ~unjudged_at_baseline return nan | (~np.isfinite(slope_held) & ~unjudged) @@ -840,6 +871,7 @@ def counted_once( # The households where each head, and each model total, has no change to # read: it is not finite there at baseline. unread = {v: ~np.isfinite(heads0[v]) for v in all_heads} + free_heads = {v for v in all_heads if unread[v].any()} model_unread = {t: ~np.isfinite(model0[t]) for t in model_totals} # The same for each component the check raises, by the units it holds. blind_in = {} @@ -1012,13 +1044,12 @@ def counted_once( + [screen["head"] for screen in screens] ): held = [v for v in all_heads if v not in (variable, own, head)] - # The households where a head held here is not finite at - # baseline: neither held measurement can hold it at its - # baseline value there, so neither judges them. - at_baseline_in = {v: unread[v] for v in held if unread[v].any()} - neither = np.zeros(len(hw), dtype=bool) - for v in at_baseline_in: - neither = neither | at_baseline_in[v] + # The engine refuses NaN as an input, so the check sets no head + # that is not finite at baseline: it leaves each to its formula, + # and the measurement judges a household only where each came + # out at the value it holds the heads at (see ``_moved``). + fixed = [v for v in held if v not in free_heads] + free = [v for v in held if v in free_heads] _perturb( sim, variable, @@ -1026,10 +1057,11 @@ def counted_once( settable, added, monthly, - hold=held, + hold=fixed, touched=touched, ) first = _head_arrays(sim, year, [head])[head] - heads0[head] + moved_in = {v: _moved(sim, v, year, baseline) for v in free} second = None if any( p["head"] == head and p["kind"] == "contains" @@ -1042,11 +1074,13 @@ def counted_once( settable, 2 * added, monthly, - hold=held, + hold=fixed, touched=touched, ) second = _head_arrays(sim, year, [head])[head] - heads0[head] - for v in held: + for v in free: + moved_in[v] = moved_in[v] | _moved(sim, v, year, baseline) + for v in fixed: _restore(sim, v, snapshot) # Second test: hold the other heads at their values after the # first point, and move the component on to the second. A head @@ -1056,26 +1090,20 @@ def counted_once( # an infinite amount is no level to hold, so a unit where another # head is not finite after the first point keeps that head at its # baseline value, and this test judges no household the unit - # belongs to. A household where that head is not finite at - # baseline too is one neither test judges, and counts there. + # belongs to. _perturb(sim, variable, year, settable, added, monthly, touched=touched) at_first = _capture(sim, held, year, baseline) head_first = _head_arrays(sim, year, [head])[head] held_at, unheld_in = {}, {} - for v in held: + for v in fixed: units = _not_finite_units(at_first[v]) held_at[v] = at_first[v] if units.any(): - held_at[v] = _held_values(at_first[v], settable[v], units) + held_at[v] = _held_values(at_first[v], baseline[v], units) # Every household a unit belongs to, through the mapping # that sums each head to households. entity_key = definitions[v].entity.key - raised = _household(sim, units, entity_key) > 0 - if v in at_baseline_in: - raised = raised & ~at_baseline_in[v] - if not raised.any(): - continue - unheld_in[v] = raised + unheld_in[v] = _household(sim, units, entity_key) > 0 _perturb( sim, variable, @@ -1083,12 +1111,15 @@ def counted_once( settable, 2 * added, monthly, - hold=held, + hold=fixed, held_at=held_at, touched=touched, ) slope_held = _head_arrays(sim, year, [head])[head] - head_first - for v in held: + # A head left to its formula is held at its value after the + # first point only where it stays there. + moved_on = {v: _moved(sim, v, year, at_first) for v in free} + for v in fixed: _restore(sim, v, snapshot) not_held = list(unheld_in) unjudged = np.zeros(len(hw), dtype=bool) @@ -1099,23 +1130,28 @@ def counted_once( record["households_not_held"][head] = int( np.count_nonzero(unjudged & (hw > 0)) ) - # A problem that rests on a held head not finite at baseline - # names it; the component's result names the head once, in - # ``not_finite_at_baseline``. - unjudged = unjudged | neither + # The households each held measurement could not judge: the + # second where a head it holds is not finite after the first + # point or one left to its formula moved, the first (at the + # baseline) where one left to its formula moved. + unjudged_at_baseline = np.zeros(len(hw), dtype=bool) + free_moved = {} + for v in free: + unjudged_at_baseline = unjudged_at_baseline | moved_in[v] + unjudged = unjudged | moved_on[v] + if (moved_in[v] | moved_on[v]).any(): + free_moved[v] = moved_in[v] | moved_on[v] + either = unjudged | unjudged_at_baseline def unheld(where): """The heads the check could not hold in ``where`` once the component rises.""" return [v for v in not_held if bool((unheld_in[v] & where).any())] - def unheld_at_baseline(where): - """The heads the check could not hold in ``where`` at their - baseline values.""" + def unheld_free(where): + """The heads left to their formula that moved in ``where``.""" return [ - v - for v in at_baseline_in - if bool((at_baseline_in[v] & where).any()) + v for v in free_moved if bool((free_moved[v] & where).any()) ] slack = _slack(heads0[head]) @@ -1133,11 +1169,11 @@ def unheld_at_baseline(where): second, slope_held, unjudged, - neither, + unjudged_at_baseline, ) - # The second measurement clears no household it could not + # A held measurement clears no household it could not # judge: there the problem rests on that measurement. - rests = p["where"] & unjudged & ~breaks + rests = p["where"] & either & ~breaks direct = breaks | rests cleared = p["where"] & ~direct if cleared.any(): @@ -1185,11 +1221,11 @@ def unheld_at_baseline(where): second, slope_held, unjudged, - neither, + unjudged_at_baseline, ), unheld(rests), rests, - unheld_at_baseline(rests), + unheld_free(rests), ) ) # A linear response that was not one for one may hide a @@ -1206,13 +1242,18 @@ def unheld_at_baseline(where): second, slope_held, unjudged, - neither, + unjudged_at_baseline, ) - # Where the second measurement judged no household, the - # check could not confirm the reader there either way. - unconfirmed = screen["where"] & unjudged & ~confirmed + # Where a held measurement judged no household, the check + # could not confirm the reader there either way. + unconfirmed = screen["where"] & either & ~confirmed nan = _held_nan( - "contains", first, second, slope_held, unjudged, neither + "contains", + first, + second, + slope_held, + unjudged, + unjudged_at_baseline, ) if confirmed.any(): not_held_there, rests, at_baseline_there = (), None, () @@ -1261,7 +1302,7 @@ def unheld_at_baseline(where): nan, unheld(unconfirmed), unconfirmed, - unheld_at_baseline(unconfirmed), + unheld_free(unconfirmed), ) ) problems = kept @@ -1270,7 +1311,9 @@ def unheld_at_baseline(where): for p in problems ] record["mediated"] = mediated - record["heads_held_skipped"] = bool(record["heads_not_held"]) + record["heads_held_skipped"] = bool(record["heads_not_held"]) or any( + "heads_not_held_at_baseline" in p for p in problems + ) if variable in declared: double_count_results.append( _double_count_row( From 17c73816b1b41ccd1daa14c8a591d2f48e14ade3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:20:14 -0400 Subject: [PATCH 034/168] Set Income Tax reliefs and allowances against income in the way that gives the least tax ITA 2007 s. 25(2) deducts Step 2 reliefs and Step 3 allowances in the way that gives the greatest reduction in liability, and from 2027-28 s. 25(3A) deducts them from income other than property, savings and dividend income first. The model set them against non-savings, savings and dividend income in that fixed order. utils/income_tax_allocation.py finds the least-tax share per person by evaluating the Step 5 tax at every crossing of the lines where its slope changes. Fixes #2106. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2106.fixed.md | 1 + .../reliefs/other_income_first.yaml | 14 + .../least_tax_allocation.yaml | 203 ++++++++ .../received_allowances_dividend_income.yaml | 60 ++- .../received_allowances_earned_income.yaml | 4 +- .../received_allowances_savings_income.yaml | 37 +- ...de_loss_relief_against_general_income.yaml | 17 +- .../tests/policy/integration/income.yaml | 36 +- .../tests/test_income_tax_allocation.py | 322 +++++++++++++ .../utils/income_tax_allocation.py | 448 ++++++++++++++++++ policyengine_uk/utils/uc_income_tax.py | 9 +- .../received_allowances_dividend_income.py | 38 +- .../received_allowances_earned_income.py | 32 +- .../received_allowances_savings_income.py | 36 +- .../earned_taxable_income.py | 9 +- .../non_savings_income_after_allowances.py | 29 +- ...ss_relief_against_savings_and_dividends.py | 29 +- 17 files changed, 1182 insertions(+), 142 deletions(-) create mode 100644 changelog.d/2106.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml create mode 100644 policyengine_uk/tests/test_income_tax_allocation.py create mode 100644 policyengine_uk/utils/income_tax_allocation.py diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md new file mode 100644 index 0000000000..7329fd05e7 --- /dev/null +++ b/changelog.d/2106.fixed.md @@ -0,0 +1 @@ +Income Tax now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), instead of always in the order non-savings, savings, dividends. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml new file mode 100644 index 0000000000..aad1838fca --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml @@ -0,0 +1,14 @@ +description: Income Tax deducts reliefs and allowances from income other than property, savings and dividend income before it deducts them from property, savings or dividend income, and then in the way that gives the least tax. +values: + # Before 2027-28, s. 25(2) alone decides how they are deducted. + 0000-01-01: false + 2027-04-06: true +metadata: + unit: bool + period: year + label: Reliefs and allowances deducted from other income first + reference: + - title: Income Tax Act 2007 s. 25(2) and (3A) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/25 + - title: Finance Act 2026 s. 6(6) and (8) (s. 25(3A) has effect from 2027-28) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml new file mode 100644 index 0000000000..dff0428056 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -0,0 +1,203 @@ +# ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in +# the way which will result in the greatest reduction in the taxpayer's +# liability to income tax." From 2027-28, s. 25(3A) (Finance Act 2026 s. 6) +# deducts them from income other than property, savings and dividend income +# first. Savings sit above non-savings income and dividends above savings +# (s. 16), and from 2027-28 property income sits just below savings (s. 16A). +# 2026-27 and 2027-28: Personal Allowance £12,570, tapered away by £1 for each +# £2 of adjusted net income above £100,000 (s. 35); basic rate limit £37,700 +# and higher rate limit £125,140; starting rate limit for savings £5,000 +# (s. 12); personal savings allowance £1,000 for a basic rate taxpayer and £500 +# with higher rate income (s. 12B); dividend allowance £500 (s. 13A); dividend +# rates 10.75%, 35.75% and 39.35%. The model's 2027-28 property basic rate is +# 24% (Budget 2026, paragraph 6.246). + +- name: Case 1, dividends of £20,000 take the whole Personal Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + dividend_income: 20_000 + output: + received_allowances_dividend_income: 12_570 + # (20,000 - 12,570 - 500) x 10.75%. + income_tax: 744.975 + +- name: Case 2, savings in the starting rate band leave the Personal Allowance to dividends + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 20_000 + output: + # The £4,000 of savings is the lowest part of Step 3 income, within the + # £5,000 starting rate band, so it is taxed at 0% (s. 12). Set against + # it, the allowance would save nothing; set against dividends it saves + # 10.75%. So all £12,570 comes off dividends, and the tax is as in case 1: + # (20,000 - 12,570 - 500) x 10.75%. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 12_570 + savings_income_tax: 0 + income_tax: 744.975 + +- name: Case 3, a trading loss is set against dividends rather than savings at the starting rate + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # Adjusted net income of £203,000 removes the Personal Allowance. The + # £1,000 of relief (s. 64) comes off dividends: the savings stay at 0% + # in the starting rate band, and the dividends start £1,000 higher, at + # £4,000. Dividends of £199,000 run from £4,000 to £203,000: + # £500 nil rate, (37,700 - 4,500) x 10.75% = 3,569, + # (125,140 - 37,700) x 35.75% = 31,259.80 and + # (203,000 - 125,140) x 39.35% = 30,637.91, so 65,466.71. Set against + # savings, £1,000 more dividends would fall in the ordinary rate band: + # £107.50 more. + personal_allowance: 0 + trade_loss_relief_against_general_income: 1_000 + trade_loss_relief_against_savings_and_dividends: 1_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 1_000 + savings_income_tax: 0 + income_tax: 65_466.71 + +- name: Case 4, in 2027-28 a trading loss is set against property income rather than dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 20_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # No other income, so s. 25(3A) leaves the choice between property and + # dividends to s. 25(2). Property income sits below dividends (s. 16A). + # Against property, the £1,000 of relief saves 24% on property income in + # the basic rate band, and moves £1,000 of dividends down into the + # ordinary rate band at 10.75%: a net saving of £132.50 over setting it + # against dividends, which would leave Step 3 income and the dividends' + # top of £219,000 unchanged. Property: 19,000 x 24% = 4,560. Dividends of + # £200,000 from £19,000 to £219,000: £500 nil rate, + # (37,700 - 19,500) x 10.75% = 1,956.50, 87,440 x 35.75% = 31,259.80 and + # (219,000 - 125,140) x 39.35% = 36,933.91, so 70,150.21. + personal_allowance: 0 + trade_loss_relief_against_savings_and_dividends: 0 + received_allowances_dividend_income: 0 + property_income_tax: 4_560 + dividend_income_tax: 70_150.21 + income_tax: 74_710.21 + +- name: Case 5, a Scottish taxpayer's savings and dividends follow the UK rates + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 3_000 + savings_interest_income: 4_000 + dividend_income: 20_000 + region: SCOTLAND + output: + # Scottish rates apply only to non-savings income (s. 11A). The pay + # takes £3,000 of the allowance, saving the 19% starter rate. The + # savings stay whole at 0% in the starting rate band (s. 12) and the + # dividends take the other £9,570. Dividends of £10,430 from £4,000: + # (10,430 - 500) x 10.75% on the UK bands (s. 13(5)) = 1,067.475. + # Setting the allowance against savings before dividends would leave + # £14,430 of dividends: £430 more tax. + received_allowances_earned_income: 3_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 9_570 + earned_income_tax: 0 + savings_income_tax: 0 + income_tax: 1_067.475 + +- name: Case 6, a Scottish higher rate taxpayer keeps the allowance against pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + region: SCOTLAND + output: + # Pay of £37,430 after the allowance is taxed at the Scottish rates: + # 3,967 x 19% + 12,989 x 20% + 14,136 x 21% + 6,338 x 42% = 8,982.05. + # Savings run from £37,430 to £47,430. Step 3 income would be higher + # rate income outside Scotland, so the savings allowance is £500 + # (s. 12B(8)(b)(iv)), and the other £9,500 is above the UK basic rate + # limit, at 40% (s. 11D(6)): 3,800. Moving £270 of the allowance to + # savings, as in case 7, would tax £270 more pay at 42% to take £270 of + # savings out of the 40% band: £5.40 more. + received_allowances_earned_income: 12_570 + received_allowances_savings_income: 0 + earned_income_tax: 8_982.05 + savings_income_tax: 3_800 + income_tax: 12_782.05 + +- name: Case 7, savings across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + output: + # With all £12,570 against pay, pay of £37,430 is taxed at 20% and the + # £500 savings allowance covers savings from £37,430 to £37,930, all but + # £230 of it below the basic rate limit, where savings would be taxed + # at 20% anyway. Setting £270 against savings instead puts the pay at + # £37,700 and the savings allowance above the limit, where it saves 40%: + # pay 37,700 x 20% = 7,540; savings (47,430 - 38,200) x 40% = 3,692. + # The order pay, savings, dividends charges 7,486 + 3,800 = 11,286. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 270 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 8, dividends across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # As in case 7, the dividend allowance saves most above the basic rate + # limit. Pay 37,700 x 20% = 7,540; dividends of £19,730 from £37,700: + # £500 nil rate and (57,430 - 38,200) x 35.75% = 6,874.725. The order + # pay, savings, dividends charges 7,486 + 19,500 x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_dividend_income: 270 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 + +- name: Case 9, a person with only savings sets the whole allowance against them + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 20_000 + output: + # Step 3 income of £7,430: £5,000 at the starting rate, £1,000 within + # the savings allowance and (7,430 - 6,000) x 20% = 286. + received_allowances_savings_income: 12_570 + income_tax: 286 + +- name: Case 10, a person with only property income in 2027-28 sets the whole allowance against it + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 30_000 + output: + # (30,000 - 12,570) x 24%. + received_allowances_earned_income: 12_570 + property_income_tax: 4_183.2 + income_tax: 4_183.2 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml index bc8ca3381e..ef45d15f0a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml @@ -1,59 +1,81 @@ -- name: Dividend income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# dividend allowance £500 (s. 13A). + +- name: Dividend income less than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 8_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 8_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_dividend_income: 2_000 -- name: Dividend income equal to remaining allowance +- name: Dividend income equal to the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 570 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 570 taxable_dividend_income: 2_000 output: received_allowances_dividend_income: 2_000 -- name: Dividend income greater than remaining allowance +- name: Dividend income greater than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: 1_000 output: + # The pay takes £11,000. Of the £430 of income left, savings would be + # in the starting rate band and dividends within the dividend + # allowance, both taxed at 0%, so the allowances keep the order + # savings then dividends: £1,000 and £570. received_allowances_dividend_income: 570 -- name: No remaining allowance +- name: Allowances go to dividends beyond the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 - received_allowances_savings_income: 2_000 + taxable_employment_income: 10_570 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 + # After the pay, £1,000 of income is left. Left as £1,000 of dividends, + # £500 would be taxed at 8.75% (s. 13A nil rate on the first £500). Left + # as £500 of savings, in the starting rate band, and £500 of dividends, + # within the nil rate, it is all taxed at 0%. + received_allowances_savings_income: 1_500 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 - name: Negative taxable dividend income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: -500 output: received_allowances_dividend_income: 0 -- name: Negative remaining allowance +- name: Allowances keep the dividends left within the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 \ No newline at end of file + # £1,430 is left after the pay. Up to £500 of dividends are taxed at 0% + # (s. 13A) and savings in the starting rate band at 0% (s. 12), so £930 + # of savings and £500 of dividends are left: £1,070 of the allowances + # go to savings and £500 to dividends. + received_allowances_savings_income: 1_070 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 6acebb4035..5abae01d67 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -32,10 +32,12 @@ period: 2024 input: allowances: 12_570 - net_income: 20_000 + taxable_employment_income: 5_000 taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: + # The pay would be taxed at 20%, so it takes £5,000 of the allowances + # (ITA 2007 s. 25(2)). received_allowances_earned_income: 5_000 - name: Earned income only, no savings or dividends diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml index ff181a5172..708a4858f5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml @@ -1,53 +1,66 @@ -- name: Savings income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# personal savings allowance £1,000 for a basic rate taxpayer (s. 12B). + +- name: Savings income less than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_savings_income: 2_000 -- name: Savings income equal to remaining allowance +- name: Savings income equal to the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 + taxable_employment_income: 10_570 taxable_savings_interest_income: 2_000 output: received_allowances_savings_income: 2_000 -- name: Savings income greater than remaining allowance +- name: Savings income greater than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_570 + taxable_employment_income: 11_570 taxable_savings_interest_income: 3_000 output: + # The pay takes £11,570, which would otherwise be taxed at 20%. The + # £2,000 of savings left is within the £5,000 starting rate band, so + # it is taxed at 0% whichever £1,000 of the allowances it takes. received_allowances_savings_income: 1_000 -- name: No remaining allowance +- name: No allowances go to savings that the starting rate leaves untaxed period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 12_570 + taxable_employment_income: 12_570 taxable_savings_interest_income: 1_000 output: + # Against the savings the allowances would save nothing: the £1,000 is + # in the starting rate band. Against the pay they save 20%. received_allowances_savings_income: 0 - name: Negative taxable savings interest income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: -500 output: received_allowances_savings_income: 0 -- name: Negative remaining allowance +- name: Earnings above the allowances leave none for savings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 13_000 + taxable_employment_income: 13_000 taxable_savings_interest_income: 1_000 output: - received_allowances_savings_income: 0 \ No newline at end of file + # £430 of pay is taxed at 20% and the savings above it at the starting + # rate of 0%, so the allowances are worth most against the pay. + received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index a7365d8d5e..da87919870 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -110,7 +110,7 @@ # (40,000 - 12,570 - 500) x 10.75%: the tax on £40,000 of dividends. income_tax: 2_894.98 -- name: Relief takes non-savings income first, then savings, then dividends +- name: Relief and allowances leave savings that the starting rate and savings allowance cover period: 2026 absolute_error_margin: 0.01 input: @@ -121,13 +121,16 @@ output: trade_loss_relief_against_general_income: 20_000 trade_loss_relief_against_savings_and_dividends: 15_000 - # The £15,000 of relief left after the pay, with the Personal - # Allowance, takes all £10,000 of savings and £17,570 of dividends. + # ITA 2007 s. 25(2) sets the £32,570 of relief and Personal Allowance + # against income in the way that gives the least tax. The pay takes + # £5,000. Of Step 3 income of £12,430, savings up to £6,000 are taxed at + # 0%: £5,000 at the starting rate (s. 12) and £1,000 within the savings + # allowance (s. 12A). So savings take £4,000 and dividends £23,570. received_allowances_earned_income: 0 - received_allowances_savings_income: 10_000 - received_allowances_dividend_income: 17_570 - # £25,000 of dividends is left: (25,000 - 12,570 - 500) x 10.75%. - income_tax: 1_282.48 + received_allowances_savings_income: 4_000 + received_allowances_dividend_income: 23_570 + # £6,430 of dividends is left: (6,430 - 500) x 10.75%. + income_tax: 637.475 - name: Adjusted total income is total income after employment expenses period: 2026 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 391dcf83fb..e86069230b 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -20,12 +20,20 @@ employment_income: 40_000 savings_interest_income: 15_000 output: - basic_rate_savings_income_pre_starter: 10_000 + # ITA 2007 s. 25(2): of the £12,500 Personal Allowance, £2,500 comes off + # the pay and £10,000 off the savings. The pay left is £37,500, the basic + # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings + # that would otherwise be taxed at 40% rather than 20%: £100 less tax + # than the allowance all against the pay. Savings of £5,000 run from + # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting + # more against the pay or the savings would not change the tax. + received_allowances_savings_income: 10_000 + basic_rate_savings_income_pre_starter: 0 savings_starter_rate_income: 0 - basic_rate_savings_income: 9_500 + basic_rate_savings_income: 0 savings_allowance: 500 - higher_rate_savings_income: 5_000 - taxed_savings_income: 14_500 + higher_rate_savings_income: 4_500 + taxed_savings_income: 4_500 - name: Dividends taxed at the marginal rates period: 2020 @@ -55,7 +63,7 @@ dividend_income_tax: 178.75 income_tax: 7_884.75 -- name: Personal allowance caps savings starter rate before dividends. +- name: Personal allowance goes to dividends rather than savings at the starting rate. period: 2027 absolute_error_margin: 0.01 input: @@ -63,14 +71,20 @@ savings_interest_income: 5_000 dividend_income: 5_000 output: - received_allowances_savings_income: 2_570 - basic_rate_savings_income_pre_starter: 2_430 - savings_starter_rate_income: 2_430 + # The pay takes £10,000 of the allowance first (ITA 2007 s. 25(3A)). The + # £5,000 of savings is all in the starting rate band (s. 12), so the + # other £2,570 saves tax only against dividends (s. 25(2)). Dividends of + # £2,430: (2,430 - 500) x 10.75% = 207.475. Setting the £2,570 against + # savings first would leave £5,000 of dividends and charge 483.75. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 2_570 + basic_rate_savings_income_pre_starter: 5_000 + savings_starter_rate_income: 5_000 savings_allowance: 1_000 savings_income_tax: 0 - taxed_dividend_income: 4_500 - dividend_income_tax: 483.75 - income_tax: 483.75 + taxed_dividend_income: 1_930 + dividend_income_tax: 207.475 + income_tax: 207.475 - name: Starter-rate savings applies before personal savings allowance. period: 2027 diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py new file mode 100644 index 0000000000..2a462513e0 --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -0,0 +1,322 @@ +"""Property tests for setting Income Tax reliefs and allowances against income +in the way that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, +s. 25(3A); #2106). + +The invariants, for every person without Marriage Allowance, on the Step 5 +tax (income_tax_pre_charges), which is all the share changes: + +1. Least tax: no share of the deductions between the components s. 25 lets + them come off gives less Step 5 tax than the model's. A brute-force oracle + forces each share on a grid through the model's own variables. +2. The tax never exceeds what the order non-savings, savings, dividends + charges, which is the order the model used before #2106. +3. The deductions set against the components add up to the deductions used, + min(relief + allowances, income), and none exceeds its component. +4. With one kind of income there is nothing to share, so the tax and the + deductions are the old order's. +5. The search's own tax function agrees with income_tax_pre_charges on every + share (a differential test of two implementations of Step 5). +6. The vertex search finds the least value of that function, checked against + a dense grid. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.income_tax_allocation import ( + _context, + _least_tax_share, + _shares, + default_order_deductions, + step_5_tax, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = (2026, 2027) +PEOPLE = 12 +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", + "trading_loss", + "gift_aid", +) +ALLOCATION = ( + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "trade_loss_relief_against_savings_and_dividends", +) +VARIABLES = ALLOCATION + ( + "net_income_before_trade_loss_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "trade_loss_relief_against_general_income", + "allowances", + "income_tax_pre_charges", +) +# Hand-built people for the brute-force oracle: (year, region, incomes). +HAND_BUILT = [ + (2026, "LONDON", dict(savings_interest_income=4_000, dividend_income=20_000)), + (2026, "LONDON", dict(employment_income=50_000, savings_interest_income=10_000)), + (2026, "LONDON", dict(employment_income=50_000, dividend_income=20_000)), + ( + 2026, + "LONDON", + dict( + savings_interest_income=4_000, dividend_income=200_000, trading_loss=1_000 + ), + ), + ( + 2027, + "LONDON", + dict(property_income=20_000, dividend_income=200_000, trading_loss=1_000), + ), + ( + 2026, + "SCOTLAND", + dict( + employment_income=3_000, + savings_interest_income=4_000, + dividend_income=20_000, + ), + ), + ( + 2027, + "LONDON", + dict( + employment_income=9_000, + property_income=30_000, + savings_interest_income=6_000, + dividend_income=15_000, + ), + ), + ( + 2026, + "WALES", + dict( + employment_income=40_000, + savings_interest_income=3_000, + dividend_income=8_000, + gift_aid=2_000, + ), + ), +] +GRID = 21 + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=150_000), + st.sampled_from([4_000.0, 6_000.0, 12_570.0, 37_700.0, 50_270.0, 125_140.0]), +) +person = st.tuples(*([amount] * 5), st.floats(min_value=0, max_value=5_000)) +people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) +year = st.sampled_from(YEARS) +SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def situation(count, year, regions): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(count)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(count)}, + "households": { + f"h{i}": {"members": [f"p{i}"], "region": {year: regions[i]}} + for i in range(count) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + """Simulations built once and reused: one for the model's own share and + one with the share forced, for each year and set of regions.""" + cache = {} + + def get(year, regions, forced=False): + key = (year, tuple(regions), forced) + if key not in cache: + cache[key] = Simulation(situation=situation(len(regions), year, regions)) + return cache[key] + + return get + + +def run(sim, year, incomes, forced=None): + count = sim.populations["person"].count + for name in INCOMES: + values = np.asarray(incomes.get(name, 0.0), dtype=float) + sim.set_input(name, year, np.broadcast_to(values, (count,)).copy()) + for name, values in (forced or {}).items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return sim + + +def results(sim, year): + return {name: sim.calculate(name, year).astype(float) for name in VARIABLES} + + +def parameters_of(sim): + return lambda period: sim.tax_benefit_system.parameters(str(period)) + + +def draw(drawn): + return {name: np.array(values) for name, values in zip(INCOMES, zip(*drawn))} + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 2e-7 * sum(np.abs(v) for v in values) + + +def default_order(sim, year): + default = default_order_deductions(sim.populations["person"], year) + return { + "received_allowances_savings_income": default.savings, + "received_allowances_dividend_income": default.dividends, + } + + +@SETTINGS +@given(year, people) +@example( + 2026, + [(0.0, 4_000.0, 20_000.0, 0.0, 0.0, 0.0), (50_000.0, 10_000.0, 0.0, 0.0, 0.0, 0.0)] + + [(0.0,) * 6] * (PEOPLE - 2), +) +def test_least_tax_never_exceeds_default_order_and_adds_up(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + law_sim = run(sims(year, regions), year, incomes) + law = results(law_sim, year) + forced = run(sims(year, regions, True), year, incomes, default_order(law_sim, year)) + default = results(forced, year) + + # Invariant 2: never more tax than the order non-savings, savings, dividends. + # The share changes only the Step 5 tax: the Step 6 reductions and Step 7 + # charges do not depend on it. + tax, old_tax = law["income_tax_pre_charges"], default["income_tax_pre_charges"] + assert np.all(tax <= old_tax + tolerance(old_tax)), (tax, old_tax) + + # Invariant 3: the deductions add up and none exceeds its component. + savings = np.maximum(0, law["taxable_savings_interest_income"]) + dividends = np.maximum(0, law["taxable_dividend_income"]) + non_savings = np.maximum( + 0, law["net_income_before_trade_loss_relief"] - savings - dividends + ) + relief = law["trade_loss_relief_against_general_income"] + used = np.minimum(relief + law["allowances"], non_savings + savings + dividends) + to_savings = law["received_allowances_savings_income"] + to_dividends = law["received_allowances_dividend_income"] + relief_left = law["trade_loss_relief_against_savings_and_dividends"] + to_non_savings = law["received_allowances_earned_income"] + relief - relief_left + margin = tolerance(used) + assert np.all(np.abs(to_non_savings + to_savings + to_dividends - used) <= margin) + for share, component in [ + (to_non_savings, non_savings), + (to_savings, savings), + (to_dividends, dividends), + ]: + assert np.all(share >= -margin) and np.all(share <= component + margin) + assert np.all(relief_left <= relief + margin) + assert np.all(relief_left <= to_savings + to_dividends + margin) + + # Invariant 4: one kind of income, nothing to share. + kinds = (non_savings > 0).astype(int) + (savings > 0) + (dividends > 0) + single = kinds <= 1 + np.testing.assert_allclose(tax[single], old_tax[single], atol=0.01) + for name in ALLOCATION: + np.testing.assert_allclose(law[name][single], default[name][single], atol=0.01) + + +@pytest.mark.parametrize( + "year, region, incomes", + HAND_BUILT, + ids=[ + f"{year}-{region}-" + "-".join(incomes) for year, region, incomes in HAND_BUILT + ], +) +def test_tax_equals_brute_force_grid_minimum(sims, year, region, incomes): + law_sim = run(sims(year, [region]), year, incomes) + law = results(law_sim, year) + c = _context(law_sim.populations["person"], year, parameters_of(law_sim)) + savings, dividends = c.savings[0, 0], c.dividends[0, 0] + used, non_savings = c.used[0, 0], c.non_savings[0, 0] + # From 2027-28 s. 25(3A) sets the deductions against other income first. + at_least_non_savings = used - c.shared[0, 0] if c.other_first else 0 + to_savings, to_dividends = ( + grid.ravel() + for grid in np.meshgrid( + np.linspace(0, min(savings, used), GRID), + np.linspace(0, min(dividends, used), GRID), + ) + ) + to_non_savings = used - to_savings - to_dividends + feasible = (to_non_savings >= at_least_non_savings - 1e-6) & ( + to_non_savings <= non_savings + 1e-6 + ) + forced = run( + sims(year, [region] * GRID**2, True), + year, + incomes, + { + "received_allowances_savings_income": to_savings, + "received_allowances_dividend_income": to_dividends, + }, + ) + grid_tax = forced.calculate("income_tax_pre_charges", year).astype(float) + model_tax = law["income_tax_pre_charges"][0] + least = grid_tax[feasible].min() + step = min(savings, used) / (GRID - 1) + min(dividends, used) / (GRID - 1) + # Invariant 1: the model's share is the least; the grid comes within its + # step of it, since a pound moved between components changes the tax by + # less than a pound. + assert model_tax <= least + tolerance(model_tax) + assert model_tax >= least - step - tolerance(model_tax) + # Invariant 5: the search's tax function is the model's Step 5 tax. + forced_c = _context(forced.populations["person"], year, parameters_of(forced)) + x = (forced_c.step_3_income - forced_c.savings - forced_c.dividends)[:, 0] + x = x + to_savings + to_dividends + s = savings - to_savings + v = dividends - to_dividends + own = step_5_tax(forced_c, x[:, None], s[:, None], v[:, None])[:, 0] + np.testing.assert_allclose( + own[feasible], grid_tax[feasible], atol=tolerance(grid_tax).max() + ) + + +@SETTINGS +@given(year, people) +def test_vertex_search_equals_dense_grid(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + sim = run(sims(year, regions), year, incomes) + c = _context(sim.populations["person"], year, parameters_of(sim)) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + # Invariant 6: a 101 x 101 grid over the first component and savings + # left, projected onto the feasible shares. + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, 101) + grid_x = (low + (high - low) * fractions[None, :]).repeat(101, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], 101) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / 100 + c.savings / 100)[:, 0] + assert np.all(found <= grid + 1e-6) + assert np.all(found >= grid - 2 * step - 1e-6) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py new file mode 100644 index 0000000000..055edd076f --- /dev/null +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -0,0 +1,448 @@ +"""Set Income Tax reliefs and allowances against the components of income. + +At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), s. 25(2) +deducts reliefs and allowances "in the way which will result in the greatest +reduction in the taxpayer's liability to income tax". From 2027-28, s. 25(3A) +(Finance Act 2026 s. 6(6) and (8)) deducts them from income other than +property, savings and dividend income first, and s. 25(2) then chooses between +property, savings and dividend income. Before 2027-28, s. 25(2) chooses between +non-savings, savings and dividend income. Property income is non-savings income +taxed at the same rates in those years, so the deductions come off the rest of +non-savings income first, as they always have in the model. + +The model deducts trade loss relief against general income at Step 2 and the +amounts in ``allowances`` (the personal and blind person's allowances, gifts of +investments to charity, covenanted payments, other deductions and pension +relief). Together they are one sum to share between the components. Each part +of it can come off any component, and the tax depends only on how much comes +off each, so the search shares out the sum. + +How the search finds the least tax +---------------------------------- +Three components share what s. 25(3A) leaves: the first (non-savings income +before 2027-28, property income after), savings and dividends. Write ``x`` for +the first component left after deductions, ``w`` for ``x`` plus the savings +left, and ``T`` for the three together after deductions, which the sharing +does not change. The dividends left are ``T - w``. Savings sit above +non-savings income and dividends above savings (ss. 16 and 16A), so with ``q`` +the non-savings income below the savings (``x``, or property plus the other +non-savings income left before it from 2027-28): + +- tax on the first component changes slope where ``x`` crosses a rate limit; +- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) cover + the savings from ``q`` up to ``max(q, L) + A``, where ``L`` is the starting + rate limit and ``A`` the personal savings allowance. That changes slope where + ``q`` reaches ``L``, where ``max(q, L) + A`` crosses a rate limit, and where + ``w`` reaches the top of that band: ``w - x = A`` above ``L``, ``w`` constant + below it; +- the rest of the savings change slope where their top, ``w``, crosses a rate + limit; +- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` of + the dividends, so their tax changes slope where ``w + DA`` crosses a dividend + rate limit or reaches ``T``. + +The limits on the components add ``x = 0`` and ``x`` equal to the whole first +component, savings left from nil to all (``w - x = 0`` and ``w - x`` equal to +the savings), and dividends left from nil to all (``w = T`` and ``w = T`` less +the dividends). So the Step 5 tax is continuous and linear on each piece of the +feasible polygon cut out by vertical lines (``x`` constant), horizontal lines +(``w`` constant) and diagonal lines (``w - x`` constant). A linear function +takes its least value on a polygon at a corner, so the least tax is at a point +where two of these lines cross. ``_candidates`` lists every such crossing, and +the search evaluates the tax at all of them for all people at once. + +That argument needs the starting rate limit at or below the basic rate limit +and a phase-out of one pound of starting rate band for each pound of +non-savings income (s. 12), as in every year modelled. Before 2027-28 it also +needs no Marriage Allowance: the model subtracts that allowance from earned +income (#1947) rather than giving the s. 55B tax reduction, which moves the +savings and dividends down by up to that amount. The search still evaluates +every candidate with the full tax, so with Marriage Allowance it takes the best +of them. In every case it keeps the order non-savings, savings, dividends +unless another share gives less tax, so it never charges more than that order. +""" + +from types import SimpleNamespace + +import numpy as np + +from policyengine_uk.utils.income_tax import first_extended_scottish_threshold + +# A share replaces the order non-savings, savings, dividends only where it +# saves more than this (in pounds), so rounding never moves a deduction. +TOLERANCE = 1e-3 +# Shares whose tax differs by less than this (in pounds) are equally good. +TIE = 1e-6 +# Bound on people times candidates evaluated at once, to bound memory. +CHUNK = 2_000_000 +# The variables on earned_taxable_income_exclusions that the allocation sets +# rather than reads. +ALLOCATED_EXCLUSIONS = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "received_allowances_earned_income", +) + + +def _schedule(scale, gift, first=1, last=3): + """Thresholds and rate steps of a marginal-rate scale, with the limits from + index ``first`` up to ``last`` raised by the grossed-up Gift Aid gift (ITA + 2007 s. 414(2)(b)), as calc_income_tax_with_gift_aid applies it.""" + thresholds, steps, previous = [], [], 0 + stop = min(last, len(scale.thresholds)) + for index, (threshold, rate) in enumerate(zip(scale.thresholds, scale.rates)): + thresholds.append(threshold + (gift if first <= index < stop else 0)) + steps.append(rate - previous) + previous = rate + return thresholds, steps + + +def _tax(income, schedule): + thresholds, steps = schedule + return sum(step * np.maximum(income - t, 0) for t, step in zip(thresholds, steps)) + + +def _context(person, period, parameters): + """Everything the tax on a share depends on, as float64 columns.""" + income_tax = parameters(period).gov.hmrc.income_tax + rates = income_tax.rates + + def column(values): + return np.asarray(values, dtype=np.float64)[:, None] + + savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) + dividends = np.maximum(0, column(person("taxable_dividend_income", period))) + non_savings = np.maximum( + 0, + column(person("net_income_before_trade_loss_relief", period)) + - savings + - dividends, + ) + property_income = np.minimum( + np.maximum(0, column(person("taxable_property_income", period))), non_savings + ) + deductions = column(person("trade_loss_relief_against_general_income", period)) + deductions = deductions + column(person("allowances", period)) + used = np.minimum(deductions, non_savings + savings + dividends) + exclusions = [ + variable + for variable in income_tax.earned_taxable_income_exclusions + if variable not in ALLOCATED_EXCLUSIONS + ] + earned_exclusions = sum( + (column(person(variable, period)) for variable in exclusions), + np.zeros_like(savings), + ) + gift = column(person("gift_aid_grossed_up", period)) + scottish = np.asarray(person("pays_scottish_income_tax", period))[:, None] + uk_thresholds = rates.uk.thresholds + c = SimpleNamespace( + other_first=bool(income_tax.reliefs.other_income_first), + savings=savings, + dividends=dividends, + non_savings=non_savings, + property=property_income, + used=used, + earned_exclusions=earned_exclusions, + gift=gift, + scottish=scottish, + uk=_schedule(rates.uk, gift), + scotland=_schedule( + rates.scotland.rates, + gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + dividend_scale=_schedule(rates.dividends, gift), + bands=( + uk_thresholds[0], + uk_thresholds[1] + gift, + uk_thresholds[2] + gift, + ), + savings_rates=rates.savings, + property_rates=rates.property, + starting_limit=rates.savings_starter_rate.allowance, + phase_out=rates.savings_starter_rate.phase_out, + savings_allowance=column(person("savings_allowance", period)), + dividend_allowance=income_tax.allowances.dividend_allowance, + ) + if c.other_first: + # s. 25(3A): other non-savings income takes the deductions first. + other = non_savings - property_income + to_other = np.minimum(other, used) + c.other_left = other - to_other + c.first = property_income + c.shared = used - to_other + # The savings stack on the other non-savings income left, as earned + # taxable income has it, plus the property income left. + c.offset = np.maximum(0, c.other_left - earned_exclusions) + else: + c.first = non_savings + c.shared = used + c.offset = np.zeros_like(savings) + c.step_3_income = c.first + savings + dividends - c.shared + return c + + +def _non_savings_tax(c, income): + return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) + + +def step_5_tax(c, x, s, v): + """Step 5 Income Tax on the share leaving ``x`` of the first component, + ``s`` of savings and ``v`` of dividends, as the model's + income_tax_pre_charges computes it from the variables the share sets.""" + if c.other_first: + other, property_left = c.other_left, x + else: + property_left = np.minimum(x, c.property) + other = x - property_left + earned = np.maximum(0, other - c.earned_exclusions) + earned_tax = _non_savings_tax(c, earned) + if c.other_first: + # property_income_tax.formula_2027: Scottish rates in Scotland, + # property rates on the UK bands elsewhere. + rates = c.property_rates + _, basic_upper, higher_upper = c.bands + in_basic = np.minimum(property_left, np.maximum(0, basic_upper - other)) + higher_space = np.maximum(0, higher_upper - np.maximum(other, basic_upper)) + in_higher = np.minimum(np.maximum(0, property_left - in_basic), higher_space) + in_additional = np.maximum(0, property_left - in_basic - in_higher) + ruk = ( + rates.basic * in_basic + + rates.higher * in_higher + + rates.additional * in_additional + ) + scottish = _tax(other + property_left, c.scotland) - _tax(other, c.scotland) + property_tax = np.where(c.scottish, scottish, ruk) + else: + property_tax = _non_savings_tax(c, other + property_left) - ( + _non_savings_tax(c, other) + ) + # Savings and dividends sit on non_savings_taxable_income. + below = earned + property_left + low, basic_limit, higher_limit = c.bands + top = below + s + in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) + starting = np.clip( + np.minimum(in_basic_band, s), + 0, + np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), + ) + start = below + np.minimum(s, starting + c.savings_allowance) + rates = c.savings_rates + savings_tax = ( + rates.basic + * np.maximum( + 0, + np.clip(top, low, basic_limit) - np.clip(start, low, basic_limit), + ) + + rates.higher + * np.maximum( + 0, + np.clip(top, basic_limit, higher_limit) + - np.clip(start, basic_limit, higher_limit), + ) + + rates.additional + * np.maximum(0, np.maximum(top, higher_limit) - np.maximum(start, higher_limit)) + ) + nil_rate = np.minimum(c.dividend_allowance, v) + dividend_tax = np.maximum( + 0, + _tax(top + v, c.dividend_scale) - _tax(top + nil_rate, c.dividend_scale), + ) + return earned_tax + property_tax + savings_tax + dividend_tax + + +def _candidates(c): + """The points where two of the lines the module docstring lists cross.""" + zero = np.zeros_like(c.savings) + offset = c.offset + low, basic_limit, higher_limit = c.bands + allowance = c.savings_allowance + if c.other_first: + first_limits = [ + np.where(c.scottish, scottish, ruk) - c.other_left + for scottish, ruk in _paired_limits( + c.scotland[0], [low, basic_limit, higher_limit], zero + ) + ] + else: + first_limits = [ + np.where(c.scottish, scottish, ruk) + for scottish, ruk in _paired_limits(c.scotland[0], c.uk[0], zero) + ] + vertical = [zero, c.first, c.starting_limit - offset + zero] + vertical += [limit - allowance - offset for limit in (basic_limit, higher_limit)] + vertical += first_limits + total = c.step_3_income + horizontal = [ + total - c.dividends, + total, + total - c.dividend_allowance, + c.starting_limit + allowance - offset, + basic_limit - offset, + higher_limit - offset, + ] + horizontal += [ + limit - c.dividend_allowance - offset + zero for limit in c.dividend_scale[0] + ] + diagonal = [zero, c.savings, allowance] + xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) + ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) + ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) + n = len(zero) + x = np.concatenate( + [ + np.repeat(xs, ws.shape[1], axis=1), + np.repeat(xs, ds.shape[1], axis=1), + (ws[:, :, None] - ds[:, None, :]).reshape(n, -1), + ], + axis=1, + ) + w = np.concatenate( + [ + np.tile(ws, xs.shape[1]), + (xs[:, :, None] + ds[:, None, :]).reshape(n, -1), + np.repeat(ws, ds.shape[1], axis=1), + ], + axis=1, + ) + return x, w + + +def _paired_limits(scottish, other, zero): + """Pair two lists of limits by position, padding the shorter with +inf.""" + length = max(len(scottish), len(other)) + pad = zero + np.inf + return [ + ( + scottish[i] + zero if i < len(scottish) else pad, + other[i] + zero if i < len(other) else pad, + ) + for i in range(length) + ] + + +def _shares(c, x, w): + """Project candidate points onto the feasible polygon and split them into + the first component, savings and dividends left.""" + total = c.step_3_income + x = np.clip( + x, np.maximum(0, total - c.savings - c.dividends), np.minimum(c.first, total) + ) + w = np.clip(w, np.maximum(x, total - c.dividends), np.minimum(x + c.savings, total)) + return x, w - x, total - w + + +def _least_tax_share(c): + """The candidate share with the least Step 5 tax, per person. Of shares + within TIE of it, the one setting most against the first component, then + most against savings: the nearest the least tax allows to the order + non-savings, savings, dividends.""" + x, w = _candidates(c) + n, count = x.shape + tax = np.empty((n, count)) + width = max(1, CHUNK // max(n, 1)) + for start in range(0, count, width): + part = slice(start, start + width) + x[:, part], w[:, part], _ = _shares(c, x[:, part], w[:, part]) + tax[:, part] = step_5_tax( + c, x[:, part], w[:, part], c.step_3_income - x[:, part] - w[:, part] + ) + near = tax <= tax.min(axis=1, keepdims=True) + TIE + x_near = np.where(near, x, np.inf) + near &= x_near <= x_near.min(axis=1, keepdims=True) + TIE + index = np.argmin(np.where(near, w, np.inf), axis=1)[:, None] + x, s = (np.take_along_axis(part, index, axis=1) for part in (x, w)) + return x, s, c.step_3_income - x - s + + +def default_order_deductions(person, period): + """The deductions from non-savings, savings and dividend income in the + order non-savings, savings, dividends, computed as the model always has.""" + allowances = person("allowances", period) + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + non_savings = person("net_income", period) - savings - dividends + relief = person("trade_loss_relief_against_general_income", period) + relief_left = np.maximum( + 0, + relief + - np.maximum( + 0, + person("net_income_before_trade_loss_relief", period) - savings - dividends, + ), + ) + to_non_savings = np.where( + non_savings < allowances, np.maximum(0, non_savings), allowances + ) + left = allowances + relief_left - to_non_savings + to_savings = np.where(savings < left, np.maximum(0, savings), np.maximum(0, left)) + left = left - to_savings + to_dividends = np.where( + dividends < left, np.maximum(0, dividends), np.maximum(0, left) + ) + return SimpleNamespace( + earned=to_non_savings, + savings=to_savings, + dividends=to_dividends, + relief_against_savings_and_dividends=relief_left, + ) + + +def allocate_deductions(person, period, parameters): + """Deductions against savings and dividend income in the share that gives + the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less + tax than the order non-savings, savings, dividends, these are that order's + deductions exactly.""" + default = default_order_deductions(person, period) + c = _context(person, period, parameters) + x, s, v = _least_tax_share(c) + default_s = c.savings - np.asarray(default.savings, dtype=np.float64)[:, None] + default_v = c.dividends - np.asarray(default.dividends, dtype=np.float64)[:, None] + default_x = c.step_3_income - default_s - default_v + lower = ( + step_5_tax(c, x, s, v) + < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE + )[:, 0] + return SimpleNamespace( + savings=np.where(lower, (c.savings - s)[:, 0], default.savings), + dividends=np.where(lower, (c.dividends - v)[:, 0], default.dividends), + ) + + +def non_savings_deductions(person, period): + """Allowances against non-savings income beyond trade loss relief, and the + relief set against savings and dividends, once received_allowances_savings_income + and received_allowances_dividend_income have taken their share. + + The relief comes off non-savings income first, as far as the share sets + anything against it, then the allowances. Where the share is the order + non-savings, savings, dividends, these are that order's amounts exactly. + """ + default = default_order_deductions(person, period) + to_savings = person("received_allowances_savings_income", period) + to_dividends = person("received_allowances_dividend_income", period) + moved = (to_savings != default.savings) | (to_dividends != default.dividends) + savings = np.maximum(0, person("taxable_savings_interest_income", period)) + dividends = np.maximum(0, person("taxable_dividend_income", period)) + non_savings = np.maximum( + 0, + person("net_income_before_trade_loss_relief", period) - savings - dividends, + ) + relief = person("trade_loss_relief_against_general_income", period) + used = np.minimum( + relief.astype(np.float64) + person("allowances", period), + non_savings.astype(np.float64) + savings + dividends, + ) + to_non_savings = used - to_savings - to_dividends + return SimpleNamespace( + earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), + relief_against_savings_and_dividends=np.where( + moved, + np.maximum(0, relief - to_non_savings), + default.relief_against_savings_and_dividends, + ), + ) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index d8cab4feb3..99aaf14990 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -58,8 +58,13 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr ) # The income left in earned_taxable_income before allowances. The # part of it above earnings is the person's other non-savings income. - income_in_base = person("net_income", period) - add( - person, period, excluded_incomes + # Trade loss relief comes off that other income first; net_income + # deducts all of it, so add back the part set against savings and + # dividends, as earned_taxable_income does. + income_in_base = ( + person("net_income", period) + + person("trade_loss_relief_against_savings_and_dividends", period) + - add(person, period, excluded_incomes) ) other_income = max_(0, income_in_base - earnings) taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py index 8503f249b8..16164c7531 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py @@ -1,35 +1,23 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_dividend_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (excluding those for a particular income type) calculated last, applied to dividends." + label = "Allowances and reliefs deducted from dividend income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from dividend income, in the share " + "that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, " + "s. 25(3A)). See received_allowances_savings_income." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - received_allowances_savings_income = person( - "received_allowances_savings_income", period - ) - # Includes any trade loss relief beyond non-savings and savings - # income (ITA 2007 s.25(2)). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - - received_allowances_savings_income - ) - - dividend_income = person("taxable_dividend_income", period) - - return where( - dividend_income < remaining_allowance, - max_(0, dividend_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).dividends diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index 3103af399a..62bf03a92d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -1,27 +1,25 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class received_allowances_earned_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to certain types of income) that is applied to earned income" + label = "Allowances deducted from non-savings income" + documentation = ( + "The part of the allowances that Income Tax deducts from non-savings, " + "non-dividend income, property income included. Trade loss relief " + "against general income comes off that income first, inside " + "net_income, so this is whatever the share in " + "received_allowances_savings_income sets against that income beyond " + "the relief." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - # This portion of all allowances, applied first, excludes savings and dividends - - all_allowances = person("allowances", period) - - all_income = person("net_income", period) - excluded_income = person("taxable_savings_interest_income", period) + person( - "taxable_dividend_income", period - ) - earned_income = all_income - excluded_income - - return where( - earned_income < all_allowances, - max_(0, earned_income), - all_allowances, - ) + return non_savings_deductions(person, period).earned diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index f306c5649a..8d0326ef64 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -1,31 +1,25 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_savings_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to one income type) calculated after earned taxable income, but before dividends. This is applied to savings interest income." + label = "Allowances and reliefs deducted from savings income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from savings income. ITA 2007 s. 25(2) " + "deducts reliefs and allowances in the way that gives the least tax, " + "and from 2027-28 s. 25(3A) deducts them from income other than " + "property, savings and dividend income first. " + "policyengine_uk.utils.income_tax_allocation finds that share." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - # Trade loss relief beyond non-savings income comes off savings income - # next, then dividends in the model's fixed allowance order (#2106). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - ) - - savings_income = person("taxable_savings_interest_income", period) - - return where( - savings_income < remaining_allowance, - max_(0, savings_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).savings diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 5de4387597..40900b49b1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -16,6 +16,11 @@ def formula(person, period, parameters): exclusion_list = parameters( period ).gov.hmrc.income_tax.earned_taxable_income_exclusions - ani = person("net_income", period) + # net_income deducts all trade loss relief against general income. + # Add back the part set against savings and dividends, which the + # exclusions take off at their full amounts (ITA 2007 s. 25). + income = person("net_income", period) + person( + "trade_loss_relief_against_savings_and_dividends", period + ) exclusions = add(person, period, exclusion_list) - return max_(0, ani - exclusions) + return max_(0, income - exclusions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py index daff67c55d..232a6ff67e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -8,17 +8,25 @@ class non_savings_income_after_allowances(Variable): documentation = ( "Net income (before the Gift Aid step of adjusted net income, which " "does not reduce the income charged to tax) less savings and dividend " - "income and allowances, floored at nil: the income below savings and " - "dividends (ITA 2007 s. 16) once the allowances, which go to it first, " - "are taken off. " - "property_income_tax and taxable_property_income_after_allowances " + "income and the reliefs and allowances set against non-savings " + "income, floored at nil: the income below savings and dividends " + "(ITA 2007 s. 16) once Steps 2 and 3 are done. ITA 2007 s. 25(2) " + "decides how much of the reliefs and allowances comes off it, and " + "from 2027-28 s. 25(3A) takes them off the rest of it before property " + "income. property_income_tax and taxable_property_income_after_allowances " "both read it." ) definition_period = YEAR - reference = dict( - title="Income Tax Act 2007, s. 16", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", - ) + reference = [ + dict( + title="Income Tax Act 2007, s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ), + dict( + title="Income Tax Act 2007, s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ), + ] unit = GBP def formula(person, period, parameters): @@ -27,8 +35,11 @@ def formula(person, period, parameters): # Not adjusted_net_income: it deducts the grossed-up Gift Aid # gift (ITA 2007 s. 58), which raises the rate limits instead # (s. 414(2)(b)) and is no deduction from taxable income. + # net_income deducts all trade loss relief, so add back the part + # set against savings and dividends (s. 25). person("net_income", period) + + person("trade_loss_relief_against_savings_and_dividends", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) - - person("allowances", period), + - person("received_allowances_earned_income", period), ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index 201cc012df..b9e147f958 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class trade_loss_relief_against_savings_and_dividends(Variable): @@ -6,15 +7,15 @@ class trade_loss_relief_against_savings_and_dividends(Variable): entity = Person label = "Trade loss relief deducted from savings and dividend income" documentation = ( - "The part of trade loss relief against general income left after " - "non-savings income is used up; it joins the allowances applied to " - "savings income and then to dividends. ITA 2007 s.25(2) deducts " - "reliefs and allowances in the order giving the greatest reduction in " - "liability, and from 2027-28 s.25(3A) puts income other than property, " - "savings and dividends first. The model uses its fixed allowance order " - "(non-savings income, property last, then savings, then dividends), " - "which can overcharge where savings are taxed at 0%; that is model-wide " - "and tracked in #2106." + "The part of trade loss relief against general income that Income Tax " + "deducts from savings and dividend income. ITA 2007 s. 25(2) deducts " + "reliefs and allowances in the way that gives the least tax, and from " + "2027-28 s. 25(3A) deducts them from income other than property, " + "savings and dividend income first; received_allowances_savings_income " + "and received_allowances_dividend_income hold that share. The relief " + "comes off non-savings income first, as far as the share sets anything " + "against that income, and this is the rest. net_income deducts the " + "whole relief, so the non-savings bases add this back." ) definition_period = YEAR unit = GBP @@ -24,10 +25,6 @@ class trade_loss_relief_against_savings_and_dividends(Variable): ) def formula(person, period, parameters): - non_savings_income = ( - person("net_income_before_trade_loss_relief", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - ) - relief = person("trade_loss_relief_against_general_income", period) - return max_(0, relief - max_(0, non_savings_income)) + return non_savings_deductions( + person, period + ).relief_against_savings_and_dividends From 6d4a29074f3a41cb5192309b2fc25cd762a325c8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:48:21 -0400 Subject: [PATCH 035/168] Describe heads not finite at baseline in the README and changelog Keep a 0.0 response from judge_total when it skips no household, as the existing differential on finite responses pins, and say in the module docstring that the 0s set for a raised component change values only in the households that hold them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2773.fixed.md | 1 + policyengine_uk/utils/aggregate_impact/README.md | 7 ++++++- policyengine_uk/utils/aggregate_impact/guard.py | 10 +++++----- 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2773.fixed.md diff --git a/changelog.d/2773.fixed.md b/changelog.d/2773.fixed.md new file mode 100644 index 0000000000..5162a01ceb --- /dev/null +++ b/changelog.d/2773.fixed.md @@ -0,0 +1 @@ +The aggregate checker no longer stops with an error, or fails a component that ignores it, when a head, a model total or a component is NaN or infinite at baseline: it names each such variable once in a blocking finite: check, holds such a head by its own formula, judges every household where that formula gives back its baseline, and counts NaN as unchanged only where it was NaN before. diff --git a/policyengine_uk/utils/aggregate_impact/README.md b/policyengine_uk/utils/aggregate_impact/README.md index 700fd0fe35..e6cee0d732 100644 --- a/policyengine_uk/utils/aggregate_impact/README.md +++ b/policyengine_uk/utils/aggregate_impact/README.md @@ -124,7 +124,12 @@ The checks are: every other head held at baseline, and fails only where it still breaks it; a response carried by another head (Incapacity Benefit lowers Carer's Allowance, which Universal Credit counts as income) is reported as - mediated. A declared double count must still be contained in its head; + mediated. A declared double count must still be contained in its head. A + head, total or component that is NaN or infinite at baseline fails its + `finite:` check (`finite:metrics` for a total or a double count), and the + counted-once check fails no component for it: it reads no change where + the value is not finite, and holds such a head by leaving it to its + formula, judging each household where the formula gives back its baseline; - **signs, bounds and finiteness**, and **determinism:** a rebuilt simulation reproduces the baseline exactly; - **inert heads:** a head that is zero for every unit is a warning, unless the diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 1905c1a23a..d8357c5d87 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -71,9 +71,9 @@ head, or a model total, in the households where it, or one of its heads, is not finite at baseline. The engine refuses NaN as an input, so the check never sets such a value. To raise a component that is not finite - at baseline, it sets each such unit to 0 and raises the others; the - households holding one are the only ones whose values change, and it - judges nothing there for that component. To hold a head that is not + at baseline, it sets each such unit to 0 and raises the others. The 0s + change values only in the households that hold them, and it judges + nothing there for that component. To hold a head that is not finite at baseline, it does not set the head at all: it leaves it to its formula. Wherever the head does not respond to the component, its formula gives it back the value the measurement holds it at (its @@ -613,10 +613,10 @@ def problem(head, kind, where, nan): def mean_response(change, skip): """The mean change per unit added, over households not in ``skip``; - None when no household with weight is left to read.""" + None when ``skip`` leaves no household with weight to read.""" added = float(np.sum(weights * np.where(skip, 0.0, expected))) if not added: - return None + return None if skip.any() else 0.0 return float(np.sum(weights * np.where(skip, 0.0, change))) / added for variable, change in head_changes.items(): From e388e1f880d405680ad2d0ce243ef49ea1985434 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:10:19 -0400 Subject: [PATCH 036/168] Test that the check gives the engine only finite values Add a Hypothesis property that records every value the check sets while heads are NaN or infinite at baseline, and run case 2 on monthly heads. Co-Authored-By: Claude Opus 5.5 --- .../test_aggregate_impact_guard_baseline.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py index 1a7f838772..dbe22e38ec 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py @@ -110,17 +110,18 @@ def test_a_component_nan_at_baseline_is_named_and_never_given_to_the_engine( assert np.isnan(sim.calculate("tax_a", YEAR)).all() -def test_holding_a_head_nan_at_baseline_raises_nothing(): +@pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) +def test_holding_a_head_nan_at_baseline_raises_nothing(monthly): """Issue #2773, case 2: tax_c is tax_b, so it moves with tax_b. To measure tax_c again, the check held tax_a at its baseline, NaN, and the engine raised the same ValueError. Now it leaves tax_a to its formula, which - gives it back NaN, so tax_a is held at its baseline and the check finds - tax_c moving with tax_b, as it does.""" + gives it back NaN in every period, so tax_a is held at its baseline and + the check finds tax_c moving with tax_b, as it does.""" formulas = {**NAN_TAX_A, "tax_c": lambda f, period: f("tax_b")} _, result = core_counted_once( formulas, partition(["tax_a", "tax_b", "tax_c"]), - False, + monthly, variables_to_check=["tax_b"], ) row = by_variable(result)["tax_b"] @@ -658,6 +659,36 @@ def test_heads_not_finite_at_baseline_are_named_and_change_nothing_else(case, re } +class SetValuesSim(FakeSim): + """FakeSim that keeps every value given to ``set_input``. Like + policyengine-core, FakeSim refuses NaN but takes an infinite value.""" + + def __init__(self, formulas, inputs, weights): + super().__init__(formulas, inputs, weights) + self.set_values = [] + + def set_input(self, variable, period, values): + self.set_values.append(np.array(values, dtype=float)) + super().set_input(variable, period, values) + + +@settings(max_examples=40, deadline=None) +@given(case=extra_heads(), reads=READS) +def test_the_check_raises_and_holds_variables_only_at_finite_values(case, reads): + """Whatever is NaN or infinite at baseline, the check raises and holds + variables only at finite values. It also restores the supplied inputs, + which are finite here, so every value it gives the engine is finite.""" + shapes, extra = case + households = len(shapes) + formulas, part = with_extra_heads(shapes, extra, declared_reads(reads)) + sim = SetValuesSim(formulas, {"x": (1,) * households}, (1,) * households) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + guard.counted_once(sim, part, YEAR, 1e-6, snapshot) + assert sim.set_values + assert all(np.isfinite(values).all() for values in sim.set_values) + + def published(check, record, publish, describe, formulas, part, x, weights): """What one implementation publishes for a model, as JSON: the check's result, the checks it records, the published guard and the report's From 2e6ae55d79f9dd32986e6929fb62b7faabde8ff1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:24:24 -0400 Subject: [PATCH 037/168] Test Universal Credit carer-element awards in the carer test and the Scottish carer premium YAML cases with the arithmetic worked from the 2026-27 rates: the blind carer of #2120, a carer inside and outside the benefit unit, a carer on Carer's Allowance and Universal Credit, partners caring for the same person, the nil award, and the Scottish carer of #2124 in 2021, 2022 and 2026 with English and Welsh controls. Property tests check that the limb changes nothing without a carer element, never raises the severe disability premium or addition, never lowers the carer premium, and that every reader calculates on one simulation in any order. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2120.fixed.md | 1 + .../council_tax_reduction_carer_premium.yaml | 249 +++++++++++ .../uc_carer_element_carer_test.yaml | 254 +++++++++++ .../test_uc_carer_element_award_properties.py | 403 ++++++++++++++++++ 4 files changed, 907 insertions(+) create mode 100644 changelog.d/2120.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml create mode 100644 policyengine_uk/tests/test_uc_carer_element_award_properties.py diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md new file mode 100644 index 0000000000..3e754bdf83 --- /dev/null +++ b/changelog.d/2120.fixed.md @@ -0,0 +1 @@ +The model now withholds the severe disability premium and the Pension Credit severe disability addition when a carer's Universal Credit award includes the carer element for the claimant or partner, and pays the carer premium in Scotland's working-age council tax reduction for such an award from 1 April 2022 (#2120, #2124). diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml new file mode 100644 index 0000000000..2e7a1bbaeb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -0,0 +1,249 @@ +# Scotland's working-age council tax reduction includes the carer premium +# where the applicant or partner has regular and substantial caring +# responsibilities for a severely disabled person (SSI 2021/249 Sch 1 para +# 5(2)), which para 6(1)(c) extends to "an award of universal credit which +# includes the carer element described in regulation 29 of the 2013 +# Regulations". No other scheme's carer premium has that limb: the Housing +# Benefit, English and Welsh schedules test entitlement to Carer's Allowance +# or Carer Support Payment (HB Regs 2006 Sch 3 para 17; WSI 2013/3029 Sch 7 +# para 14), as did Scotland's scheme for persons under pension age before +# 1 April 2022 (SSI 2012/303 Sch 1 para 14). +# +# Amounts: carer premium £48.15 a week from 1 April 2026 (SSI 2021/249 Sch 1 +# para 5(1), substituted by SSI 2026/56) and £38.85 in 2022-23; personal +# allowance for a single person aged 25 or over £95.55 (2026-27) and £77.00 +# (2022-23); Universal Credit carer element £209.34 a month (2026-27) and +# £168.81 (2022-23), standard allowance £424.90 and £334.91. + +- name: The issue's Scottish carer aged 40 with 35 hours and no Carer's Allowance claim gets the carer premium + # Para 6(1)(c): the award includes the carer element, 209.34 x 12 = + # 2,512.08, so the applicable amount is (95.55 + 48.15) x 52 = 7,472.40, + # not 95.55 x 52 = 4,968.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 209.34 * 12 + universal_credit: (424.90 + 209.34) * 12 + receives_uc_carer_element: true + is_entitled_to_carer_benefit: false + carer_premium: 0 + council_tax_reduction_carer_premium: 48.15 * 52 + council_tax_reduction_applicable_amount: (95.55 + 48.15) * 52 + +- name: The Scottish carer premium for a carer element starts with the 2021 Regulations on 1 April 2022 + # Model year 2022 is 2022-23, read at 30 April 2022. Carer element + # 168.81 x 12 = 2,025.72; carer premium 38.85 x 52 = 2,020.20; applicable + # amount (77.00 + 38.85) x 52. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 168.81 * 12 + council_tax_reduction_carer_premium: 38.85 * 52 + council_tax_reduction_applicable_amount: (77.00 + 38.85) * 52 + +- name: Before 1 April 2022 the Scottish scheme for persons under pension age tested Carer's Allowance only + # SSI 2012/303 Sch 1 para 14: "entitled to a carer's allowance". Model + # year 2021 is 2021-22, read at 30 April 2021, so the carer element, + # 163.73 x 12 = 1,964.76, gives no premium. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 163.73 * 12 + council_tax_reduction_carer_premium: 0 + +- name: The same carer in England gets no carer premium for the carer element + # England: the working-age premiums follow the Housing Benefit schedule, + # whose carer premium needs entitlement to Carer's Allowance (HB Regs 2006 + # Sch 3 para 17(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: ENGLAND + output: + uc_carer_element: 209.34 * 12 + receives_uc_carer_element: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_applicable_amount: 95.55 * 52 + +- name: The same carer in Wales gets no carer premium for the carer element + # WSI 2013/3029 Sch 7 para 14(1): "entitled to a carer's allowance". + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: WALES + output: + uc_carer_element: 209.34 * 12 + receives_uc_carer_element: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_applicable_amount: 95.55 * 52 + +- name: A Scottish carer whose income tapers the award to nil has no award and no carer premium + # With £20,000 of earnings the award is nil, so there is no award (Welfare + # Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and para 6(1)(c) does not + # apply, though the maximum amount still includes the carer element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 209.34 * 12 + universal_credit: 0 + receives_uc_carer_element: false + council_tax_reduction_carer_premium: 0 + +- name: A Scottish carer with both Carer Support Payment and the carer element gets one premium + # The premium is paid in respect of each person who qualifies (para 5(3)), + # not for each award: 48.15 x 52 = 2,503.80, not twice that. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + receives_carer_benefit: true + receives_uc_carer_element: true + carer_premium: 48.15 * 52 + council_tax_reduction_carer_premium: 48.15 * 52 + +- name: Scottish partners caring for the same person get one premium, whichever holds the carer element + # Both care 35 hours. The younger receives Carer Support Payment and the + # joint award includes the carer element for the elder (UC Regs 2013 reg + # 29(3)). Para 5(3) and (4) pay one premium, in respect of the one whose + # award includes the carer element: 48.15 x 52 = 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 45 + care_hours: 35 + would_claim_carers_allowance: false + younger: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + benunits: + couple: + members: [elder, younger] + households: + household: + members: [elder, younger] + country: SCOTLAND + region: SCOTLAND + output: + partners_care_for_same_severely_disabled_person: true + receives_carer_benefit: [false, true] + receives_uc_carer_element: [true, false] + council_tax_reduction_carer_premium: 48.15 * 52 + +- name: A Scottish pensioner's schedule has no carer element limb + # SSI 2012/319 Sch 1 para 10(1): entitlement to Carer's Allowance or Carer + # Support Payment. The working-age variable keeps the shared premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + council_tax_reduction_pensioner: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_premiums: 0 diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml new file mode 100644 index 0000000000..39b4892cd1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -0,0 +1,254 @@ +# An award of Universal Credit which includes the carer element counts as a +# carer benefit in the carer condition of the legacy severe disability premium +# (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b); HB(SPC) Regs 2006 Sch 3 +# para 6(2)) and of the Pension Credit severe disability addition (SPC Regs +# 2002 Sch I para 1(1)(a)(iii) and (b), with para 1(4)): no premium or +# addition is paid for a person someone "has an award of universal credit which +# includes the carer element in respect of caring for". The carer element is +# for regular and substantial caring for a severely disabled person, whether or +# not the carer claims Carer's Allowance (UC Regs 2013 regs 29 and 30(2)). +# +# Amounts for 2026-27 (DWP benefit and pension rates 2026 to 2027; the Social +# Security Benefits Up-rating Order 2026, SI 2026/148): severe disability +# premium and addition £86.05 a week, couple premium £172.10, Carer's Allowance +# £86.45; Universal Credit standard allowance £424.90 a month (single, 25 or +# over) and £666.97 (joint, either 25 or over), carer element £209.34 a month. + +- name: A blind carer's Universal Credit carer element bars the premium of the person they care for + # The carer is a non-dependant who is blind, so the non-dependant condition + # ignores them (para 14(4)(b)); their award bars the premium instead. + # Carer element 209.34 x 12 = 2,512.08; award (424.90 + 209.34) x 12 = + # 7,610.88. Without the limb the premium was 86.05 x 52 = 4,474.60. + period: 2026 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + uc_carer_element: [0, 2_512.08] + universal_credit: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: [0, 0] + +- name: A partner whose joint award includes the carer element leaves a couple who both qualify the single rate + # Para 14(2)(b)(iii): a carer element in respect of caring for only one of + # the couple gives one single rate, 86.05 x 52 = 4,474.60, where no carer + # gave the double rate, 172.10 x 52 = 8,949.20. Joint award (666.97 + + # 209.34) x 12 = 10,515.72. + period: 2026 + input: + people: + claimant: + age: 45 + pip_dl_category: STANDARD + partner: + age: 40 + pip_dl_category: STANDARD + care_hours: 35 + would_claim_carers_allowance: false + benunits: + couple: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + uc_carer_element: 2_512.08 + universal_credit: 10_515.72 + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: 4_474.60 + +- name: A son's Universal Credit carer element in another benefit unit bars a pensioner's severe disability addition + # SPC Regs 2002 Sch I para 1(1)(a)(iii) and (4). The son is blind, so the + # residence condition ignores him (para 2). Without the limb the addition + # was 86.05 x 52 = 4,474.60, and so was the pension-age Housing Benefit + # premium (HB(SPC) Regs 2006 Sch 3 para 6(2)(a)(iii)). + period: 2026 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: ENGLAND + output: + universal_credit: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_minimum_guarantee_addition: [0, 0] + pension_age_severe_disability_premium: [0, 0] + +- name: A carer on both Carer's Allowance and Universal Credit is one carer + # The carer receives Carer's Allowance, 86.45 x 52 = 4,495.40, and a + # Universal Credit award with the carer element, (424.90 + 209.34) x 12 - + # 4,495.40 = 3,115.48. They care for one person (SSCBA 1992 s.70(7) and + # (7ZA), (7A)), so the couple who both qualify keep one rate of the + # addition, 86.05 x 52 = 4,474.60 (SPC Regs 2002 reg 6(5)), as with the + # allowance alone. + period: 2026 + input: + people: + husband: + age: 78 + aa_category: HIGHER + wife: + age: 76 + aa_category: HIGHER + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: true + benunits: + couple: + members: [husband, wife] + carer_unit: + members: [carer] + households: + household: + members: [husband, wife, carer] + country: ENGLAND + output: + carers_allowance: [0, 0, 4_495.40] + universal_credit: [0, 3_115.48] + receives_carer_benefit: [false, false, true] + has_uc_award_with_carer_element: [false, false, true] + is_cared_for_by_carer_benefit_recipient: [true, false, false] + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + +- name: A partner's carer element is not a second carer where the other partner receives the allowance for the same person + # Both partners care 35 hours; the younger claims Carer's Allowance and the + # joint award includes the carer element for the elder (UC Regs 2013 reg + # 29(3); the model takes the elder). Partners caring for the same severely + # disabled person have one relevant entitlement between them (SSCBA 1992 + # s.70(7ZA) and (7A)), so they care for one person, the disabled child, and + # not also the grandmother in another benefit unit. + period: 2026 + input: + people: + elder: + age: 45 + care_hours: 35 + would_claim_carers_allowance: false + younger: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + child: + age: 8 + dla_sc_category: MIDDLE + grandmother: + age: 80 + aa_category: HIGHER + benunits: + family: + members: [elder, younger, child] + grandmother_unit: + members: [grandmother] + households: + household: + members: [elder, younger, child, grandmother] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: [true, true] + receives_carer_benefit: [false, true, false, false] + has_uc_award_with_carer_element: [true, false, false, false] + is_cared_for_by_carer_benefit_recipient: [false, false, true, false] + +- name: "Disclosed limit: a carer whose income tapers the award to nil still bars the premium" + # With £25,000 of earnings the carer's award is nil (income reduction + # 7,610.88 against a maximum of 7,610.88), so in law there is no award + # (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and the premium, + # 86.05 x 52 = 4,474.60, is payable. The model reads the award from its + # conditions, because the award reads Pension Credit, which reads the + # addition, so it withholds the premium. + period: 2026 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + universal_credit: [0, 0] + receives_uc_carer_element: [false, false] + has_uc_award_with_carer_element: [false, true] + severe_disability_premium: [0, 0] + +- name: Without a Universal Credit claim there is no carer element award + # The same carer as in the first case, in a family that does not claim + # Universal Credit and does not claim Carer's Allowance: no one has an award + # in respect of caring for the disabled person, who keeps the premium, + # 86.05 x 52 = 4,474.60. + period: 2026 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + would_claim_uc: false + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_premium: [4_474.60, 0] diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py new file mode 100644 index 0000000000..d02cedc855 --- /dev/null +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -0,0 +1,403 @@ +"""Universal Credit carer-element awards as a carer benefit. + +HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b) (with the IS, JSA, ESA, +pension-age Housing Benefit and council tax reduction schedules) and SPC Regs +2002 Sch I para 1(1) and (4) withhold the severe disability premium and the +Pension Credit severe disability addition where someone "has an award of +universal credit which includes the carer element in respect of caring for" +the claimant or partner. Scotland's working-age council tax reduction (SSI +2021/249 Sch 1 paras 5 and 6(1)(c), from 1 April 2022) pays the carer premium +to an applicant or partner with such an award. + +The model reads the award from its conditions (has_uc_award_with_carer_element) +for the severe disability carer test, since the award itself reads Pension +Credit, and from the positive award (receives_uc_carer_element) for the +Scottish carer premium. Each example simulates the households as drawn, and +again with has_uc_award_with_carer_element supplied as false for everyone, the +attribution as it ran before the limb (its carers are then +receives_carer_benefit alone). The Scottish premium's counterpart without the +limb is carer_premium. + +Invariants, for any generated population of households: + +1. No change without a carer element: where no one has + has_uc_award_with_carer_element or receives_uc_carer_element, the + attribution, the severe disability premiums and the addition equal their + values without the limb, and the Scottish premium equals carer_premium, + exactly. +2. Never raises: no carer element raises the severe disability premium (the + shared, council tax reduction and pension-age premiums) or the Pension + Credit addition. +3. Never lowers, and only in Scotland: council_tax_reduction_carer_premium is + never below carer_premium, and differs from it only for a Scottish + working-age family. +4. Bounds: the Scottish premium is a whole number of weekly amounts, at most + the number of claimants and partners, and at most one where partners care + for the same person; a person with both an allowance and a carer element + counts once. +5. The read before the award misses no award: receives_uc_carer_element + implies has_uc_award_with_carer_element. +6. Attribution: no one is their own carer, everyone cared for is severely + disabled for Carer's Allowance, and a household has no more people cared + for than carers (carer benefit recipients and carer element holders). +7. No cycle: every reader calculates on one simulation, whichever is requested + first. + +The no-change invariant compares the two code paths in this tree; the +differential against origin/main itself runs on hand-built datasets outside +the test suite (see the pull request). +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +CARER_PREMIUM = 48.15 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +SEVERE_READERS = ( + "severe_disability_premium", + "severe_disability_premium_before_non_dependant_test", + "council_tax_reduction_severe_disability_premium", + "pension_age_severe_disability_premium", + "severe_disability_minimum_guarantee_addition", +) +CARER_READERS = ( + "carer_premium", + "council_tax_reduction_carer_premium", + "council_tax_reduction_premiums", +) +BENUNIT_READERS = SEVERE_READERS + CARER_READERS +PERSON_READERS = ( + "is_cared_for_by_carer_benefit_recipient", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", +) +# Every reader of the attribution or the carer element award, and the +# programmes that read them, for the cycle check. +ALL_READERS = BENUNIT_READERS + ( + "is_cared_for_by_carer_benefit_recipient", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", + "universal_credit", + "pension_credit", + "housing_benefit", + "income_support", + "council_tax_reduction", + "household_net_income", +) + + +DISABILITY = { + "none": {}, + "pip_standard": {"pip_dl_category": "STANDARD"}, + "aa_higher": {"aa_category": "HIGHER"}, + "dla_middle": {"dla_sc_category": "MIDDLE"}, +} + + +@st.composite +def adult(draw, age): + return dict( + age=draw(age), + disability=draw(st.sampled_from(["none", "none", "pip_standard", "aa_higher"])), + blind=draw(st.sampled_from([False, False, True])), + # The qualifying hours, with or without a Carer's Allowance claim: the + # Universal Credit carer element needs no claim (UC Regs 2013 reg + # 30(2)), and its test ignores the allowance's earnings limit. + care_hours=draw(st.sampled_from([0, 35, 35])), + would_claim_carers_allowance=draw(st.booleans()), + earnings=draw(st.sampled_from([0, 0, 8_000, 25_000])), + ) + + +@st.composite +def benefit_unit(draw): + # Working-age or pension-age adults, partners within ten years of each + # other, so no adult reads as the other's child. + eldest = draw(st.integers(20, 80)) + ages = st.integers(max(18, eldest - 10), eldest) + adults = [draw(adult(st.just(eldest)))] + adults += draw(st.lists(adult(ages), max_size=1)) + children = draw( + st.lists( + st.fixed_dictionaries( + dict( + age=st.integers(0, 15), + disability=st.sampled_from(["none", "dla_middle"]), + ) + ), + max_size=1, + ) + ) + return dict(adults=adults, children=children) + + +@st.composite +def households(draw): + return dict( + country=draw(st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"])), + units=draw(st.lists(benefit_unit(), min_size=1, max_size=3)), + ) + + +def situation(population, without_limb=()): + """One situation; households in without_limb have no carer element award.""" + people, benunits, hh = {}, {}, {} + for h, household in enumerate(population): + members = [] + for u, unit in enumerate(household["units"]): + unit_members = [] + for a, p in enumerate(unit["adults"]): + name = f"h{h}_u{u}_a{a}" + people[name] = { + "age": {YEAR: p["age"]}, + "is_blind": {YEAR: p["blind"]}, + "care_hours": {YEAR: p["care_hours"]}, + "would_claim_carers_allowance": { + YEAR: p["would_claim_carers_allowance"] + }, + "employment_income": {YEAR: p["earnings"]}, + "is_claimant_or_partner": {YEAR: True}, + **{k: {YEAR: v} for k, v in DISABILITY[p["disability"]].items()}, + } + if h in without_limb: + people[name]["has_uc_award_with_carer_element"] = {YEAR: False} + unit_members.append(name) + for c, p in enumerate(unit["children"]): + name = f"h{h}_u{u}_c{c}" + people[name] = { + "age": {YEAR: p["age"]}, + "is_claimant_or_partner": {YEAR: False}, + **{k: {YEAR: v} for k, v in DISABILITY[p["disability"]].items()}, + } + unit_members.append(name) + benunits[f"h{h}_u{u}"] = {"members": unit_members} + members += unit_members + hh[f"h{h}"] = {"members": members, "country": {YEAR: household["country"]}} + return {"people": people, "benunits": benunits, "households": hh} + + +def with_and_without_limb(population, variables): + """Each variable for the households as drawn, and without the limb. + + Without the limb, has_uc_award_with_carer_element is supplied as false + for everyone, in a second simulation: a variable supplied for some people + takes its default, not its formula, for the rest. + """ + sim = Simulation(situation=situation(population)) + sim_without = Simulation( + situation=situation(population, without_limb=range(len(population))) + ) + return sim, { + v: ( + np.asarray(sim.calculate(v, YEAR)), + np.asarray(sim_without.calculate(v, YEAR)), + ) + for v in variables + } + + +def household_index(sim, entity): + """The household each benefit unit or person belongs to, by position.""" + population = sim.populations[entity] + names = population.ids + return np.array([int(str(name).split("_")[0][1:]) for name in names]) + + +def single(age=40, **inputs): + return dict( + dict( + age=age, + disability="none", + blind=False, + care_hours=0, + would_claim_carers_allowance=False, + earnings=0, + ), + **inputs, + ) + + +EXAMPLES = [ + # The blind carer: a Universal Credit carer element in another benefit + # unit bars the disabled person's premium. + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict(adults=[single(40, blind=True, care_hours=35)], children=[]), + ], + ), + # The same carer earning enough to taper the award to nil. + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict( + adults=[single(40, blind=True, care_hours=35, earnings=25_000)], + children=[], + ), + ], + ), + # The Scottish carer of the issue, aged 40 with 35 hours and no claim. + dict(country="SCOTLAND", units=[dict(adults=[single(care_hours=35)], children=[])]), + # A Scottish couple, one claiming the allowance, both caring. + dict( + country="SCOTLAND", + units=[ + dict( + adults=[ + single(45, care_hours=35), + single(40, care_hours=35, would_claim_carers_allowance=True), + ], + children=[dict(age=6, disability="dla_middle")], + ) + ], + ), + # A pensioner cared for by a son on a qualifying benefit. + dict( + country="WALES", + units=[ + dict(adults=[single(75, disability="aa_higher")], children=[]), + dict( + adults=[single(40, disability="pip_standard", care_hours=35)], + children=[], + ), + ], + ), +] + + +def test_issue_households_read_the_carer_element_award(): + # #2120: the blind carer's award includes the carer element, 209.34 x 12, + # so the disabled person has no severe disability premium (86.05 x 52 + # without it). #2124: the Scottish carer's applicable amount includes the + # carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. Only variables + # that predate the fix are read, so the test also runs on the old code. + sim = Simulation(situation=situation(EXAMPLES[:1] + EXAMPLES[2:3])) + severe = sim.calculate("severe_disability_premium", YEAR) + carer_element = sim.calculate("uc_carer_element", YEAR) + applicable = sim.calculate("council_tax_reduction_applicable_amount", YEAR) + # Benefit units: the disabled person, the blind carer, the Scottish carer. + assert carer_element == pytest.approx([0, 209.34 * 12, 209.34 * 12]) + assert severe[0] == 0 + assert applicable[2] == pytest.approx((95.55 + 48.15) * WEEKS) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=1, max_size=4)) +@example(EXAMPLES) +def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(population): + variables = SEVERE_READERS + ( + "carer_premium", + "council_tax_reduction_carer_premium", + "council_tax_reduction_pensioner", + "partners_care_for_same_severely_disabled_person", + "is_cared_for_by_carer_benefit_recipient", + "is_severely_disabled_for_carers_allowance", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", + "receives_carer_benefit", + "is_claimant_or_partner", + ) + sim, values = with_and_without_limb(population, variables) + drawn = {v: pair[0] for v, pair in values.items()} + without = {v: pair[1] for v, pair in values.items()} + n_benunits = len(drawn["carer_premium"]) + benunit_household = household_index(sim, "benunit") + person_household = household_index(sim, "person") + + # 5. The read before the award misses no award. + has_award = drawn["has_uc_award_with_carer_element"] + positive_award = drawn["receives_uc_carer_element"] + assert not (positive_award & ~has_award).any() + + # 1. No change without a carer element, household by household. + touched = list(set(person_household[has_award | positive_award])) + untouched_units = ~np.isin(benunit_household, touched) + untouched_people = ~np.isin(person_household, touched) + for v in SEVERE_READERS: + assert (drawn[v][untouched_units] == without[v][untouched_units]).all(), v + cared_for = drawn["is_cared_for_by_carer_benefit_recipient"] + cared_for_without = without["is_cared_for_by_carer_benefit_recipient"] + assert (cared_for[untouched_people] == cared_for_without[untouched_people]).all() + ctr = drawn["council_tax_reduction_carer_premium"] + shared = drawn["carer_premium"] + assert (ctr[untouched_units] == shared[untouched_units]).all() + + # 2. A carer element never raises the severe disability premium or the + # addition, and never removes anyone from the cared-for set. + for v in SEVERE_READERS: + assert (drawn[v] <= without[v]).all(), v + assert not (cared_for_without & ~cared_for).any() + + # 3. The Scottish premium never lowers the carer premium and moves it + # only in Scotland at working age. + assert (ctr >= shared).all() + countries = sim.calculate("country", YEAR) + if hasattr(countries, "decode_to_str"): + countries = countries.decode_to_str() + country = dict(zip(sim.populations["household"].ids, np.asarray(countries))) + scottish = np.array([country[f"h{h}"] == "SCOTLAND" for h in benunit_household]) + scottish_working_age = scottish & ~drawn["council_tax_reduction_pensioner"] + assert (ctr[~scottish_working_age] == shared[~scottish_working_age]).all() + + # 4. Whole weekly amounts, at most one per claimant or partner, one where + # partners care for the same person. + weekly = ctr / (CARER_PREMIUM * WEEKS) + assert np.allclose(weekly, np.round(weekly)) + benunit_position = { + name: i for i, name in enumerate(sim.populations["benunit"].ids) + } + person_benunit = [ + benunit_position[name.rsplit("_", 1)[0]] + for name in sim.populations["person"].ids + ] + claimants = np.bincount( + person_benunit, weights=drawn["is_claimant_or_partner"], minlength=n_benunits + ) + assert (np.round(weekly) <= claimants).all() + same_person = drawn["partners_care_for_same_severely_disabled_person"] + assert (np.round(weekly[same_person]) <= 1).all() + + # 6. Everyone cared for is severely disabled for Carer's Allowance; no + # household has more people cared for than carers, and a lone carer is + # not cared for. + assert not (cared_for & ~drawn["is_severely_disabled_for_carers_allowance"]).any() + carer = drawn["receives_carer_benefit"] | has_award + for h in np.unique(person_household): + in_h = person_household == h + assert cared_for[in_h].sum() <= carer[in_h].sum() + if carer[in_h].sum() == 1: + assert not (cared_for & carer)[in_h].any() + + +READER_ORDERS = st.permutations(ALL_READERS) + + +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(households(), min_size=1, max_size=3), READER_ORDERS) +@example(EXAMPLES, ALL_READERS) +@example(EXAMPLES, tuple(reversed(ALL_READERS))) +def test_every_reader_calculates_on_one_simulation_in_any_order(population, order): + # A CycleError, or the recursion it guards, would fail this test. + sim = Simulation(situation=situation(population)) + for variable in order: + values = np.asarray(sim.calculate(variable, YEAR)) + assert np.isfinite(values.astype(float)).all(), variable From 2f5b183ca8f0a3ef7f3bc1a2175ae24fdb25cacc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:51:42 -0400 Subject: [PATCH 038/168] Follow net income in the allocation, test s. 25(3A) and update tests the least-tax share moves Base non-savings income on net_income plus trade loss relief, as the default order does, so a supplied adjusted net income or a reform that sets net income is followed. Add a 2027-28 case where s. 25(3A) keeps the allowance against pay. Update the gift aid and integration cases, and the gift aid snapshot, whose tax falls under s. 25(2), with the arithmetic. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 17 ++++++++ .../gov/hmrc/income_tax/gift_aid.yaml | 42 ++++++++++++------- policyengine_uk/tests/test_gift_aid.py | 30 ++++++++----- .../tests/test_income_tax_allocation.py | 4 +- .../utils/income_tax_allocation.py | 21 +++++----- 5 files changed, 77 insertions(+), 37 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index dff0428056..94e5ccba29 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -201,3 +201,20 @@ received_allowances_earned_income: 12_570 property_income_tax: 4_183.2 income_tax: 4_183.2 + +- name: Case 11, from 2027-28 the allowance comes off pay before dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # s. 25(3A) deducts the allowance from income other than property, + # savings and dividend income first, so all £12,570 comes off the pay, + # unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 + # at the nil rate and (57,430 - 37,930) x 35.75% = 6,971.25. + received_allowances_earned_income: 12_570 + received_allowances_dividend_income: 0 + dividend_income_tax: 6_971.25 + income_tax: 14_457.25 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index e6384e97ee..b9052b16d6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -92,11 +92,15 @@ dividend_income: [10_000, 10_000] gift_aid: [0, 800] output: - earned_taxable_income: [37_430, 37_430] - # The £500 dividend allowance still occupies the band. The extension - # moves £770 of taxed dividends from 35.75% to 10.75%: £192.50 relief. - dividend_income_tax: [3_396.25, 3_203.75] - income_tax: [10_882.25, 10_689.75] + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £38,700 with the £1,000 grossed-up gift, and the + # rest of it comes off dividends, so the £500 dividend allowance covers + # dividends that would otherwise be taxed at 35.75%. Dividends left: + # 9,730 and 8,730; less £500 at the nil rate, at 35.75%. The extension + # keeps £1,000 more pay at 20% in place of upper-rate dividends: £157.50. + earned_taxable_income: [37_700, 38_700] + dividend_income_tax: [3_299.725, 2_942.225] + income_tax: [10_839.725, 10_682.225] - name: Gift Aid extends UK limits across savings and dividend slices period: 2026 @@ -109,12 +113,18 @@ dividend_income: [5_000, 5_000] gift_aid: [0, 1_600] output: - # £2,000 gross extension moves £800 taxed savings down 20 percentage - # points and £700 taxed dividends down 25 percentage points. + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £39,700 with the £2,000 grossed-up gift, and + # the rest comes off savings above it, taxed at 40%, keeping the £500 + # savings allowance in use. Without the gift: £800 of savings, £500 at + # the nil rate and £300 at 40% = 120; all £5,000 of dividends, £500 at the + # nil rate and £4,500 at 35.75% = 1,608.75; pay 7,540. With it: £500 of + # savings at the nil rate; £3,300 of dividends, (3,300 - 500) x 35.75% = + # 1,001; pay 39,700 x 20% = 7,940. savings_allowance: [500, 500] - savings_income_tax: [460, 300] - dividend_income_tax: [1_608.75, 1_433.75] - income_tax: [9_368.75, 9_033.75] + savings_income_tax: [120, 0] + dividend_income_tax: [1_608.75, 1_001] + income_tax: [9_268.75, 8_941] - name: Gift Aid extends UK savings and dividend limits for Scottish taxpayers period: 2026 @@ -461,8 +471,12 @@ members: [control] region: NORTH_WEST output: - # Both still have higher-rate income, so a £500 savings allowance. Without - # the gift, £9,770 at 20% and £9,730 at 40%; the gift moves £1,000 from - # 40% to 20%: £200. + # Both still have higher-rate income, so a £500 savings allowance. ITA + # 2007 s. 25(2): the Personal Allowance keeps the property income up to + # the basic rate limit, £38,700 with the £1,000 grossed-up gift and + # £37,700 without, and the rest comes off savings, so the savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 8,730 and 9,730, less £500 at the nil rate, at 40%. The gift moves + # £1,000 of income from 40% to 20%: £200. savings_allowance: [500, 500] - savings_income_tax: [5_646, 5_846] + savings_income_tax: [3_292, 3_692] diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 34d7bb182c..1b74ef4d08 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -299,23 +299,33 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "personal_allowance": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "allowances": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "unused_personal_allowance": [0, 0, 0, 0, 0, 0] * 2, - "earned_taxable_income": [17_430, 47_430, 150_000, 17_430, 0, 88_930] * 2, - "received_allowances_earned_income": [12_570, 12_570, 0, 12_570, 10_000, 12_070] - * 2, - "received_allowances_savings_income": [0, 0, 0, 0, 2570, 0] * 2, + # The fourth row outside Scotland sets £1,300 of the Personal + # Allowance against pay and £11,270 against savings (ITA 2007 + # s. 25(2), #2106): pay and property reach the £37,700 basic rate + # limit, and the £500 savings allowance covers savings that would be + # taxed at 40%, so its tax falls by £100. In Scotland the pay above + # £31,092 would be taxed at 42%, so the order is unchanged. + "earned_taxable_income": [17_430, 47_430, 150_000, 28_700, 0, 88_930] + + [17_430, 47_430, 150_000, 17_430, 0, 88_930], + "received_allowances_earned_income": [12_570, 12_570, 0, 1_300, 10_000, 12_070] + + [12_570, 12_570, 0, 12_570, 10_000, 12_070], + "received_allowances_savings_income": [0, 0, 0, 11_270, 2570, 0] + + [0, 0, 0, 0, 2570, 0], "received_allowances_dividend_income": [0, 0, 0, 0, 0, 0] * 2, - "basic_rate_earned_income": [17_430, 37_700, 37_700, 17_430, 0, 37_700] * 2, + "basic_rate_earned_income": [17_430, 37_700, 37_700, 28_700, 0, 37_700] + + [17_430, 37_700, 37_700, 17_430, 0, 37_700], "higher_rate_earned_income": [0, 9730, 87_440, 0, 0, 51_230] * 2, "add_rate_earned_income": [0, 0, 24_860, 0, 0, 0] * 2, - "basic_rate_savings_income": [0, 0, 0, 10_770, 11_430, 0] * 2, - "higher_rate_savings_income": [0, 0, 0, 730, 0, 0] * 2, + "basic_rate_savings_income": [0, 0, 0, 0, 11_430, 0] + + [0, 0, 0, 10_770, 11_430, 0], + "higher_rate_savings_income": [0, 0, 0, 230, 0, 0] + [0, 0, 0, 730, 0, 0], "add_rate_savings_income": [0, 0, 0, 0, 0, 0] * 2, "savings_allowance": [1000, 500, 0, 500, 1000, 500] * 2, "earned_income_tax": [ 3486, 11_432, 53_703, - 3486, + 5740, 0, 28_032, 3451.070068359375, @@ -325,14 +335,14 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): 0, 31407.05078125, ], - "savings_income_tax": [0, 0, 0, 2446, 2286, 0] * 2, + "savings_income_tax": [0, 0, 0, 92, 2286, 0] + [0, 0, 0, 2446, 2286, 0], "dividend_income_tax": [0, 0, 0, 6256.25, 1021.25, 0] * 2, "property_income_tax": [0, 0, 0, 1800, 0, 0, 0, 0, 0, 1890, 0, 0], "income_tax": [ 3486, 11_432, 53_703, - 13988.25, + 13888.25, 3307.25, 28_032, 3451.070068359375, diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 2a462513e0..21923ce248 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -37,7 +37,7 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEARS = (2026, 2027) -PEOPLE = 12 +PEOPLE = 8 REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] INCOMES = ( "employment_income", @@ -121,7 +121,7 @@ people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) year = st.sampled_from(YEARS) SETTINGS = settings( - max_examples=20, + max_examples=8, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 055edd076f..61f3238422 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -113,17 +113,16 @@ def column(values): savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) dividends = np.maximum(0, column(person("taxable_dividend_income", period))) + relief = column(person("trade_loss_relief_against_general_income", period)) + # net_income, not its components, so a supplied adjusted net income or a + # reform that sets net income is followed, as the default order does. non_savings = np.maximum( - 0, - column(person("net_income_before_trade_loss_relief", period)) - - savings - - dividends, + 0, column(person("net_income", period)) + relief - savings - dividends ) property_income = np.minimum( np.maximum(0, column(person("taxable_property_income", period))), non_savings ) - deductions = column(person("trade_loss_relief_against_general_income", period)) - deductions = deductions + column(person("allowances", period)) + deductions = relief + column(person("allowances", period)) used = np.minimum(deductions, non_savings + savings + dividends) exclusions = [ variable @@ -428,14 +427,14 @@ def non_savings_deductions(person, period): moved = (to_savings != default.savings) | (to_dividends != default.dividends) savings = np.maximum(0, person("taxable_savings_interest_income", period)) dividends = np.maximum(0, person("taxable_dividend_income", period)) + relief = person("trade_loss_relief_against_general_income", period).astype( + np.float64 + ) non_savings = np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - savings - dividends, + 0, person("net_income", period) + relief - savings - dividends ) - relief = person("trade_loss_relief_against_general_income", period) used = np.minimum( - relief.astype(np.float64) + person("allowances", period), - non_savings.astype(np.float64) + savings + dividends, + relief + person("allowances", period), non_savings + savings + dividends ) to_non_savings = used - to_savings - to_dividends return SimpleNamespace( From b6ddfcf0dc36d640a36f544032ffaffbe912d8f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:20:30 -0400 Subject: [PATCH 039/168] Read entered or reformed Pension Credit for every couple the ESA bar reaches The income-related ESA screen inferred that Pension Credit could not pay a couple that failed the Pension Credit age conditions or would not claim. That holds only for the model's own formulas. Where a caller enters pension_credit, its entitlement, its eligibility or either credit, a reform replaces one of their formulas or changes the entitlement's sum, or the reform freezes Pension Credit at the baseline's award, the screen now reads Pension Credit without the award for every couple whose claim passes the other tests (pension_credit_follows_model_formulas). A parameter reform keeps the age and take-up conditions. The screen also returns before it reads any Pension Credit condition when no couple's claim passes the age, work and capital tests, and the Income Support gate test reads Pension Credit only for its ESA reference. replaces_formula, shared with Housing Benefit's hb_uses_annual_override, tells an input or a replaced formula from the model's calculation; the five Pension Credit variables join SUPPLIED_INPUT_VARIABLES so that it can ask about them. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 14 +- .../esa_income/esa_income_pension_age.yaml | 117 +++++ .../test_esa_income_pension_age_properties.py | 406 ++++++++++++++++-- ...t_gate_reads_claimant_or_partner_awards.py | 19 +- policyengine_uk/utils/supplied_inputs.py | 26 ++ .../variables/gov/dwp/esa_income_eligible.py | 228 ++++++---- ...ed_or_temporary_accommodation_disregard.py | 19 +- 7 files changed, 695 insertions(+), 134 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index efbe9685ec..2c5a45f939 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -29,9 +29,9 @@ their own State Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). A caller that reads the model's pensionable age passes it as is_SP_age; otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone - aged 64 or under was born after 5 October 1960 and reaches it at 66 and - some months or at 67 (rules 7 and 8), and anyone aged 70 or over was born - before 6 April 1960 and reached it at 66 or earlier. + aged 64 or under was born after 5 October 1960, reaches pensionable age at + 66 or later, and so falls below it; anyone aged 70 or over was born before + 6 April 1960 and reached it at 66 or earlier. - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would @@ -41,8 +41,9 @@ exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 - s.1(6)), so s.1(3)(c) bars each of them whether or not Pension Credit - pays. It bars only the couple's claim, not that of a member claiming in + s.1(6)), so each of them has reached pensionable age and has no claim + under s.1(3)(c), whether or not Pension Credit pays. Pension Credit + entitlement ends only the couple's claim, not that of a member claiming in their own right. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the @@ -184,7 +185,8 @@ def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing Benefit reports in 2025 or 2026: a couple every member of which has reached the qualifying age, each of whom has also reached pensionable - age, so s.1(3)(c) bars them whether or not Pension Credit pays.""" + age and so has no claim under s.1(3)(c), whether or not Pension Credit + pays.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index bde90721ca..c612100677 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -696,3 +696,120 @@ is_housing_benefit_benefit_cap_exempt_specified_benefit: true # So no cap applies: uc_benefit_cap is unlimited. is_uc_benefit_cap_exempt: true + +# Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model +# then reads the award it pays without the couple's income-related ESA, +# whatever the Pension Credit age conditions and the claim +# (would_claim_pc), since the input, not the formulas, sets the award. The +# man of cases 7 and 8 meets the age conditions; a man aged 40, born 1 March +# 1976, does not. + +- name: Case 21, a man who reports income-related ESA and has £2,000 of Pension Credit as an input gets no ESA, though he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + pension_credit: 2_000 + households: + household: + members: [person1] + output: + # Para 6(1)(c): he has an entitlement to the £2,000 of Pension Credit. + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + +- name: Case 22, a man aged 40 whose Pension Credit entitlement is an input of £2,000 gets that Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + pension_credit_entitlement: 2_000 + households: + household: + members: [person1] + output: + # He would claim, so pension_credit pays the entitlement, though he is + # under the qualifying age for State Pension Credit. + meets_pension_credit_age_conditions: false + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + +- name: Case 23, a man aged 40 whom an input makes eligible for Pension Credit, with no income, gets the minimum guarantee and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + is_pension_credit_eligible: true + households: + household: + members: [person1] + output: + meets_pension_credit_age_conditions: false + # Without the award his income is nil, so the guarantee credit pays the + # minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg + # 6(1)(b)). Under 65 he gets no savings credit. + guarantee_credit: 8_091.20 + pension_credit: 8_091.20 + esa_income_eligible: false + esa_income: 0 + +- name: Case 24, the man of case 21 with £2,000 of guarantee credit as an input keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + guarantee_credit: 2_000 + households: + household: + members: [person1] + output: + # pension_credit pays the entitlement only on a claim, so he gets no + # Pension Credit, and the model pays the award. + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index e8d9ddb554..3822c0a271 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -1,22 +1,24 @@ -"""Income-related ESA gives way to State Pension Credit only where Pension Credit pays. +"""The model refuses a couple income-related ESA only where the couple would +get State Pension Credit without it. -Welfare Reform Act 2007 s.1(3)(c) bars ESA for a claimant who has reached -pensionable age, their own State Pension age (s.1(6)). Sch 1 para 6(1)(c) and -(d) bar the income-related allowance where the claimant, or the other member -of the couple, is entitled to State Pension Credit. Pension Credit reads the -couple's award, so the model reads the Pension Credit the couple would get with -no income-related ESA in the family, B(0), and bars the couple's claim where it -pays. +Parliament bars ESA for a claimant who has reached pensionable age, their own +State Pension age (Welfare Reform Act 2007 s.1(3)(c) and s.1(6)), and bars the +income-related allowance where the claimant, or the other member of the +couple, has an entitlement to State Pension Credit (Sch 1 para 6(1)(c) and +(d)). The model's Pension Credit reads the couple's award, so the model reads +the Pension Credit the couple would get with no income-related ESA in the +family, B(0), and refuses the couple's claim where B(0) exceeds nil. Each example builds a second, separate simulation of the same people with no income-related ESA reported. Its pension_credit gives B(0) without a branch. Against it, for every family drawn here: -- where B(0) pays nothing, whether the couple would claim Pension Credit - changes neither the couple's income-related ESA - (claimant_or_partner_esa_income) nor the family's (esa_income); -- where B(0) pays, the couple would claim it and has an open report, the - couple's income-related ESA is nil and the family's Pension Credit is B(0); +- where B(0) is nil, whether the couple would claim Pension Credit changes + neither the couple's income-related ESA (claimant_or_partner_esa_income) + nor the family's (esa_income); +- where B(0) exceeds nil, the couple would claim it and has an open report, + the couple's income-related ESA is nil and the family's Pension Credit is + B(0); - claiming Pension Credit never raises income-related ESA; - esa_income_eligible equals the family-by-family reading of the law in legacy_award_work_reference (esa_screen), given the model's pensionable age @@ -25,7 +27,7 @@ (is_on_income_related_esa), and a family whose every report comes from such a member gets none. -A family whose Pension Credit pays only with the award, through the higher +A family that gets Pension Credit only with the award, through the higher earnings disregard the award brings, gets both: no state is consistent there. The test counts such families and asserts nothing more of them. @@ -35,11 +37,11 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults have earnings, pensions and household savings, so Pension Credit's -means test pays some families and refuses others, and in 2016 and 2018 some -report working tax credit, whose income test the award lifts (TCA 2002 -s.7(2)). A member outside the couple claims in their own right: the couple's -Pension Credit does not bar them, and pensionable age does. +Adults have earnings, pensions and household savings, so some families pass +Pension Credit's means test and others fail it, and in 2016 and 2018 some +report working tax credit, which HMRC pays without its income test to a +family on the award (TCA 2002 s.7(2)). A member outside the couple claims in their own right: the couple's +Pension Credit leaves their claim alone, and their pensionable age does not. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one @@ -50,22 +52,40 @@ same simulation. Simulations take most of the time here, so each example builds two and the file builds few. -Separate tests count the branches the model opens: a population with no couple -that could be on Pension Credit opens none, a branch never outlives the -calculation, and the tax credit passport branch inside it reads the couple's -award as nil. +A differential test runs the drawn families through a reform that calculates +the model's Pension Credit by a formula of its own, so the screen reads +Pension Credit for every couple whose claim passes the other tests, and +requires the same results as the model, which reads it only for a couple +that meets the age conditions and would claim. + +Separate tests count the branches the model opens and the variables the +screen reads: a population with no couple that could be on Pension Credit +opens none, a branch never outlives the calculation, the tax credit passport +branch inside it reads the couple's award as nil, and the screen reads no +Pension Credit condition without a couple's claim that passes the other +tests. Others enter Pension Credit, or a part of it, as an input, or reform +it: an entered award, a reform that pays every family and a frozen award each +make the model read the award for every couple, and a parameter reform keeps +the age and take-up conditions. """ import numpy as np import pytest from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as period_ +from policyengine_core.reforms import Reform from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_uk import Simulation +from policyengine_uk.model_api import GBP, YEAR, BenUnit, Variable from policyengine_uk.tests.legacy_award_work_reference import esa_screen from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( NO_INCOME_RELATED_ESA_BRANCH, + pension_credit_follows_model_formulas, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit as model_pension_credit, ) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -159,10 +179,11 @@ def couple_reports(sim, year): return sim.populations["benunit"].sum(open_report & couple) > 0 -# Each pairing of B(0) and B(e) in 2016, for a man aged 63 born 1 March 1953 -# (esa_income_pension_age.yaml cases 15, 16 and 18), the couple of case 19 -# whose working tax credit decides it, and a member outside the couple -# reporting beside a pensioner on Pension Credit. +# Each pairing of B(0) and B(e) in 2016, for a man aged 63 and six months, whom +# the model takes to be born on 6 April 1953, with the earnings, pensions and +# awards of esa_income_pension_age.yaml cases 15, 16 and 18; the couple of case +# 19, whose working tax credit decides the pairing; and a member outside the +# couple reporting beside a pensioner on Pension Credit. MAN = { "age": 63, "months_since_last_birthday": 6, @@ -483,3 +504,334 @@ def test_the_branch_closes_and_its_passport_branch_reads_no_award(monkeypatch): assert not [ name for name in sim.branches if name.startswith(NO_INCOME_RELATED_ESA_BRANCH) ] + + +# The man of esa_income_pension_age.yaml cases 7 and 8, born 1 March 1953: in +# 2016 he meets the Pension Credit age conditions, under his pensionable age. +# A man aged 40 does not meet them. +MAN_63 = { + "age": 63, + "date_of_birth": 19530301, + "gender": "MALE", + "is_claimant_or_partner": True, + "esa_income_reported": 5_000, +} +MAN_40 = dict(MAN_63, age=40, date_of_birth=19760301) +ADULT = {"is_claimant_or_partner": True} + + +def no_branch_opens_beside(log): + return log.bar_branches() == [] + + +def test_an_entered_pension_credit_award_bars_the_award_whatever_the_age_and_take_up(): + """Three men in 2016 each have £2,000 of Pension Credit as an input: the + man aged 63 who would claim Pension Credit, the same man who would not, + and a man aged 40. Each has an entitlement to that Pension Credit (Sch 1 + para 6(1)(c)), so none gets income-related ESA (esa_income_pension_age.yaml + case 21).""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=True, pension_credit=2_000), + household([MAN_63], year, would_claim_pc=False, pension_credit=2_000), + household([MAN_40], year, pension_credit=2_000), + ) + ) + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + True, + False, + ] + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert sim.calculate("claimant_or_partner_esa_income", year).tolist() == [0] * 3 + + +class flat_pension_credit(Reform): + """Pension Credit of £2,000 for every benefit unit.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return benunit.filled_array(2_000) + + self.update_variable(pension_credit) + + +def test_a_reform_that_pays_pension_credit_to_all_bars_the_award(monkeypatch): + """Under a reform that pays every benefit unit £2,000 of Pension Credit, + the man aged 63 who would claim it, the same man who would not and a man + aged 40 each have an entitlement to it, so none gets income-related ESA, + and the model reads that Pension Credit for all three.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + ), + reform=flat_pension_credit, + ) + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert log.bar_branches() + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class pension_credit_by_another_formula(Reform): + """The model's Pension Credit, calculated by a formula of the reform's.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return model_pension_credit.formula(benunit, period, parameters) + + self.update_variable(pension_credit) + + +OUTPUTS = [ + "esa_income", + "claimant_or_partner_esa_income", + "esa_income_eligible", + "pension_credit", + "working_tax_credit", + "household_net_income", +] + + +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(examples()) +@example((ROWS, 2016)) +def test_reading_pension_credit_for_every_couple_matches_the_model(drawn): + """Differential: a reform that calculates the model's Pension Credit by a + formula of its own makes the screen read Pension Credit without the award + for every couple whose claim passes the other tests. It must give every + family the same ESA, Pension Credit and income as the model, which reads it + only for a couple that meets the age conditions and would claim.""" + units, year = drawn + event(f"year {year}") + people = situation(units, year, [True, False]) + model = Simulation(situation=people) + reformed = Simulation(situation=people, reform=pension_credit_by_another_formula) + parameters = model.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + model.populations["benunit"], period_(year), parameters + ) + assert not pension_credit_follows_model_formulas( + reformed.populations["benunit"], period_(year), parameters + ) + for variable in OUTPUTS: + assert np.allclose( + model.calculate(variable, year), + reformed.calculate(variable, year), + atol=0.01, + ), (variable, units) + + +def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): + """A reform that lifts the exclusion of mixed-age couples in 2026 keeps + the model's formulas, and meets_pension_credit_age_conditions reads its + parameters. A couple aged 70 and 60 without the saving then meets the + age conditions, so the model reads its Pension Credit and refuses the + younger member's award. With only a working-age claimant, no couple meets + them, and the model opens no branch, under the reform or without it.""" + log = BranchLog(monkeypatch) + year = 2026 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": False + } + } + working_age = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + mixed = household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ) + + alone = Simulation(situation=combine(working_age), reform=reform) + assert pension_credit_follows_model_formulas( + alone.populations["benunit"], period_(year), alone.tax_benefit_system.parameters + ) + assert alone.calculate("esa_income", year).tolist() == [5_000] + assert no_branch_opens_beside(log) + + baseline = Simulation(situation=combine(working_age, mixed)) + assert baseline.calculate("esa_income", year).tolist() == [5_000, 5_000] + assert baseline.calculate("pension_credit", year).tolist() == [0, 0] + assert no_branch_opens_beside(log) + + lifted = Simulation(situation=combine(working_age, mixed), reform=reform) + assert lifted.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + False, + True, + ] + # The couple's minimum guarantee for 52 weeks in 2026-27: 52 x 363.25. + assert lifted.calculate("pension_credit", year) == pytest.approx( + [0, 18_889], abs=0.01 + ) + assert lifted.calculate("esa_income", year).tolist() == [5_000, 0] + assert log.bar_branches() + + +def test_a_frozen_pension_credit_bars_the_award_whatever_the_reform_age_conditions(): + """In 2018 a couple aged 70 and 60 meets the Pension Credit age + conditions, so gets Pension Credit and no income-related ESA. A reform + that brings the exclusion of mixed-age couples forward to 2018 and freezes + Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) + still pays that award, though the couple no longer meets the reform's age + conditions, so the couple still gets no income-related ESA.""" + year = 2018 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": True + }, + "gov.contrib.freeze_pension_credit": {f"{year}-01-01.{year}-12-31": True}, + } + sim = Simulation( + situation=household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ), + reform=reform, + ) + assert not sim.calculate("meets_pension_credit_age_conditions", year)[0] + assert sim.baseline.calculate("esa_income", year).tolist() == [0] + # The couple's minimum guarantee for 52 weeks in 2018-19: 52 x 248.80. + frozen = sim.baseline.calculate("pension_credit", year) + assert frozen == pytest.approx([12_937.60], abs=0.01) + assert sim.calculate("pension_credit", year) == pytest.approx(frozen, abs=0.01) + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + assert sim.calculate("esa_income", year).tolist() == [0] + + +def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_conditions(): + """An input for pension_credit, its entitlement, its eligibility or either + credit can pay Pension Credit outside the age and take-up conditions, so + each makes the model read Pension Credit for every couple.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + parameters = sim.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + for variable, value in [ + ("pension_credit", 2_000), + ("pension_credit_entitlement", 2_000), + ("is_pension_credit_eligible", True), + ("guarantee_credit", 2_000), + ("savings_credit", 2_000), + ]: + branch = sim.get_branch(f"entered_{variable}") + try: + branch.set_input(variable, year, np.array([value])) + assert not pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), parameters + ), variable + # An input for another year leaves this year to the formulas. + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year + 1), parameters + ), variable + finally: + sim.branches.pop(f"entered_{variable}", None) + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + + +class ScreenReads: + """Records each variable the income-related ESA screen reads.""" + + def __init__(self, monkeypatch): + self.variables = [] + calculate = CoreSimulation.calculate + + def logged(simulation, variable_name, *args, **kwargs): + stack = simulation.tracer.stack + if stack and stack[-1]["name"] == "esa_income_eligible": + self.variables.append(variable_name) + return calculate(simulation, variable_name, *args, **kwargs) + + monkeypatch.setattr(CoreSimulation, "calculate", logged) + + +PENSION_CREDIT_CONDITIONS = {"meets_pension_credit_age_conditions", "would_claim_pc"} + + +def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( + monkeypatch, +): + """In 2026 no couple here has a claim that passes the age, work and + capital tests: a pensioner reporting the award, a claimant aged 45 in + remunerative work, and a member outside the couple aged 40 reporting + beside a pensioner, whose own claim passes. The screen then reads neither + the Pension Credit age conditions nor the claim. A claimant aged 45 out of + work passes, and the screen reads both.""" + reads = ScreenReads(monkeypatch) + year = 2026 + no_couple_claim = [ + household([dict(ADULT, age=75, esa_income_reported=5_000)], year), + household( + [ + dict( + ADULT, + age=45, + esa_income_reported=5_000, + employment_income=15_000, + hours_worked=1_560, + ) + ], + year, + ), + household( + [ + dict(ADULT, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 5_000, + }, + ], + year, + ), + ] + sim = Simulation(situation=combine(*no_couple_claim)) + assert sim.calculate("esa_income_eligible", year).tolist() == [False, False, True] + assert "esa_income_assessable_capital" in reads.variables + assert not PENSION_CREDIT_CONDITIONS & set(reads.variables) + + reads.variables.clear() + out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) + assert sim.calculate("esa_income_eligible", year).tolist() == [ + False, + False, + True, + True, + ] + assert PENSION_CREDIT_CONDITIONS <= set(reads.variables) diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index 86734bb1fb..f5cd9310c2 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -79,15 +79,16 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen - # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension Credit - # and it pays. No one here has earnings, and in 2025 no one has tax - # credits, so Pension Credit pays the same with the couple's award as - # without. It never bars an outside member's claim. - on_pension_credit = ( - benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - & (benunit("pension_credit", period) > 0) - ) + if is_esa: + # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension + # Credit and it pays. No one here has earnings, and in 2025 no one has + # tax credits, so Pension Credit pays the same with the couple's award + # as without. An outside member keeps their own claim whatever it pays. + on_pension_credit = ( + benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + & (benunit("pension_credit", period) > 0) + ) def passes(members, unit, couple): if not is_esa: diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 38897f81bf..6c292bbacd 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -63,13 +63,17 @@ "extended_childcare_entitlement_term_share", "extended_childcare_entitlement_total_per_child", "gas_consumption_kwh", + "guarantee_credit", "housing_benefit_applicable_income", "housing_benefit_applicable_income_childcare_element", "housing_benefit_applicable_income_disregard", "housing_benefit_childcare_earnings_limit", "housing_benefit_specified_or_temporary_accommodation_disregard", "in_HE", + "is_pension_credit_eligible", "other_local_bus_trips", + "pension_credit", + "pension_credit_entitlement", "pip_dl_category", "pip_dl_points", "pip_m_category", @@ -80,6 +84,7 @@ "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "ni_epg_full_rate_applies", + "savings_credit", "tenure_type", "targeted_childcare_entitlement", "trading_loss", @@ -200,6 +205,27 @@ def stored_as_input(population, variable_name: str, period: Period) -> bool: ) +def replaces_formula( + population, variable_name: str, period: Period, default_formula +) -> bool: + """Whether the simulation gives ``variable_name`` for ``period`` a value + other than what ``default_formula`` calculates: a reform neutralizes the + variable, removes its formula for the period or gives it another, or a + caller set an input for the period (``stored_as_input``), as a scenario + does with its own value (``set_model_value``). A cached formula result is + not an input, even in populations taken from another simulation or in + their clones and branches.""" + variable = population.simulation.tax_benefit_system.get_variable(variable_name) + formula = variable.get_formula(period) + if ( + variable.is_neutralized + or formula is None + or formula.__code__ != default_formula.__code__ + ): + return True + return stored_as_input(population, variable_name, period) + + def supplied_input( population, variable_name: str, period: Period ) -> Optional[np.ndarray]: diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 4a3e156fcd..d4b600e741 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,41 +1,108 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import replaces_formula, stored_as_input +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_entitlement import ( + pension_credit_entitlement, +) +from policyengine_uk.variables.gov.dwp.pension_credit.savings_credit.savings_credit import ( + savings_credit, +) # The branch in which the family has no income-related ESA. NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" +# The Pension Credit variables whose formulas the model's age and take-up +# conditions govern, with pension_credit_entitlement, which sums two of them. +PENSION_CREDIT_FORMULAS = [ + pension_credit, + is_pension_credit_eligible, + guarantee_credit, + savings_credit, +] + + +def pension_credit_follows_model_formulas(benunit, period, parameters): + """Whether the simulation calculates Pension Credit with the model's own + formulas, which pay it only to a family that meets the Pension Credit age + conditions (meets_pension_credit_age_conditions) and would claim + (would_claim_pc). + + pension_credit equals the entitlement where the family would claim and + nil otherwise. The entitlement sums the guarantee and savings credits for + a family eligible for Pension Credit (is_pension_credit_eligible), and + nil for any other, and only a family that meets the age conditions is + eligible. A parameter reform keeps these formulas, and the age conditions + read its parameters. + + A caller can instead set pension_credit, its entitlement, its eligibility + or either credit as an input for the period. A reform can replace or + remove one of their formulas, change what the entitlement sums or which + families it covers, or freeze Pension Credit at the baseline's award + (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. + Pension Credit can then pay a family that does not meet the age + conditions or would not claim. + """ + simulation = benunit.simulation + if ( + parameters(period).gov.contrib.freeze_pension_credit + and simulation.baseline is not None + ): + return False + for default in PENSION_CREDIT_FORMULAS: + if replaces_formula(benunit, default.__name__, period, default.formula): + return False + name = pension_credit_entitlement.__name__ + entitlement = simulation.tax_benefit_system.get_variable(name) + return ( + not entitlement.is_neutralized + and entitlement.get_formula(period) is None + and entitlement.adds == pension_credit_entitlement.adds + and entitlement.defined_for == pension_credit_entitlement.defined_for + and not stored_as_input(benunit, name, period) + ) + def pension_credit_without_income_related_esa(benunit, period): """The Pension Credit the family would get with no income-related ESA. - Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d) bar the couple's - income-related ESA while the claimant, or the other member of the couple, - has an entitlement to State Pension Credit, and Pension Credit reads that - award. - Its higher earnings disregard counts the couple's award - (claimant_or_partner_esa_income; SPC Regs 2002 Sch VI para - 4(1)(a)(vii)). Working tax credit counts as Pension Credit income, and + Parliament bars the couple's income-related ESA while the claimant, or + the other member of the couple, has an entitlement to State Pension + Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), and the + model's Pension Credit reads that award. Its higher earnings disregard + counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 + Sch VI para 4(1)(a)(vii)). It counts working tax credit as income, and the tax credit income test reads the award (TCA 2002 s.7(2)), as does - Income Support's gate, which that test reads. Pension Credit's route test + Income Support's gate, which that test reads. Its route test (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income - (has_own_esa_award). Reading Pension Credit in the simulation itself - would close a cycle. + (has_own_esa_award). A formula that read Pension Credit in the + simulation itself would close a cycle. - This calculates Pension Credit in a branch that sets esa_income, the - couple's award and esa_income_eligible to nil, so the screen never runs - there. The branch reads pension_credit, so the claim (would_claim_pc) - and an entered award count. Where it pays, the bar holds: the screen - refuses the couple's award, and the simulation's own Pension Credit then - equals the branch's. Where it pays nothing, the award stands. If the - award then makes Pension Credit pay, through the higher earnings - disregard, no consistent state exists: Pension Credit would end the - award, which would end Pension Credit. The award then stands beside the - Pension Credit the model works out on it. + This function calculates Pension Credit in a branch that sets + esa_income, the couple's award and esa_income_eligible to nil, so the + screen never runs there. The branch reads pension_credit, so it counts + the claim (would_claim_pc) and an entered award. Where that Pension + Credit pays, the screen refuses the couple's award, and the + simulation's own Pension Credit then equals the branch's. Where it pays + nothing, the model pays the award. If the award then makes Pension + Credit pay, through the higher earnings disregard, no consistent state + exists: with Pension Credit in payment the family could not have the + award, and without the award it would get no Pension Credit. The model + then pays the award beside the Pension Credit it works out on it. - In a year with tax credits, this branch reaches the passport branch - (pension_credit_with_passported_tax_credits), and the passport branch - reaches this one. A branch reads the inputs its parents set, so a branch - of either keeps both. + In a year with tax credits, a calculation in this branch opens the + passport branch (pension_credit_with_passported_tax_credits), and one in + the passport branch opens this one. Core reads a branch's inputs from + the branches it was made from as well, so a branch of either keeps both + sets of inputs. """ simulation = benunit.simulation # get_branch returns an existing branch of that name, or this simulation @@ -69,40 +136,43 @@ class esa_income_eligible(Variable): "otherwise other open reporters are candidates. The claimant must not have " "reached pensionable age (Welfare Reform Act 2007 s.1(3)(c)): their own State " "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " - "Neither the claimant nor the other member of the couple may be entitled to " - "State Pension Credit (Sch 1 para 6(1)(c) and (d)). Pension Credit reads the " - "couple's award, so the screen reads the Pension Credit the couple would get " - "with no income-related ESA in the family " - "(pension_credit_without_income_related_esa), which counts the claim " - "(would_claim_pc) and an entered award, and bars the couple's claim where it " - "pays more than nil. It works that out only for a population in which some " - "couple's claim passes the other tests and the couple meets the Pension " - "Credit age conditions (meets_pension_credit_age_conditions) and would claim, " - "and bars only such a couple. Where Pension Credit pays nothing without the " - "award but pays with " - "it, through the higher earnings disregard the award brings (SPC Regs 2002 " + "Neither the claimant nor the other member of the couple may have an " + "entitlement to State Pension Credit (Sch 1 para 6(1)(c) and (d)). The " + "model's Pension Credit reads the couple's award, so the screen reads the " + "Pension Credit the couple would get with no income-related ESA in the " + "family (pension_credit_without_income_related_esa), which counts the claim " + "(would_claim_pc) and an entered award, and refuses the couple's claim where " + "it exceeds nil. The screen works that out for each couple whose claim " + "passes the other tests, and only where some couple's claim does. Where the " + "model's own formulas calculate Pension Credit " + "(pension_credit_follows_model_formulas), they pay it only to a couple that " + "meets the Pension Credit age conditions (meets_pension_credit_age_conditions) " + "and would claim, so the screen works it out only for such a couple. Where " + "the family gets no Pension Credit without the award but gets it with the " + "award, through the higher earnings disregard the award brings (SPC Regs 2002 " "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " - "both. The couple's Pension Credit bars only the couple's claim, not that of " - "a member who claims in their own right. The claimant must not engage in " - "remunerative work (para 6(1)(e); esa_income_claimant_remunerative_work). The " - "other member of the couple must not engage in remunerative work (para " - "6(1)(f)): paid work of 24 hours a week or more (ESA Regs 2008 reg 42(1)), " - "subject to the existing carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B " - "para 4), which the model reads as is_carer_for_benefits. Assessable capital " - "must remain within the existing limit (para 6(1)(b); reg 110). The model " - "applies neither para 6(1)(g) (receiving education) nor the other member's " - "income-based JSA under para 6(1)(d). The Welfare Reform Act (Northern " - "Ireland) 2007 sets the same conditions in s.1(3)(c) and Sch 1 para 6(1)(c) " - "and (d), but its s.1(6) takes pensionable age from the Pensions (Northern " - "Ireland) Order 1995 Sch 2 para 1, not the Pensions Act 1995 Sch 4 para 1. " - "The two give the same pensionable age for every date of birth, so one rule " - "covers the UK. The aggregate esa_income uses this screen for its original " - "couple's part; each outside reporter's claim is paid separately on their own " - "existing work/capital screen, pensionable age and tariff. The benefit unit's " - "capital is an explicit proxy for unobserved outside-claim capital. Reports " - "whose own award is closed are excluded before choosing candidates; ESA also " - "excludes the couple's reports on a UC claim. This variable's candidate rules " - "are unchanged by the separate aggregation." + "both. A member who claims in their own right keeps their claim whatever the " + "couple's Pension Credit. The claimant must not engage in remunerative work " + "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " + "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " + "24 hours a week or more (ESA Regs 2008 reg 42(1)), subject to the existing " + "carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " + "reads as is_carer_for_benefits. Assessable capital must remain within the " + "existing limit (para 6(1)(b); reg 110). The model applies neither para " + "6(1)(g) (receiving education) nor the other member's income-based JSA under " + "para 6(1)(d). The Northern Ireland Assembly set the same conditions in the " + "Welfare Reform Act (Northern Ireland) 2007 s.1(3)(c) and Sch 1 para 6(1)(c) " + "and (d), but took pensionable age in s.1(6) from the Pensions (Northern " + "Ireland) Order 1995 Sch 2 para 1, where Parliament took it from the Pensions " + "Act 1995 Sch 4 para 1. Under either schedule a person born on a given date " + "reaches pensionable age on the same day, so the model applies one rule " + "across the UK. The aggregate esa_income uses this screen for its original " + "couple's part and pays each outside reporter's claim separately, on their " + "own existing work/capital screen, pensionable age and tariff. The screen " + "uses the benefit unit's capital as an explicit proxy for unobserved " + "outside-claim capital. It drops reports whose own award has closed before it " + "chooses candidates, and ESA also drops the couple's reports on a UC claim. " + "The separate aggregation leaves this variable's candidate rules unchanged." ) definition_period = YEAR reference = ( @@ -134,9 +204,9 @@ def formula(benunit, period, parameters): candidate = reports_award & ( claimant_or_partner | ~benunit.project(couple_reports) ) - # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age", - # which s.1(6) takes from Pensions Act 1995 Sch 4 para 1, so a man - # born before 6 December 1953 reaches it at 65. + # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age" + # (s.1(6): the age in Pensions Act 1995 Sch 4 para 1). A man born + # before 6 December 1953 reaches it at 65. pensionable_age = person("is_SP_age", period) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) @@ -153,22 +223,28 @@ def formula(benunit, period, parameters): ) passes = candidate & ~pensionable_age & ~claimant_works & ~other_member_works within_capital = capital <= ESA.capital.limit - # Sch 1 para 6(1)(c) and (d): the claimant is not entitled to State - # Pension Credit, nor is the other member of the couple. Pension - # Credit reads the couple's award, so this reads the couple's Pension - # Credit without it, for a couple whose claim passes the other tests and - # that meets the Pension Credit age conditions and would claim, and - # only where some couple does. The couple's Pension Credit does not - # bar a member who claims in their own right. - could_be_on_pension_credit = ( - benunit.any(passes & claimant_or_partner) - & within_capital - & benunit("meets_pension_credit_age_conditions", period) - & benunit("would_claim_pc", period) - ) - on_pension_credit = could_be_on_pension_credit - if could_be_on_pension_credit.any(): - on_pension_credit = could_be_on_pension_credit & ( + couple_passes = benunit.any(passes & claimant_or_partner) & within_capital + if not couple_passes.any(): + return benunit.any(passes) & within_capital + # Sch 1 para 6(1)(c) and (d): Parliament allows the income-related + # allowance only where neither the claimant nor the other member of + # the couple has an entitlement to State Pension Credit. The model's + # Pension Credit reads the couple's award, so this reads the couple's + # Pension Credit without it, for each couple whose claim passes the + # other tests. The model's own formulas pay Pension Credit only to a + # couple that meets the age conditions and would claim, so where they + # calculate it, this reads it only for a couple that does, and only + # where some couple does. A member who claims in their own right keeps + # their claim whatever the couple's Pension Credit. + on_pension_credit = couple_passes + if pension_credit_follows_model_formulas(benunit, period, parameters): + on_pension_credit = ( + couple_passes + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + ) + if on_pension_credit.any(): + on_pension_credit = on_pension_credit & ( pension_credit_without_income_related_esa(benunit, period) > 0 ) pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py index 7f29a3b4cc..b474522ada 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py @@ -1,27 +1,14 @@ from policyengine_uk.model_api import * from policyengine_core.parameters import ParameterNode from policyengine_uk.utils.excise import fiscal_year_segments -from policyengine_uk.utils.supplied_inputs import stored_as_input +from policyengine_uk.utils.supplied_inputs import replaces_formula def hb_uses_annual_override(benunit, period, variable_name, default_formula): """Keep annual inputs and replacement formulas out of recalculation.""" - simulation = benunit.simulation - variable = simulation.tax_benefit_system.get_variable(variable_name) - formula = variable.get_formula(period) - if ( - variable.is_neutralized - or formula is None - or formula.__code__ != default_formula.__code__ - ): - return True - # An input the caller set for this period overrides recalculation, and so - # does a scenario's own value (set_model_value), which the variable then - # reads; a cached formula result does not, even in populations taken from - # another simulation or in their clones and branches. - # SUPPLIED_INPUT_VARIABLES lists each name passed here, as stored_as_input + # SUPPLIED_INPUT_VARIABLES lists each name passed here, as replaces_formula # and set_model_value require. - return stored_as_input(benunit, variable_name, period) + return replaces_formula(benunit, variable_name, period, default_formula) def hb_accommodation_segments(benunit, period, parameters): From 998486f4dca07e9777ab10a417eeeef18da03aa2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:28:45 -0400 Subject: [PATCH 040/168] Allow the allocation search's tie tolerance in the dense-grid property test Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_income_tax_allocation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 21923ce248..8da0f18a56 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -318,5 +318,7 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) step = ((high - low) / 100 + c.savings / 100)[:, 0] - assert np.all(found <= grid + 1e-6) - assert np.all(found >= grid - 2 * step - 1e-6) + # The search may take a share within TIE (1e-6) of the least, for its + # tie-break. + assert np.all(found <= grid + 1e-5) + assert np.all(found >= grid - 2 * step - 1e-5) From d4441835a7d0fa80cd285c631d3448229ff27b76 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:06:57 -0400 Subject: [PATCH 041/168] Leave Housing Benefit's override helper as it is on main The open fix-2730-hb-wtc-disregard branch rewrites hb_uses_annual_override, so delegating it to replaces_formula here would conflict with that branch. replaces_formula makes the same test for the ESA screen. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/supplied_inputs.py | 3 ++- ...ed_or_temporary_accommodation_disregard.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 6c292bbacd..74ca59f443 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -214,7 +214,8 @@ def replaces_formula( caller set an input for the period (``stored_as_input``), as a scenario does with its own value (``set_model_value``). A cached formula result is not an input, even in populations taken from another simulation or in - their clones and branches.""" + their clones and branches. Housing Benefit's hb_uses_annual_override + makes the same test for its annual amounts.""" variable = population.simulation.tax_benefit_system.get_variable(variable_name) formula = variable.get_formula(period) if ( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py index b474522ada..7f29a3b4cc 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py @@ -1,14 +1,27 @@ from policyengine_uk.model_api import * from policyengine_core.parameters import ParameterNode from policyengine_uk.utils.excise import fiscal_year_segments -from policyengine_uk.utils.supplied_inputs import replaces_formula +from policyengine_uk.utils.supplied_inputs import stored_as_input def hb_uses_annual_override(benunit, period, variable_name, default_formula): """Keep annual inputs and replacement formulas out of recalculation.""" - # SUPPLIED_INPUT_VARIABLES lists each name passed here, as replaces_formula + simulation = benunit.simulation + variable = simulation.tax_benefit_system.get_variable(variable_name) + formula = variable.get_formula(period) + if ( + variable.is_neutralized + or formula is None + or formula.__code__ != default_formula.__code__ + ): + return True + # An input the caller set for this period overrides recalculation, and so + # does a scenario's own value (set_model_value), which the variable then + # reads; a cached formula result does not, even in populations taken from + # another simulation or in their clones and branches. + # SUPPLIED_INPUT_VARIABLES lists each name passed here, as stored_as_input # and set_model_value require. - return replaces_formula(benunit, variable_name, period, default_formula) + return stored_as_input(benunit, variable_name, period) def hb_accommodation_segments(benunit, period, parameters): From da6d9add345c60febcadaab9b07fb45980f32cd1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:12:40 -0400 Subject: [PATCH 042/168] Name the claimant or body that acts in the ESA bar's comments Comments and docstrings no longer make a statute bar, exclude or lift: the claimant fails or meets a condition, DWP disregards earnings and HMRC pays working tax credit. The changelog fragment covers entered and reformed Pension Credit. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2565.fixed.md | 2 +- .../esa_income/esa_income_pension_age.yaml | 75 +++++++++---------- .../test_esa_income_pension_age_properties.py | 4 +- .../variables/gov/dwp/_legacy_award_payee.py | 9 ++- .../variables/gov/dwp/esa_income.py | 4 +- .../gov/dwp/is_on_income_related_esa.py | 5 +- 6 files changed, 50 insertions(+), 49 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index c99d5ac52c..c686c53a85 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to the claimant or partner of a couple whose State Pension Credit without that award pays more than nil (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index c612100677..a5a1bbc8ae 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -2,17 +2,17 @@ # Welfare Reform Act 2007 s.1(3)(c): a claimant who has reached pensionable # age is not entitled to ESA. Pensionable age is the person's own State # Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). -# Sch 1 para 6(1)(c) and (d): the income-related allowance needs that the -# claimant "is not entitled to state pension credit" and "is not a member of a -# couple the other member of which is entitled to ... state pension credit". -# The model reads the Pension Credit the couple would get with no -# income-related ESA in the family, B(0), and bars the couple's claim where it -# pays. B(e) is the Pension Credit the family would get with the award e. -# A mixed-age couple is not entitled to State Pension Credit from 15 May 2019 -# (State Pension Credit Act 2002 s.4(1A)) unless the SI 2019/37 art 4 saving -# applies. -# Everyone here is a legacy claimant (would_claim_uc false), since a Universal -# Credit claim ends the couple's income-related ESA anyway. No case has +# Sch 1 para 6(1)(c) and (d): a claimant gets the income-related allowance +# only if the claimant "is not entitled to state pension credit" and "is not a +# member of a couple the other member of which is entitled to ... state +# pension credit". The model reads the Pension Credit the couple would get +# with no income-related ESA in the family, B(0), and refuses the couple's +# claim where B(0) exceeds nil. B(e) is the Pension Credit the family would +# get with the award e. A mixed-age couple has no entitlement to State Pension +# Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it +# keeps the SI 2019/37 art 4 saving. +# Everyone here is a legacy claimant (would_claim_uc false), since a couple +# that claims Universal Credit loses its income-related ESA anyway. No case has # capital, so no tariff income: an award that passes is the amount reported. # Pension Credit with no income is the guarantee credit's standard minimum # guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and @@ -41,7 +41,7 @@ output: # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 6). is_SP_age: true - # s.1(3)(c) bars the claim: 0, not the 5_000 reported. + # The claimant fails s.1(3)(c): 0, not the 5_000 reported. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -73,8 +73,7 @@ # age 66 (rule 6). is_SP_age: [true, true] meets_pension_credit_age_conditions: true - # s.1(3)(c) bars the 68-year-old's claim, and para 6(1)(c) and (d) bar it - # too. + # The 68-year-old fails s.1(3)(c), and para 6(1)(c) and (d) too. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -110,10 +109,9 @@ # qualifying age on 6 January 2016 (Pensions Act 1995 Sch 4 para 1 rule 3, # table 1, via SPCA 2002 s.1(6)), so the couple could be entitled to # Pension Credit on 14 May 2019, and it reports Pension Credit now, so - # s.4(1A) does not exclude it. + # it keeps Pension Credit despite s.4(1A). has_mixed_age_couple_pension_credit_saving: true - # The 60-year-old has not reached pensionable age, so s.1(3)(c) does not - # bar them. + # The 60-year-old has not reached pensionable age, so meets s.1(3)(c). is_SP_age: [true, false] meets_pension_credit_age_conditions: true # Para 6(1)(d): B(0) = 52 x 363.25 = 18_889 is payable, so the other @@ -150,12 +148,13 @@ members: [person1, person2] output: # Neither member reports Pension Credit or pension-age Housing Benefit, - # so the couple keeps no saving, and SPCA 2002 s.4(1A) excludes the - # 73-year-old while the partner is under the qualifying age. + # so the couple keeps no saving, and the 73-year-old has no entitlement + # to Pension Credit while the partner is under the qualifying age (SPCA + # 2002 s.4(1A)). has_mixed_age_couple_pension_credit_saving: false meets_pension_credit_age_conditions: false pension_credit: 0 - # Neither para 6(1)(d) nor s.1(3)(c) bars the 60-year-old: the 5_000. + # The 60-year-old meets both para 6(1)(d) and s.1(3)(c): the 5_000. esa_income_eligible: true esa_income: 5_000 claimant_or_partner_esa_income: 5_000 @@ -188,7 +187,7 @@ has_mixed_age_couple_pension_credit_saving: true meets_pension_credit_age_conditions: true # Entitlement to Pension Credit needs a claim (Social Security - # Administration Act 1992 s.1), so para 6(1)(d) does not bar the award. + # Administration Act 1992 s.1), so the 60-year-old meets para 6(1)(d). pension_credit: 0 esa_income_eligible: true esa_income: 5_000 @@ -240,8 +239,8 @@ members: [person1] output: # Born 1 March 1953, before 6 December 1953: pensionable age 65, on 1 - # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so s.1(3)(c) does - # not bar him in 2016-17. + # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so he meets + # s.1(3)(c) in 2016-17. is_SP_age: false # The qualifying age for State Pension Credit is a woman's pensionable # age for the same date of birth (SPCA 2002 s.1(6)): a woman born from 6 @@ -359,7 +358,7 @@ household: members: [person1, person2] output: - # They claim in their own right, and s.1(3)(c) bars their claim too. + # They claim in their own right, and fail s.1(3)(c) too. esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -467,9 +466,9 @@ # Pension Credit (from 6 January 2016) and under his pensionable age (65, # on 1 March 2018), with support-group income-related ESA. In 2016-17 the # minimum guarantee is £155.60 a week, 52 x 155.60 = 8_091.20 a year (SPC -# Regs 2002 reg 6(1)(b)), and Pension Credit disregards £5 a week of a single -# claimant's earnings (Sch VI para 5), or £20 a week while the claimant is on -# employment and support allowance (para 4(1)(a)(vii)). Below 65 no savings +# Regs 2002 reg 6(1)(b)), and DWP disregards £5 a week of a single claimant's +# earnings (Sch VI para 5), or £20 a week while the claimant is on employment +# and support allowance (para 4(1)(a)(vii)). Below 65 no savings # credit is due (SPCA 2002 s.3(1)). His income is below the personal # allowance (£11,000) and his earnings below the National Insurance primary # threshold (£155 a week), so neither takes anything off. @@ -500,8 +499,8 @@ members: [person1] output: # B(0): income 50 + 40 - 5 = 85 a week, so 155.60 - 85 = 70.60 a week. - # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) pays, so para 6(1)(c) - # bars the award and Pension Credit is B(0): 52 x 70.60. + # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) exceeds nil, so he fails + # para 6(1)(c) and gets B(0): 52 x 70.60. esa_income_eligible: false esa_income: 0 pension_credit: 3_671.20 @@ -533,8 +532,8 @@ members: [person1] output: # B(0): 115.50 + 61 - 5 = 171.50 a week, over 155.60. B(e): 115.50 + 61 - # - 20 = 156.50, still over it. Pension Credit refuses him either way, so - # para 6(1)(c) does not bar the award: 52 x 94.60 = 4_919.20. + # - 20 = 156.50, still over it. He gets no Pension Credit either way, so + # meets para 6(1)(c) and keeps the award: 52 x 94.60 = 4_919.20. pension_credit: 0 esa_income_eligible: true esa_income: 4_919.20 @@ -635,16 +634,16 @@ # Working tax credit: the basic and couple elements, 1_960 + 2_010 = # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts # as Pension Credit income (SPCA 2002 s.15(1)(b)). - # B(e): the award lifts the tax credit income test (TCA 2002 s.7(2)), so - # working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over + # B(e): with the award HMRC pays working tax credit without its income + # test (TCA 2002 s.7(2)), so working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the # £212.97 couple threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - - # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) pays, so para - # 6(1)(d) bars the award, and the couple gets B(0). + # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) exceeds nil, so + # the younger member fails para 6(1)(d), and the couple gets B(0). esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -684,8 +683,8 @@ # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so # nil either way. (Each member's income is under the £11,850 personal # allowance, and £124 a week is under the £162 National Insurance primary - # threshold, so neither takes anything off.) Para 6(1)(d) does not bar the - # award. + # threshold, so neither takes anything off.) The younger member meets + # para 6(1)(d). pension_credit: 0 esa_income: 2_537.60 claimant_or_partner_esa_income: 2_537.60 @@ -700,7 +699,7 @@ # Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model # then reads the award it pays without the couple's income-related ESA, # whatever the Pension Credit age conditions and the claim -# (would_claim_pc), since the input, not the formulas, sets the award. The +# (would_claim_pc), since the caller, not the formulas, sets the award. The # man of cases 7 and 8 meets the age conditions; a man aged 40, born 1 March # 1976, does not. diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 3822c0a271..9a5fbfd4da 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -424,8 +424,8 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( meets the Pension Credit age conditions: a working-age claimant reporting the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside - a pensioner, and a pensioner reporting the award, whose claim pensionable - age bars whatever Pension Credit pays.""" + a pensioner, and a pensioner reporting the award, who has reached + pensionable age and so has no claim whatever Pension Credit pays.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 35f5fdc5d6..e476acb154 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -45,10 +45,11 @@ def own_report_is_paid(person, period, award, barred, capital_limit): "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. - Their claim is screened on them alone. No condition of their own may bar - it (barred): they must not be engaged in remunerative work as its - claimant, and, for income-related ESA, must not have reached pensionable - age (Welfare Reform Act 2007 s.1(3)(c)). The award on their report alone + The model screens their claim on them alone. They must meet each + condition of their own (barred is true where they fail one): they must + not engage in remunerative work as its claimant, and, for income-related + ESA, must not have reached pensionable age (Welfare Reform Act 2007 + s.1(3)(c)). The award on their report alone must be positive: the report exceeds the tariff income from the benefit unit's capital, within the capital limit (capital_limit). Their own capital is not observed, so the benefit unit's stands in for it. diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index fe14c79598..d29c1f3a5e 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -15,8 +15,8 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) The claimant and partner share one claim. Other reporters have separate claims, each using the existing own-work and shared-capital/tariff proxy, - and barred once the reporter reaches pensionable age (Welfare Reform Act - 2007 s.1(3)(c)). + and the model pays none once the reporter reaches pensionable age + (Welfare Reform Act 2007 s.1(3)(c)). Selecting members changes only reports, never the original couple or the work/capital screen. This helper must not read payable-person status, which itself depends on the aggregate award. diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index 2a52e7d81c..5a52150937 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -49,7 +49,7 @@ class is_on_income_related_esa(Variable): def formula(person, period, parameters): # WRA 2007 s.1(3)(c): a member who has reached pensionable age cannot - # be the claimant, so the award is not payable to them. + # be the claimant, so the model pays them none of the award. pensionable_age = person("is_SP_age", period) couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 @@ -57,7 +57,8 @@ def formula(person, period, parameters): person, period, "esa_income_reported", can_claim=~pensionable_age ) # Their own claim, on their own report, work and age alone (WRA 2007 - # s.1(3)(c): a claimant who has reached pensionable age is not entitled). + # s.1(3)(c): a claimant who has reached pensionable age has no + # entitlement). own_award = own_report_is_paid( person, period, From 2ee68e799d9250a4b1e67df904e9134d796b64f2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:22:15 -0400 Subject: [PATCH 043/168] Count only the screen's reads in the simulation itself The branch that works out Pension Credit without employment income gives a claimant in paid work no earnings, so the screen can read the Pension Credit conditions there for a family whose claim fails the work test in the simulation itself. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 9a5fbfd4da..25e49e679d 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -764,7 +764,10 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition class ScreenReads: - """Records each variable the income-related ESA screen reads.""" + """Records each variable the income-related ESA screen reads in a + simulation itself. A branch can change the family: the one that works out + Pension Credit without employment income gives a claimant in paid work no + earnings, so their claim can pass the work test there.""" def __init__(self, monkeypatch): self.variables = [] @@ -772,7 +775,11 @@ def __init__(self, monkeypatch): def logged(simulation, variable_name, *args, **kwargs): stack = simulation.tracer.stack - if stack and stack[-1]["name"] == "esa_income_eligible": + if ( + simulation.branch_name == "default" + and stack + and stack[-1]["name"] == "esa_income_eligible" + ): self.variables.append(variable_name) return calculate(simulation, variable_name, *args, **kwargs) From cef744bc2cc7d3da868f11ddae22ffaaebfb52aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:34:34 -0400 Subject: [PATCH 044/168] Count the screen's reads for the test year in the simulation itself The model also runs the screen for 2027 to 2032 in that population, where the situation enters no earnings or hours, so the claimant in paid work can pass the work test there. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 25e49e679d..67e808d202 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -764,27 +764,36 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition class ScreenReads: - """Records each variable the income-related ESA screen reads in a - simulation itself. A branch can change the family: the one that works out - Pension Credit without employment income gives a claimant in paid work no - earnings, so their claim can pass the work test there.""" + """Records each variable the income-related ESA screen reads, with the + branch and year it reads in. A branch can change the family: the one that + works out Pension Credit without employment income gives a claimant in + paid work no earnings, so their claim can pass the work test there. So + can a later year: the model also runs the screen for 2027 to 2032 here, + and the situation enters earnings and hours for 2026 only.""" def __init__(self, monkeypatch): - self.variables = [] + self.reads = [] calculate = CoreSimulation.calculate def logged(simulation, variable_name, *args, **kwargs): stack = simulation.tracer.stack - if ( - simulation.branch_name == "default" - and stack - and stack[-1]["name"] == "esa_income_eligible" - ): - self.variables.append(variable_name) + if stack and stack[-1]["name"] == "esa_income_eligible": + frame = stack[-1] + self.reads.append( + (simulation.branch_name, str(frame["period"]), variable_name) + ) return calculate(simulation, variable_name, *args, **kwargs) monkeypatch.setattr(CoreSimulation, "calculate", logged) + def variables(self, year): + """What the screen read in the simulation itself, for year.""" + return { + variable + for branch, period, variable in self.reads + if branch == "default" and period == str(year) + } + PENSION_CREDIT_CONDITIONS = {"meets_pension_credit_age_conditions", "would_claim_pc"} @@ -829,10 +838,10 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( ] sim = Simulation(situation=combine(*no_couple_claim)) assert sim.calculate("esa_income_eligible", year).tolist() == [False, False, True] - assert "esa_income_assessable_capital" in reads.variables - assert not PENSION_CREDIT_CONDITIONS & set(reads.variables) + assert "esa_income_assessable_capital" in reads.variables(year) + assert not PENSION_CREDIT_CONDITIONS & reads.variables(year), reads.reads - reads.variables.clear() + reads.reads.clear() out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) assert sim.calculate("esa_income_eligible", year).tolist() == [ @@ -841,4 +850,4 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( True, True, ] - assert PENSION_CREDIT_CONDITIONS <= set(reads.variables) + assert PENSION_CREDIT_CONDITIONS <= reads.variables(year) From 60de3303b49d2c16b28303a92b1392f9c3e3fc87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:43:00 -0400 Subject: [PATCH 045/168] Skip the least-tax search for people who cannot gain from another share The search now runs only on people that three tests leave: one share exists (nothing to share, or no income left after the deductions); the old order sets every shared deduction against the first component and leaves no savings or dividends charged above the nil rates; or the rate on the first component just above where the old order leaves it covers the highest savings and dividend rates the moved deductions can reach. Each test gives the result the search would, and a property test checks that on synthetic people, with rising and with falling rates. The search evaluates its candidates only on the people left. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_tax_allocation.py | 223 ++++++++++++ .../utils/income_tax_allocation.py | 333 ++++++++++++++---- 2 files changed, 494 insertions(+), 62 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 8da0f18a56..27118b5bc9 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -18,6 +18,9 @@ share (a differential test of two implementations of Step 5). 6. The vertex search finds the least value of that function, checked against a dense grid. +7. Fast path: for everyone the tests in _old_order_is_least let through, the + result equals the full search's, bit for bit, and in current law those + tests let through everyone with one kind of income. """ import numpy as np @@ -27,10 +30,14 @@ from policyengine_uk import Simulation from policyengine_uk.utils.income_tax_allocation import ( + _build, _context, _least_tax_share, + _old_order_is_least, _shares, default_order_deductions, + least_tax_deductions, + old_order, step_5_tax, ) @@ -322,3 +329,219 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # tie-break. assert np.all(found <= grid + 1e-5) assert np.all(found >= grid - 2 * step - 1e-5) + + +# The fast path, checked on many synthetic people at once without a simulation: +# the inputs the search reads, drawn freely, and the old order computed from +# them as the model does, in float32. +SYNTHETIC_YEARS = tuple(range(2016, 2031)) +SYNTHETIC_PEOPLE = 2_000 +LIMITS = [500, 1_000, 5_000, 6_000, 12_570, 17_570, 37_700, 50_270, 100_000, 125_140] +# Curated people at the edges of each test: other non-savings income, property, +# savings, dividends, trade loss relief, allowances beyond the Personal +# Allowance, Marriage Allowance received, grossed-up Gift Aid, Scottish, and +# personal savings allowance. +EDGES = [ + # Pay and savings across the basic rate limit: the search moves £270. + (50_000, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Pay and dividends across the basic rate limit. + (50_000, 0, 0, 20_000, 0, 0, 0, 0, False, 500), + # Pay that ends exactly at the basic rate limit, and a pound below it. + (50_270, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + (50_269, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Savings and dividends with no other income. + (0, 0, 4_000, 20_000, 0, 0, 0, 0, False, 1_000), + # Marriage Allowance moves the savings below the basic rate limit. + (51_000, 0, 10_000, 0, 0, 0, 1_260, 0, False, 500), + (49_000, 0, 0, 20_000, 0, 0, 1_260, 0, False, 500), + # Savings exactly at the savings allowance, dividends at the allowance. + (30_000, 0, 1_000, 500, 0, 0, 0, 0, False, 1_000), + (30_000, 0, 1_001, 501, 0, 0, 0, 0, False, 1_000), + # Deductions that just fill the first component. + (12_570, 0, 3_000, 0, 0, 0, 0, 0, False, 1_000), + (12_570, 0, 3_000, 2_000, 0, 0, 0, 0, False, 1_000), + # A pensioner whose savings fill the starting rate band. + (14_000, 0, 8_000, 0, 0, 0, 0, 0, False, 1_000), + # Scottish pay in the starter, basic and intermediate bands. + (14_000, 0, 7_000, 0, 0, 0, 0, 0, True, 1_000), + (25_000, 0, 2_000, 3_000, 0, 0, 0, 0, True, 1_000), + (40_000, 0, 0, 5_000, 0, 0, 0, 0, True, 500), + (43_000, 0, 6_000, 0, 0, 0, 0, 0, True, 500), + # Property with other income, savings and dividends. + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, False, 500), + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, True, 500), + (0, 20_000, 0, 200_000, 1_000, 0, 0, 0, False, 0), + (45_000, 20_000, 3_000, 0, 0, 0, 1_260, 0, False, 500), + # Gift Aid raises the limits. + (40_000, 0, 3_000, 8_000, 0, 0, 0, 2_000, False, 1_000), + (52_000, 0, 9_000, 0, 0, 0, 0, 2_000, False, 500), + # Pension relief and a trading loss. + (60_000, 0, 9_000, 4_000, 3_000, 10_000, 0, 0, False, 500), + (110_000, 0, 20_000, 30_000, 0, 0, 0, 0, False, 0), +] + + +@pytest.fixture(scope="module") +def system(): + import policyengine_uk.simulation as uk_simulation + + return uk_simulation.CountryTaxBenefitSystem() + + +def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free"): + """Inputs for ``least_tax_deductions`` and the old order's deductions. + + "free" draws every amount from a wide mixture with values at the limits; + "realistic" gives most people pay alone and some savings or dividends.""" + rng = np.random.default_rng(seed) + + def amount(zero, scale): + rest = 1 - zero + kind = rng.choice(4, count, p=[zero, 0.3 * rest, 0.4 * rest, 0.3 * rest]) + limits = rng.choice(LIMITS, count) + rng.uniform(-300, 300, count) + values = np.select( + [kind == 0, kind == 1, kind == 2], + [0, rng.uniform(0, 2_000, count), rng.uniform(0, scale, count)], + limits, + ) + return np.maximum(0, values) + + if mix == "free": + other = amount(0.2, 200_000) + property_income = amount(0.6, 60_000) + savings = amount(0.4, 150_000) + dividends = amount(0.5, 250_000) + else: + other = rng.lognormal(10.2, 0.6, count) + property_income = np.where( + rng.random(count) < 0.05, rng.uniform(0, 20_000, count), 0 + ) + savings = np.where(rng.random(count) < 0.25, rng.lognormal(5, 1.5, count), 0) + dividends = np.where(rng.random(count) < 0.08, rng.lognormal(7, 1.5, count), 0) + income = other + property_income + savings + dividends + relief = np.where( + rng.random(count) < 0.1, np.minimum(rng.uniform(0, 30_000, count), income), 0 + ) + extra = np.where(rng.random(count) < 0.2, rng.uniform(0, 15_000, count), 0) + married = rng.random(count) < 0.1 + gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) + scottish = rng.random(count) < 0.3 + savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) + rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) + other, property_income, savings, dividends, relief, extra, marriage, gift = ( + np.concatenate([column, rows[:, i]]) + for i, column in enumerate( + [ + other, + property_income, + savings, + dividends, + relief, + extra, + married * 1_260.0, + gift, + ] + ) + ) + scottish = np.concatenate([scottish, rows[:, 8] > 0]) + savings_allowance = np.concatenate([savings_allowance, rows[:, 9]]) + f32 = np.float32 + before_relief = (other + property_income + savings + dividends).astype(f32) + relief = relief.astype(f32) + net_income = np.maximum(0, before_relief - relief) + personal_allowance = np.maximum(0, 12_570 - np.maximum(0, net_income - 100_000) / 2) + allowances = (personal_allowance + extra).astype(f32) + savings, dividends = savings.astype(f32), dividends.astype(f32) + default = old_order( + allowances, savings, dividends, net_income, relief, before_relief + ) + inputs = dict( + savings=savings, + dividends=dividends, + relief=relief, + net_income=net_income, + property=property_income.astype(f32), + allowances=allowances, + earned_exclusions=marriage.astype(f32), + gift=gift.astype(f32), + scottish=scottish, + savings_allowance=savings_allowance.astype(f32), + ) + inputs = { + name: values if name == "scottish" else values.astype(np.float64) + for name, values in inputs.items() + } + return inputs, default + + +def assert_fast_path_equals_search(income_tax, inputs, default): + fast = least_tax_deductions(inputs, income_tax, default.savings, default.dividends) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + for fast_part, full_part in zip(fast, full): + mismatch = np.flatnonzero(fast_part != full_part) + assert not len(mismatch), { + name: values[mismatch[:5]] for name, values in inputs.items() + } + + +@settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2026, 0) +@example(2027, 0) +@example(2028, 1) +def test_fast_path_equals_full_search(system, year, seed): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(seed) + # Invariant 7: the people the tests let through get the search's result. + assert_fast_path_equals_search(income_tax, inputs, default) + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_lets_most_realistic_people_through(system, year): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(2106, count=20_000, mix="realistic") + c = _build(inputs, income_tax) + through = _old_order_is_least( + c, default.savings[:, None], default.dividends[:, None] + )[:, 0] + # With one kind of income nothing can move (invariant 4), so all of + # those go through; and few of the rest need the search. + one_kind = (inputs["savings"] == 0) & (inputs["dividends"] == 0) + assert through[one_kind].all() + assert (~through).mean() < 0.1, (~through).mean() + + +# Rates that fall with income break the arguments of tests 2 and 3, so the +# tests must then send everyone with something to share to the search. +FALLING_RATES = { + "gov.hmrc.income_tax.rates.scotland.rates[3].rate": 0.05, + "gov.hmrc.income_tax.rates.uk[1].rate": 0.1, + "gov.hmrc.income_tax.rates.dividends[1].rate": 0.05, +} + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_equals_full_search_when_rates_fall(system, year): + parameters = system.parameters + old = {} + try: + for path, value in FALLING_RATES.items(): + parameter = parameters.get_child(path) + old[path] = parameter(str(year)) + parameter.update(period=str(year), value=value) + system.reset_parameter_caches() + income_tax = parameters(str(year)).gov.hmrc.income_tax + for seed in range(3): + inputs, default = synthetic_people(seed) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + for path, value in old.items(): + parameters.get_child(path).update(period=str(year), value=value) + system.reset_parameter_caches() diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 61f3238422..bdf23a1d57 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -60,6 +60,42 @@ every candidate with the full tax, so with Marriage Allowance it takes the best of them. In every case it keeps the order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. + +Who cannot gain +--------------- +Most people cannot gain from another share, and ``_old_order_is_least`` finds +them without a search, so the search runs only on the rest. + +1. One share: with no deduction left for s. 25(2) to place, or no income left + after the deductions, only one share exists, and the old order takes it. + +Tests 2 and 3 need the rates to rise with income in every scale, as Parliament +has set them, and the old order to set every shared deduction against the +first component, so that any other share leaves more of it (``x`` rises). + +2. Nothing taxed above the first component: if the old order leaves no savings + and no dividends charged above the nil rates, any other share charges them + at least nil, and tax on the first component rises with ``x``. So no share + gives less tax. +3. Rates: moving a pound of the deductions to savings or dividends raises + ``x`` by that pound and ``w`` by at most a pound. Write ``rho_n`` for the + rate on the first component just above where the old order leaves it, + ``rho_s`` for the savings rate just above the top of the savings, and + ``sigma_s`` and ``sigma_v`` for the highest savings and dividend rates the + moved pounds can reach above the nil rates. The tax then rises by at least + ``(rho_n - sigma_s) dx + (rho_s - sigma_v) dw`` for any move with + ``0 <= dw <= dx``, or ``rho_n dx - sigma_v dw`` where the old order charges + no savings above the nil rates. Where that is never negative, the old order + gives the least tax. The top of the nil rates moves by at most ``dx``, + because the starting rate band shrinks by at most a pound for each pound of + non-savings income (s. 12) and the savings and dividend nil rates cover + fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no + Marriage Allowance or no property income: with property income, the + model's Marriage Allowance breaks the rising rate on the first component. + +Where any test holds, the search would keep the old order too, since it +changes the share only for a saving above ``TOLERANCE``. So the result is the +same, and ``test_income_tax_allocation.py`` checks that it is. """ from types import SimpleNamespace @@ -103,39 +139,57 @@ def _tax(income, schedule): return sum(step * np.maximum(income - t, 0) for t, step in zip(thresholds, steps)) -def _context(person, period, parameters): - """Everything the tax on a share depends on, as float64 columns.""" - income_tax = parameters(period).gov.hmrc.income_tax - rates = income_tax.rates +def _inputs(person, period, income_tax): + """The amounts the tax on a share depends on, one float64 value a person.""" - def column(values): - return np.asarray(values, dtype=np.float64)[:, None] + def values(variable): + return np.asarray(person(variable, period), dtype=np.float64) - savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) - dividends = np.maximum(0, column(person("taxable_dividend_income", period))) - relief = column(person("trade_loss_relief_against_general_income", period)) - # net_income, not its components, so a supplied adjusted net income or a - # reform that sets net income is followed, as the default order does. - non_savings = np.maximum( - 0, column(person("net_income", period)) + relief - savings - dividends - ) - property_income = np.minimum( - np.maximum(0, column(person("taxable_property_income", period))), non_savings - ) - deductions = relief + column(person("allowances", period)) - used = np.minimum(deductions, non_savings + savings + dividends) exclusions = [ variable for variable in income_tax.earned_taxable_income_exclusions if variable not in ALLOCATED_EXCLUSIONS ] - earned_exclusions = sum( - (column(person(variable, period)) for variable in exclusions), - np.zeros_like(savings), + savings = values("taxable_savings_interest_income") + return dict( + savings=savings, + dividends=values("taxable_dividend_income"), + relief=values("trade_loss_relief_against_general_income"), + net_income=values("net_income"), + property=values("taxable_property_income"), + allowances=values("allowances"), + earned_exclusions=sum( + (values(variable) for variable in exclusions), np.zeros_like(savings) + ), + gift=values("gift_aid_grossed_up"), + scottish=np.asarray(person("pays_scottish_income_tax", period)), + savings_allowance=values("savings_allowance"), ) - gift = column(person("gift_aid_grossed_up", period)) - scottish = np.asarray(person("pays_scottish_income_tax", period))[:, None] + + +def _context(person, period, parameters): + """Everything the tax on a share depends on, as float64 columns.""" + income_tax = parameters(period).gov.hmrc.income_tax + return _build(_inputs(person, period, income_tax), income_tax) + + +def _build(inputs, income_tax): + """The context for the people in ``inputs`` (see ``_inputs``).""" + rates = income_tax.rates + columns = {name: values[:, None] for name, values in inputs.items()} + savings = np.maximum(0, columns["savings"]) + dividends = np.maximum(0, columns["dividends"]) + relief = columns["relief"] + # net_income, not its components, so a supplied adjusted net income or a + # reform that sets net income is followed, as the default order does. + non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) + property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) + deductions = relief + columns["allowances"] + used = np.minimum(deductions, non_savings + savings + dividends) + gift = columns["gift"] uk_thresholds = rates.uk.thresholds + savings_rates = rates.savings + property_rates = rates.property c = SimpleNamespace( other_first=bool(income_tax.reliefs.other_income_first), savings=savings, @@ -143,27 +197,40 @@ def column(values): non_savings=non_savings, property=property_income, used=used, - earned_exclusions=earned_exclusions, + earned_exclusions=columns["earned_exclusions"], gift=gift, - scottish=scottish, + scottish=columns["scottish"], uk=_schedule(rates.uk, gift), + uk_rates=list(rates.uk.rates), scotland=_schedule( rates.scotland.rates, gift, first_extended_scottish_threshold(rates.scotland.rates), len(rates.scotland.rates.thresholds), ), + scotland_rates=list(rates.scotland.rates.rates), dividend_scale=_schedule(rates.dividends, gift), + dividend_rates=list(rates.dividends.rates), bands=( uk_thresholds[0], uk_thresholds[1] + gift, uk_thresholds[2] + gift, ), - savings_rates=rates.savings, - property_rates=rates.property, + savings_rates=savings_rates, + savings_rate_list=[ + savings_rates.basic, + savings_rates.higher, + savings_rates.additional, + ], + property_rates=property_rates, + property_rate_list=[ + property_rates.basic, + property_rates.higher, + property_rates.additional, + ], starting_limit=rates.savings_starter_rate.allowance, phase_out=rates.savings_starter_rate.phase_out, - savings_allowance=column(person("savings_allowance", period)), + savings_allowance=columns["savings_allowance"], dividend_allowance=income_tax.allowances.dividend_allowance, ) if c.other_first: @@ -175,7 +242,7 @@ def column(values): c.shared = used - to_other # The savings stack on the other non-savings income left, as earned # taxable income has it, plus the property income left. - c.offset = np.maximum(0, c.other_left - earned_exclusions) + c.offset = np.maximum(0, c.other_left - c.earned_exclusions) else: c.first = non_savings c.shared = used @@ -188,16 +255,38 @@ def _non_savings_tax(c, income): return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) -def step_5_tax(c, x, s, v): - """Step 5 Income Tax on the share leaving ``x`` of the first component, - ``s`` of savings and ``v`` of dividends, as the model's - income_tax_pre_charges computes it from the variables the share sets.""" +def _split(c, x): + """The other non-savings income and the property income left, and the + earned taxable income, where the share leaves ``x`` of the first + component.""" if c.other_first: other, property_left = c.other_left, x else: property_left = np.minimum(x, c.property) other = x - property_left - earned = np.maximum(0, other - c.earned_exclusions) + return other, property_left, np.maximum(0, other - c.earned_exclusions) + + +def _savings_band(c, below, s): + """The top of the savings and the top of the part of them that the + starting rate (s. 12) and the savings nil rate (s. 12A) cover, for ``s`` + of savings above ``below`` of non-savings income.""" + low, basic_limit, _ = c.bands + top = below + s + in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) + starting = np.clip( + np.minimum(in_basic_band, s), + 0, + np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), + ) + return top, below + np.minimum(s, starting + c.savings_allowance) + + +def step_5_tax(c, x, s, v): + """Step 5 Income Tax on the share leaving ``x`` of the first component, + ``s`` of savings and ``v`` of dividends, as the model's + income_tax_pre_charges computes it from the variables the share sets.""" + other, property_left, earned = _split(c, x) earned_tax = _non_savings_tax(c, earned) if c.other_first: # property_income_tax.formula_2027: Scottish rates in Scotland, @@ -220,16 +309,8 @@ def step_5_tax(c, x, s, v): _non_savings_tax(c, other) ) # Savings and dividends sit on non_savings_taxable_income. - below = earned + property_left + top, start = _savings_band(c, earned + property_left, s) low, basic_limit, higher_limit = c.bands - top = below + s - in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) - starting = np.clip( - np.minimum(in_basic_band, s), - 0, - np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), - ) - start = below + np.minimum(s, starting + c.savings_allowance) rates = c.savings_rates savings_tax = ( rates.basic @@ -361,18 +442,23 @@ def _least_tax_share(c): def default_order_deductions(person, period): """The deductions from non-savings, savings and dividend income in the order non-savings, savings, dividends, computed as the model always has.""" - allowances = person("allowances", period) - savings = person("taxable_savings_interest_income", period) - dividends = person("taxable_dividend_income", period) - non_savings = person("net_income", period) - savings - dividends - relief = person("trade_loss_relief_against_general_income", period) + return old_order( + person("allowances", period), + person("taxable_savings_interest_income", period), + person("taxable_dividend_income", period), + person("net_income", period), + person("trade_loss_relief_against_general_income", period), + person("net_income_before_trade_loss_relief", period), + ) + + +def old_order( + allowances, savings, dividends, net_income, relief, net_income_before_relief +): + """``default_order_deductions`` from the amounts it reads.""" + non_savings = net_income - savings - dividends relief_left = np.maximum( - 0, - relief - - np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - savings - dividends, - ), + 0, relief - np.maximum(0, net_income_before_relief - savings - dividends) ) to_non_savings = np.where( non_savings < allowances, np.maximum(0, non_savings), allowances @@ -391,25 +477,148 @@ def default_order_deductions(person, period): ) +def _rate(thresholds, rates, income, above=True): + """The rate a scale charges just above ``income`` (or just below it).""" + rate = np.zeros(np.shape(income)) + for threshold, value in zip(thresholds, rates): + rate = np.where( + income >= threshold if above else income > threshold, value, rate + ) + return rate + + +def _rising(rates): + return rates[0] >= 0 and all(b >= a for a, b in zip(rates, rates[1:])) + + +def _old_order_is_least(c, to_savings, to_dividends): + """People for whom no share gives less Step 5 tax than the old order, by + the tests the module docstring sets out. ``to_savings`` and + ``to_dividends`` are the old order's deductions from savings and + dividends.""" + in_first = (to_savings == 0) & (to_dividends == 0) + # Test 1: nothing to share, or nothing left after the deductions, so only + # one share exists, and the old order takes it. + nothing_to_share = (in_first & (c.shared <= 0)) | ( + (to_savings == c.savings) + & (to_dividends == c.dividends) + & (c.step_3_income <= TIE) + ) + low, basic_limit, higher_limit = c.bands + if not ( + all( + _rising(rates) + for rates in ( + c.uk_rates, + c.scotland_rates, + c.dividend_rates, + c.savings_rate_list, + c.property_rate_list, + ) + ) + and low <= 0 + and c.phase_out >= 0 + ): + return nothing_to_share + in_first &= c.shared <= c.first + x = c.first - c.shared + other, property_left, earned = _split(c, x) + top, start = _savings_band(c, earned + property_left, c.savings) + savings_taxed = top > start + nil_rated = ~savings_taxed & (c.dividends <= c.dividend_allowance) + # Test 3. The first component's rate just above x. + if c.other_first: + _, basic_upper, higher_upper = c.bands + rho_n = np.where( + c.scottish, + _rate(c.scotland[0], c.scotland_rates, other + x), + _rate( + (-np.inf, basic_upper, higher_upper), c.property_rate_list, other + x + ), + ) + in_reach = in_first + else: + # With no property income, Marriage Allowance only moves every band + # position down by its amount. + position = x - c.earned_exclusions + rho_n = np.where( + c.scottish, + _rate(c.scotland[0], c.scotland_rates, position), + _rate(c.uk[0], c.uk_rates, position), + ) + in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) + savings_scale = ((low, basic_limit, higher_limit), c.savings_rate_list) + # dx <= c.shared and dw <= min(dx, dividends). The top of the nil rates + # moves up by at most dx and never passes the top of the savings. + dw = np.minimum(c.shared, c.dividends) + sigma_s = _rate(*savings_scale, np.minimum(start + c.shared, top + dw), above=False) + rho_s = _rate(*savings_scale, top) + # The dividends end at the top of income, which no share moves. + sigma_v = np.where( + c.dividends > c.dividend_allowance, + _rate( + c.dividend_scale[0], + c.dividend_rates, + top + np.minimum(c.dividend_allowance + dw, c.dividends), + above=False, + ), + 0, + ) + rates_hold = np.where( + savings_taxed, + (rho_n >= sigma_s) & (rho_n - sigma_s + rho_s - sigma_v >= 0), + rho_n >= sigma_v, + ) + return nothing_to_share | (in_first & nil_rated) | (in_reach & rates_hold) + + def allocate_deductions(person, period, parameters): """Deductions against savings and dividend income in the share that gives the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less tax than the order non-savings, savings, dividends, these are that order's deductions exactly.""" default = default_order_deductions(person, period) - c = _context(person, period, parameters) + income_tax = parameters(period).gov.hmrc.income_tax + savings, dividends = least_tax_deductions( + _inputs(person, period, income_tax), + income_tax, + default.savings, + default.dividends, + ) + return SimpleNamespace(savings=savings, dividends=dividends) + + +def least_tax_deductions( + inputs, income_tax, to_savings, to_dividends, search_all=False +): + """The deductions from savings and dividends: the old order's + (``to_savings`` and ``to_dividends``) unless another share saves more than + TOLERANCE. The search runs only on people the tests in + ``_old_order_is_least`` leave, or on everyone with ``search_all``.""" + to_savings = np.asarray(to_savings, dtype=np.float64) + to_dividends = np.asarray(to_dividends, dtype=np.float64) + if search_all: + search = np.ones(len(to_savings), dtype=bool) + else: + search = ~_old_order_is_least( + _build(inputs, income_tax), to_savings[:, None], to_dividends[:, None] + )[:, 0] + if not search.any(): + return to_savings, to_dividends + c = _build({name: values[search] for name, values in inputs.items()}, income_tax) x, s, v = _least_tax_share(c) - default_s = c.savings - np.asarray(default.savings, dtype=np.float64)[:, None] - default_v = c.dividends - np.asarray(default.dividends, dtype=np.float64)[:, None] + default_s = c.savings - to_savings[search][:, None] + default_v = c.dividends - to_dividends[search][:, None] default_x = c.step_3_income - default_s - default_v lower = ( step_5_tax(c, x, s, v) < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE )[:, 0] - return SimpleNamespace( - savings=np.where(lower, (c.savings - s)[:, 0], default.savings), - dividends=np.where(lower, (c.dividends - v)[:, 0], default.dividends), - ) + moved = np.flatnonzero(search)[lower] + to_savings, to_dividends = to_savings.copy(), to_dividends.copy() + to_savings[moved] = (c.savings - s)[lower, 0] + to_dividends[moved] = (c.dividends - v)[lower, 0] + return to_savings, to_dividends def non_savings_deductions(person, period): From 48f57f977b451993e99f63813027f137cd9b46ea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:26:19 -0500 Subject: [PATCH 046/168] Test units that cancel within a household, and narrow the finite claims Add a regression for changes that cancel between units, group the held-heads assertion as guard.py computes it, and shorten the changelog and README text. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2773.fixed.md | 2 +- .../test_aggregate_impact_guard_baseline.py | 54 +++++++++++++++++-- .../utils/aggregate_impact/README.md | 12 ++--- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/changelog.d/2773.fixed.md b/changelog.d/2773.fixed.md index 5162a01ceb..0692a444a9 100644 --- a/changelog.d/2773.fixed.md +++ b/changelog.d/2773.fixed.md @@ -1 +1 @@ -The aggregate checker no longer stops with an error, or fails a component that ignores it, when a head, a model total or a component is NaN or infinite at baseline: it names each such variable once in a blocking finite: check, holds such a head by its own formula, judges every household where that formula gives back its baseline, and counts NaN as unchanged only where it was NaN before. +The aggregate checker reports non-finite baseline values as blocking failures and continues checking readable households. diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py index dbe22e38ec..01027184cf 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py @@ -6,8 +6,8 @@ not read once, as a failure of a ``finite:`` check; it fails no component for it, never gives the engine NaN, and judges every other reading as before. ``counted_once_base_reference`` holds the check as it stood before: every -model whose heads and totals are finite at baseline must publish exactly what -it published. +model whose heads, totals and checked components are finite at baseline must +publish exactly what it published. """ import json @@ -364,6 +364,51 @@ def test_a_held_head_that_moves_leaves_only_those_households_unjudged(): assert row["heads_not_held"] == {} and row["heads_held_skipped"] +@pytest.mark.parametrize( + "entity, people", + [ + ("person", [(0, 0), (1, 1), (1, 1)]), + ("benunit", [(0, 0), (1, 1), (2, 1)]), + ], +) +def test_units_that_cancel_within_a_household_still_count_as_moved(entity, people): + """tax_n, a head of people or benefit units, is NaN at baseline in the + first household. In the second it moves with tax_b, up in one unit and + down in the other, so the household's sum stays the same. The head still + moved, so the second household's failures stay and name tax_n.""" + formulas = { + "tax_b": lambda f: np.ones(2) * 5, + "tax_d": lambda f: 0.2 * f("tax_b"), + "tax_a": lambda f: 0.5 * f("tax_d"), + "tax_n": lambda f: np.array([np.nan, f("tax_b")[1], -f("tax_b")[1]]), + "gov_tax": lambda f: ( + f("tax_b") + + f("tax_d") + + f("tax_a") + + sim.map_result(f("tax_n"), entity, "household") + ), + } + sim = EntitySim(formulas, {"x": (1, 1)}, (1, 1), {"tax_n": entity}, people) + part = partition( + ["tax_b", "tax_d", "tax_a", "tax_n"], + reads={"tax_d": ["tax_b"]}, + entities={"tax_n": entity}, + ) + with np.errstate(all="ignore"): + result = guard.counted_once( + sim, + part, + YEAR, + 1e-6, + guard.snapshot_inputs(sim, part.component_variables()), + variables_to_check=("tax_b",), + ) + problems = by_variable(result)["tax_b"]["problems"] + assert {p["head"]: p["households"] for p in problems} == {"tax_a": 1, "tax_d": 1} + assert all(p["heads_not_held_at_baseline"] == ["tax_n"] for p in problems) + assert result["reset_reproduces_baseline"] + + def test_a_problem_names_heads_the_check_could_not_hold_for_either_reason(): """One household: ben_c turns NaN once tax_b rises, and tax_n, NaN at baseline, turns 0. tax_a's failure names both, each with its reason.""" @@ -634,8 +679,9 @@ def test_heads_not_finite_at_baseline_are_named_and_change_nothing_else(case, re assert all(p["head"] not in bad for p in row["problems"]) if row["variable"] in bad: assert row["status"] != "fail" - assert row["heads_held_skipped"] == bool(row["heads_not_held"]) or any( - p.get("heads_not_held_at_baseline") for p in row["problems"] + assert row["heads_held_skipped"] == ( + bool(row["heads_not_held"]) + or any(p.get("heads_not_held_at_baseline") for p in row["problems"]) ) assert result["reset_reproduces_baseline"] diff --git a/policyengine_uk/utils/aggregate_impact/README.md b/policyengine_uk/utils/aggregate_impact/README.md index e6cee0d732..f99b7bf2e9 100644 --- a/policyengine_uk/utils/aggregate_impact/README.md +++ b/policyengine_uk/utils/aggregate_impact/README.md @@ -124,12 +124,12 @@ The checks are: every other head held at baseline, and fails only where it still breaks it; a response carried by another head (Incapacity Benefit lowers Carer's Allowance, which Universal Credit counts as income) is reported as - mediated. A declared double count must still be contained in its head. A - head, total or component that is NaN or infinite at baseline fails its - `finite:` check (`finite:metrics` for a total or a double count), and the - counted-once check fails no component for it: it reads no change where - the value is not finite, and holds such a head by leaving it to its - formula, judging each household where the formula gives back its baseline; + mediated. A declared double count must still be contained in its head. The + check records a NaN or infinite baseline value under a blocking `finite:` + check (`finite:metrics` for a total or a double count) and continues + judging readable households. It holds such a head by leaving it to its + formula, and judges each household where the formula gives back the held + value; - **signs, bounds and finiteness**, and **determinism:** a rebuilt simulation reproduces the baseline exactly; - **inert heads:** a head that is zero for every unit is a warning, unless the From 8604456522ce12361a6339aa139e56826bae7de2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:03:15 -0500 Subject: [PATCH 047/168] Gate the carer element award on an upper bound and date Scotland's nil-award rule has_uc_award_with_carer_element now also needs a positive uc_award_upper_bound: the award before any non-dependant deduction, worked in the award's own order of operations so it never falls below universal_credit_pre_benefit_cap. A carer whose income tapers the award to nil has no award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and no longer bars the severe disability premium or the Pension Credit addition. Where universal_credit is stored or neutralized, the test reads the award itself (receives_uc_carer_element). Scotland's working-age carer premium counts a nil award with the carer element from 1 April 2022 to 31 March 2025, when SSI 2021/249 reg 4 defined an award of universal credit to include one with a value of zero (SSI 2022/52 reg 3), and reads the positive award from 1 April 2025 (SSI 2025/24 reg 8). The new parameter uc_award_includes_zero_value_award carries the dates. Co-Authored-By: Claude Opus 5.5 --- .../uc_award_includes_zero_value_award.yaml | 18 ++ .../council_tax_reduction_carer_premium.yaml | 123 ++++++++- .../uc_carer_element_carer_test.yaml | 142 +++++++++- .../test_uc_carer_element_award_properties.py | 243 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 1 + ...e_disability_minimum_guarantee_addition.py | 3 +- .../has_uc_award_with_carer_element.py | 59 +++-- .../universal_credit/uc_award_upper_bound.py | 97 +++++++ .../council_tax_reduction_carer_premium.py | 83 ++++-- .../household/demographic/carer_premium.py | 8 +- ...is_cared_for_by_carer_benefit_recipient.py | 16 +- .../pension_age_severe_disability_premium.py | 5 +- .../demographic/severe_disability_premium.py | 5 +- 13 files changed, 715 insertions(+), 88 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml new file mode 100644 index 0000000000..c9037e7852 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml @@ -0,0 +1,18 @@ +description: Scotland's working-age Council Tax Reduction scheme treats an award of Universal Credit with a value of zero as an award of Universal Credit if this is true, so a nil award that includes the carer element meets the carer premium's caring condition. +values: + 2013-04-01: false + 2022-04-01: true + 2025-04-01: false +metadata: + unit: bool + period: year + label: Scottish working-age Council Tax Reduction counts a Universal Credit award with a value of zero + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 4 ("award of universal credit" includes a universal credit award with a value of zero), as in force to 31 March 2025 + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2024-04-30 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2022 (SSI 2022/52) reg. 3 (inserts that definition from 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/3/made + - title: The Council Tax Reduction (Miscellaneous Amendment) (Scotland) Regulations 2025 (SSI 2025/24) regs. 1 and 8 (substitute "means an award of universal credit made under Part 1 of the 2012 Act" from 1 April 2025) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/8/made + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 10A (awards reduced to nil by a sanction, and reclaims under the Claims and Payments Regulations 2013 reg. 32A, from 1 April 2025) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/10A diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 2e7a1bbaeb..788cbac06f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -7,13 +7,21 @@ # Benefit, English and Welsh schedules test entitlement to Carer's Allowance # or Carer Support Payment (HB Regs 2006 Sch 3 para 17; WSI 2013/3029 Sch 7 # para 14), as did Scotland's scheme for persons under pension age before -# 1 April 2022 (SSI 2012/303 Sch 1 para 14). +# 1 April 2022 (SSI 2012/303 Sch 1 para 14). From 1 April 2022 to 31 March +# 2025 an "award of universal credit" included "a universal credit award with +# a value of zero" (reg 4, as amended by SSI 2022/52 reg 3); from 1 April 2025 +# it "means an award of universal credit made under Part 1 of the 2012 Act" +# (SSI 2025/24 reg 8), and an income that tapers the award to nil leaves no +# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). # # Amounts: carer premium £48.15 a week from 1 April 2026 (SSI 2021/249 Sch 1 -# para 5(1), substituted by SSI 2026/56) and £38.85 in 2022-23; personal -# allowance for a single person aged 25 or over £95.55 (2026-27) and £77.00 -# (2022-23); Universal Credit carer element £209.34 a month (2026-27) and -# £168.81 (2022-23), standard allowance £424.90 and £334.91. +# para 5(1), substituted by SSI 2026/56), £46.40 in 2025-26, £45.60 in +# 2024-25 and £38.85 in 2022-23 (para 17, as substituted by SSI 2022/52 reg +# 25(c)(ii); para 5(1) kept £37.70 until SSI 2023/38 amended both from +# 1 April 2023); personal allowance for a single person aged 25 or over +# £95.55 (2026-27) and £77.00 (2022-23); Universal Credit carer element +# £209.34 a month (2026-27), £201.68 (2025-26), £198.31 (2024-25) and £168.81 +# (2022-23), standard allowance £424.90, £400.14, £393.45 and £334.91. - name: The issue's Scottish carer aged 40 with 35 hours and no Carer's Allowance claim gets the carer premium # Para 6(1)(c): the award includes the carer element, 209.34 x 12 = @@ -142,9 +150,10 @@ council_tax_reduction_applicable_amount: 95.55 * 52 - name: A Scottish carer whose income tapers the award to nil has no award and no carer premium - # With £20,000 of earnings the award is nil, so there is no award (Welfare - # Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and para 6(1)(c) does not - # apply, though the maximum amount still includes the carer element. + # With £20,000 of earnings the award is nil, so in 2026 there is no award + # (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and para 6(1)(c) + # does not apply, though the maximum amount still includes the carer + # element. period: 2026 absolute_error_margin: 0.01 input: @@ -168,6 +177,69 @@ receives_uc_carer_element: false council_tax_reduction_carer_premium: 0 +- name: In 2024 a Scottish carer's nil award still counts as an award with the carer element + # The same carer in 2024-25. The maximum amount is (393.45 + 198.31) x 12 + # = 7,101.12, and the taper on £20,000 of earnings less income tax and + # National Insurance takes more, so the award is nil. Until 31 March 2025 + # an "award of universal credit" included one "with a value of zero" (reg + # 4), so para 6(1)(c) applies: carer premium 45.60 x 52 = 2,371.20. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 198.31 * 12 + universal_credit: 0 + receives_uc_carer_element: false + is_entitled_to_carer_benefit: false + carer_premium: 0 + council_tax_reduction_carer_premium: 45.60 * 52 + +- name: From 1 April 2025 the same Scottish carer's nil award is no award + # The same carer in 2025-26. The maximum amount is (400.14 + 201.68) x 12 = + # 7,221.84, and the taper again takes more, so the award is nil. From 1 + # April 2025 an award of universal credit is one "made under Part 1 of the + # 2012 Act" (SSI 2025/24 reg 8), and an income that tapers it to nil leaves + # no award, so para 6(1)(c) does not apply: no carer premium. Neither of + # reg 10A's nil awards (a sanction, or a reclaim under the UC Claims and + # Payments Regs 2013 reg 32A) arises. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 201.68 * 12 + universal_credit: 0 + receives_uc_carer_element: false + is_entitled_to_carer_benefit: false + council_tax_reduction_carer_premium: 0 + - name: A Scottish carer with both Carer Support Payment and the carer element gets one premium # The premium is paid in respect of each person who qualifies (para 5(3)), # not for each award: 48.15 x 52 = 2,503.80, not twice that. @@ -247,3 +319,38 @@ council_tax_reduction_pensioner: true council_tax_reduction_carer_premium: 0 council_tax_reduction_premiums: 0 + +- name: A dependant's caring does not raise the Scottish carer premium + # Para 5(2) includes the premium where "an applicant or the applicant's + # partner" has caring responsibilities. The son receives Carer Support + # Payment for caring for his grandmother, but he is neither the applicant + # nor a partner, so his parent's family gets no carer premium (48.15 x 52 + # = 2,503.80 if his caring counted). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + son: + age: 17 + care_hours: 35 + would_claim_carers_allowance: true + grandmother: + age: 80 + aa_category: HIGHER + benunits: + family: + members: [parent, son] + grandmother_unit: + members: [grandmother] + households: + household: + members: [parent, son, grandmother] + country: SCOTLAND + region: SCOTLAND + output: + is_claimant_or_partner: [true, false, true] + receives_carer_benefit: [false, true, false] + carer_premium: [0, 0] + council_tax_reduction_carer_premium: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml index 39b4892cd1..66edc75834 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -186,14 +186,20 @@ has_uc_award_with_carer_element: [true, false, false, false] is_cared_for_by_carer_benefit_recipient: [false, false, true, false] -- name: "Disclosed limit: a carer whose income tapers the award to nil still bars the premium" - # With £25,000 of earnings the carer's award is nil (income reduction - # 7,610.88 against a maximum of 7,610.88), so in law there is no award - # (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and the premium, - # 86.05 x 52 = 4,474.60, is payable. The model reads the award from its - # conditions, because the award reads Pension Credit, which reads the - # addition, so it withholds the premium. +- name: A carer whose income tapers the award to nil has no award and does not bar the premium + # The carer's maximum amount is (424.90 + 209.34) x 12 = 7,610.88. Earnings + # of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 and + # National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, and + # with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. That + # exceeds the maximum amount even with no non-dependant deduction + # (uc_award_upper_bound; the disabled person's family, which does not + # claim, has a bound of its standard allowance, 424.90 x 12 = 5,098.80), so + # the award is nil and there is no award (Welfare Reform Act 2012 s.5(1)(b); + # UC Regs 2013 reg 17). No one has an award in + # respect of caring for the disabled person, who keeps the premium, 86.05 x + # 52 = 4,474.60. period: 2026 + absolute_error_margin: 0.01 input: people: disabled: @@ -216,9 +222,131 @@ members: [disabled, carer] country: ENGLAND output: + uc_earned_income: [0, 21_519.60] universal_credit: [0, 0] + uc_award_upper_bound: [5_098.80, 0] receives_uc_carer_element: [false, false] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_premium: [4_474.60, 0] + +- name: A renting carer's housing costs element keeps an award that bars the premium + # The carer of the case above, earning £25,000, rents from the council at + # £6,000 a year. The housing costs element raises the maximum amount to + # 7,610.88 + 6,000 = 13,610.88, and the taper of 11,835.78 leaves an award + # of 1,775.10. The disabled person, on the PIP daily living component, + # brings no non-dependant deduction (UC Regs 2013 Sch 4 para 16(2)(e)), so + # the award equals its bound. It includes the carer element and bars the + # premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + is_household_head: true + disabled: + age: 50 + pip_dl_category: STANDARD + benunits: + carer_unit: + members: [carer] + disabled_unit: + members: [disabled] + would_claim_uc: false + households: + household: + members: [carer, disabled] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + uc_housing_costs_element: [6_000, 0] + universal_credit: [1_775.10, 0] + uc_award_upper_bound: [1_775.10, 5_098.80] + has_uc_award_with_carer_element: [true, false] + is_cared_for_by_carer_benefit_recipient: [false, true] + severe_disability_premium: [0, 0] + +- name: A son whose income tapers the award to nil does not bar a pensioner's severe disability addition + # The son of the third case earns £20,000: less income tax 1,486.00 and + # National Insurance 594.40, the taper takes 0.55 x 17,919.60 = 9,855.78, + # more than his maximum amount, 7,610.88. He has no award, so the addition + # and the pension-age Housing Benefit premium stay at 86.05 x 52 = + # 4,474.60 (SPC Regs 2002 Sch I para 1(1)(a)(iii); HB(SPC) Regs 2006 Sch 3 + # para 6(2)(a)(iii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: ENGLAND + output: + uc_earned_income: [0, 17_919.60] + universal_credit: [0, 0] + uc_award_upper_bound: [0, 0] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + pension_age_severe_disability_premium: [4_474.60, 0] + +- name: A stored award is read as the award, even without a claim + # The carer of the first case earns £20,000, which tapers the award the + # model would work out to nil, and neither family claims. Universal Credit + # is supplied instead: nil for the disabled person's family and £100 for + # the carer's. A stored award reads no other variable, so the carer test + # reads it (receives_uc_carer_element): the carer's award includes the + # carer element and bars the premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + disabled_unit: + members: [disabled] + claims_universal_credit: false + universal_credit: 0 + carer_unit: + members: [carer] + claims_universal_credit: false + universal_credit: 100 + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + receives_uc_carer_element: [false, true] has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] severe_disability_premium: [0, 0] - name: Without a Universal Credit claim there is no carer element award diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index d02cedc855..96b91f99e3 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -9,16 +9,19 @@ 2021/249 Sch 1 paras 5 and 6(1)(c), from 1 April 2022) pays the carer premium to an applicant or partner with such an award. -The model reads the award from its conditions (has_uc_award_with_carer_element) -for the severe disability carer test, since the award itself reads Pension -Credit, and from the positive award (receives_uc_carer_element) for the -Scottish carer premium. Each example simulates the households as drawn, and +The model reads the award from its conditions and an upper bound on it +(has_uc_award_with_carer_element, uc_award_upper_bound) for the severe +disability carer test, since the award itself reads Pension Credit, unless the +simulation stores the award. Scotland's carer premium reads the positive award +(receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when an +award of nil counted, the award's conditions. Each example simulates the +households as drawn, and again with has_uc_award_with_carer_element supplied as false for everyone, the attribution as it ran before the limb (its carers are then receives_carer_benefit alone). The Scottish premium's counterpart without the limb is carer_premium. -Invariants, for any generated population of households: +Invariants, on generated households under baseline law: 1. No change without a carer element: where no one has has_uc_award_with_carer_element or receives_uc_carer_element, the @@ -35,13 +38,18 @@ the number of claimants and partners, and at most one where partners care for the same person; a person with both an allowance and a carer element counts once. -5. The read before the award misses no award: receives_uc_carer_element - implies has_uc_award_with_carer_element. +5. Where the bound is nil, the award is nil: uc_award_upper_bound is at least + universal_credit_pre_benefit_cap, which is at least universal_credit, so + receives_uc_carer_element implies has_uc_award_with_carer_element. That + implication also holds where the simulation stores the award. 6. Attribution: no one is their own carer, everyone cared for is severely disabled for Carer's Allowance, and a household has no more people cared for than carers (carer benefit recipients and carer element holders). 7. No cycle: every reader calculates on one simulation, whichever is requested - first. + first, and also where the simulation stores the award. + +A reform that makes is_uc_eligible read Pension Credit would cycle through the +attribution, which reads eligibility; these tests do not cover reforms. The no-change invariant compares the two code paths in this tree; the differential against origin/main itself runs on hand-built datasets outside @@ -89,6 +97,7 @@ "is_cared_for_by_carer_benefit_recipient", "has_uc_award_with_carer_element", "receives_uc_carer_element", + "uc_award_upper_bound", "universal_credit", "pension_credit", "housing_benefit", @@ -145,8 +154,23 @@ def benefit_unit(draw): @st.composite def households(draw): + # Renters pay the housing costs element, and the household head's family + # bears a deduction for each non-dependant in another benefit unit, which + # the upper bound leaves out. + tenure, rent = draw( + st.sampled_from( + [ + ("OWNED_OUTRIGHT", 0), + ("RENT_PRIVATELY", 6_000), + ("RENT_PRIVATELY", 12_000), + ("RENT_FROM_COUNCIL", 6_000), + ] + ) + ) return dict( country=draw(st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"])), + tenure=tenure, + rent=rent, units=draw(st.lists(benefit_unit(), min_size=1, max_size=3)), ) @@ -185,6 +209,9 @@ def situation(population, without_limb=()): benunits[f"h{h}_u{u}"] = {"members": unit_members} members += unit_members hh[f"h{h}"] = {"members": members, "country": {YEAR: household["country"]}} + if "tenure" in household: + hh[f"h{h}"]["tenure_type"] = {YEAR: household["tenure"]} + hh[f"h{h}"]["rent"] = {YEAR: household["rent"]} return {"people": people, "benunits": benunits, "households": hh} @@ -310,6 +337,9 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati "receives_uc_carer_element", "receives_carer_benefit", "is_claimant_or_partner", + "uc_award_upper_bound", + "universal_credit_pre_benefit_cap", + "universal_credit", ) sim, values = with_and_without_limb(population, variables) drawn = {v: pair[0] for v, pair in values.items()} @@ -318,7 +348,12 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati benunit_household = household_index(sim, "benunit") person_household = household_index(sim, "person") - # 5. The read before the award misses no award. + # 5. Where the bound is nil the award is nil, exactly, so the carer test + # misses no award. + bound = drawn["uc_award_upper_bound"] + award = drawn["universal_credit_pre_benefit_cap"] + assert (bound >= award).all() + assert (award >= drawn["universal_credit"]).all() has_award = drawn["has_uc_award_with_carer_element"] positive_award = drawn["receives_uc_carer_element"] assert not (positive_award & ~has_award).any() @@ -401,3 +436,193 @@ def test_every_reader_calculates_on_one_simulation_in_any_order(population, orde for variable in order: values = np.asarray(sim.calculate(variable, YEAR)) assert np.isfinite(values.astype(float)).all(), variable + + +def stored_award_situation(stored=True): + """The issue's blind carer on £20,000, which tapers the award the model + works out to nil, with neither family claiming; the award is supplied + instead: nil for the disabled person's family, £100 for the carer's.""" + sim_situation = situation( + [ + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict( + adults=[single(40, blind=True, care_hours=35, earnings=20_000)], + children=[], + ), + ], + ) + ] + ) + for unit, award in (("h0_u0", 0), ("h0_u1", 100)): + sim_situation["benunits"][unit]["claims_universal_credit"] = {YEAR: False} + if stored: + sim_situation["benunits"][unit]["universal_credit"] = {YEAR: award} + return sim_situation + + +@pytest.mark.parametrize("order", [ALL_READERS, tuple(reversed(ALL_READERS))]) +def test_a_stored_award_is_the_award_and_reads_no_cycle(order): + # A stored award reads no other variable, so the carer test reads it: the + # carer's £100 award includes the carer element and bars the disabled + # person's premium, 86.05 x 52 = 4,474.60 without it. Every reader then + # calculates on the one simulation (invariants 5 and 7). + sim = Simulation(situation=stored_award_situation()) + for variable in order: + values = np.asarray(sim.calculate(variable, YEAR)) + assert np.isfinite(values.astype(float)).all(), variable + has_award = sim.calculate("has_uc_award_with_carer_element", YEAR) + receives = sim.calculate("receives_uc_carer_element", YEAR) + assert receives.tolist() == [False, True] + assert has_award.tolist() == [False, True] + assert sim.calculate("is_cared_for_by_carer_benefit_recipient", YEAR).tolist() == [ + True, + False, + ] + assert sim.calculate("severe_disability_premium", YEAR).tolist() == [0, 0] + # Without the stored award neither family claims, so no one has an award. + unstored = Simulation(situation=stored_award_situation(stored=False)) + assert not unstored.calculate("has_uc_award_with_carer_element", YEAR).any() + assert unstored.calculate("severe_disability_premium", YEAR)[0] == pytest.approx( + 86.05 * WEEKS + ) + + +def test_a_reform_that_abolishes_universal_credit_leaves_no_carer_element_award(): + # A neutralized award reads no other variable and is nil, so no one has + # an award and the blind carer of the issue no longer bars the premium. + from policyengine_core.reforms import Reform + + class AbolishUniversalCredit(Reform): + def apply(self): + self.neutralize_variable("universal_credit") + + sim = Simulation(situation=situation(EXAMPLES[:1]), reform=AbolishUniversalCredit) + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() + assert sim.calculate("severe_disability_premium", YEAR)[0] == pytest.approx( + 86.05 * WEEKS + ) + + +def require(condition, message): + # A broken premise is an error, not the disclosed failure. + if not condition: + raise RuntimeError(message) + + +def non_dependant_situation(): + """A carer (40) who rents jointly with the disabled person (50) she cares + for, each liable for half of £12,000, and a blind non-dependant (30) + earning £20,000. The carer earns £27,000.""" + people = { + "carer": { + "age": {YEAR: 40}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 27_000}, + "is_household_head": {YEAR: True}, + }, + "disabled": {"age": {YEAR: 50}, "pip_dl_category": {YEAR: "STANDARD"}}, + "non_dependant": { + "age": {YEAR: 30}, + "is_blind": {YEAR: True}, + "employment_income": {YEAR: 20_000}, + }, + } + benunits = { + "carer_unit": {"members": ["carer"], "share_of_household_rent": {YEAR: 0.5}}, + "disabled_unit": { + "members": ["disabled"], + "would_claim_uc": {YEAR: False}, + "liable_for_share_of_household_rent": {YEAR: True}, + "share_of_household_rent": {YEAR: 0.5}, + "severe_disability_premium_joint_occupier_exclusion_applies": {YEAR: True}, + }, + "non_dependant_unit": { + "members": ["non_dependant"], + "would_claim_uc": {YEAR: False}, + }, + } + household = { + "members": ["carer", "disabled", "non_dependant"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: 12_000}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def tax_free_childcare_situation(): + """A blind carer (35) earning £60,000 with a child (3) in £12,000 of + childcare, and the disabled person (60) she cares for in another family + of the household.""" + people = { + "carer": { + "age": {YEAR: 35}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 60_000}, + }, + "child": {"age": {YEAR: 3}, "childcare_expenses": {YEAR: 12_000}}, + "disabled": {"age": {YEAR: 60}, "pip_dl_category": {YEAR: "STANDARD"}}, + } + benunits = { + "carer_unit": {"members": ["carer", "child"]}, + "disabled_unit": {"members": ["disabled"], "would_claim_uc": {YEAR: False}}, + } + household = { + "members": ["carer", "child", "disabled"], + "country": {YEAR: "ENGLAND"}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +@pytest.mark.xfail( + raises=AssertionError, + strict=True, + reason=( + "Disclosed limit: the carer test reads the award before the " + "non-dependant deduction, which takes this award to nil." + ), +) +def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award(): + # The non-dependant's deduction (UC Regs 2013 Sch 4 paras 13 and 16; a + # blind non-dependant has no exemption, and the carer is no exempt renter + # under para 15) takes the carer's award to nil, so she has no award + # (Welfare Reform Act 2012 s.5(1)(b); reg 17). The disabled person is a + # joint tenant, not her non-dependant (para 9(2)(d)), and she is not his + # (HB Regs 2006 reg 3(2)(d)); a blind non-dependant does not bar the + # premium (Sch 3 para 14(4)). So the law pays him the premium. + sim = Simulation(situation=non_dependant_situation()) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + bound = sim.calculate("uc_award_upper_bound", YEAR) + deductions = sim.calculate("uc_non_dep_deductions", YEAR) + require(award[0] == 0 and bound[0] > 0, "the carer's award is not nil") + require(deductions[0] > bound[0], "the deduction does not take it to nil") + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) + + +@pytest.mark.xfail( + raises=AssertionError, + strict=True, + reason=( + "Disclosed limit: the carer test cannot read whether a family takes " + "Tax-Free Childcare instead, which reads the award." + ), +) +def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): + # The family is better off with the Tax-Free Childcare top-up, so it + # takes no award (forgoes_universal_credit_for_tax_free_childcare); with + # no award, no one has one in respect of caring for the disabled person, + # and the law pays him the premium. + sim = Simulation(situation=tax_free_childcare_situation()) + forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + bound = sim.calculate("uc_award_upper_bound", YEAR) + require(forgoes[0] and award[0] == 0 and bound[0] > 0, "the family claims") + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 38897f81bf..3e1bdd4ed2 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -85,6 +85,7 @@ "trading_loss", "uc_carer_element", "uc_LCWRA_element", + "universal_credit", "universal_childcare_entitlement", } ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index cb7369c9ef..9afa73c9ae 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -29,7 +29,8 @@ class severe_disability_minimum_guarantee_addition(Variable): "is_cared_for_by_carer_benefit_recipient, which the legacy severe " "disability premium shares; a carer benefit there includes an award " "of Universal Credit which includes the carer element (para 1(4)), " - "read from the award's conditions rather than its amount. Blindness " + "read from the award's conditions and the award before any " + "non-dependant deduction (has_uc_award_with_carer_element). Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index e442d0cb7b..fe18c62e1d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -1,32 +1,45 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import stored_as_input + + +def award_reads_no_other_variable(simulation, period): + """Whether reading universal_credit for this period reads no other + variable: a reform neutralized it, or an input or a scenario stored its + value for the period.""" + variable = simulation.tax_benefit_system.get_variable("universal_credit") + return variable.is_neutralized or stored_as_input( + simulation.populations["benunit"], "universal_credit", period + ) class has_uc_award_with_carer_element(Variable): value_type = bool entity = Person - label = "has a Universal Credit award with the carer element, read before the award" + label = "has a Universal Credit award with the carer element" documentation = ( "Whether this person has an award of Universal Credit which includes " "the carer element (Universal Credit Regulations 2013 reg 29) in " - "respect of them, read from the conditions of the award rather than " - "its amount: the family claims Universal Credit " - "(claims_universal_credit), meets the age, pension-route and capital " - "conditions (is_uc_eligible), and the award includes the carer " - "element in respect of this claimant " - "(uc_carer_element_included_for_claimant). It is the carer condition " - "of the legacy severe disability premium (HB Regs 2006 Sch 3 para " - "14(2)(a)(iii) and (b)) and of the Pension Credit severe disability " - "addition (SPC Regs 2002 Sch I para 1(1) and (4)), through " - "is_cared_for_by_carer_benefit_recipient. Those cannot read the award " - "itself: the housing costs element's non-dependant deduction reads " - "Pension Credit, which reads the addition. A claimant whose income " - "tapers the award to nil fails the financial conditions (Welfare " - "Reform Act 2012 s.5(1)(b) and (2)(b); the minimum is one penny, reg " - "17), so has no award in law, but still counts here. So does a family " - "that forgoes Universal Credit for Tax-Free Childcare " + "respect of them: the carer condition of the legacy severe disability " + "premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b)) and of the " + "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " + "1(1) and (4)), through is_cared_for_by_carer_benefit_recipient. " + "Those cannot read the award: its non-dependant deduction spares a " + "non-dependant on Pension Credit, so it reads Pension Credit, which " + "reads the addition. So this reads the award's conditions instead. " + "The family claims Universal Credit (claims_universal_credit), meets " + "the age, pension-route and capital conditions (is_uc_eligible) and " + "has an award before the non-dependant deduction (uc_award_upper_bound " + "positive), and the award includes the carer element in respect of " + "this claimant (uc_carer_element_included_for_claimant). Where the " + "bound is nil, the award is nil and there is no award (Welfare Reform " + "Act 2012 s.5(1)(b) and (2)(b); the minimum is one penny, reg 17), so " + "a carer whose income tapers the award to nil does not count. A carer " + "whose award a non-dependant deduction takes to nil still counts, and " + "so does a family that takes Tax-Free Childcare instead " "(forgoes_universal_credit_for_tax_free_childcare reads the award). " - "receives_uc_carer_element is the same test on a positive award, for " - "readers that can read it." + "Where an input or a scenario stores universal_credit for the period, " + "or a reform neutralizes it, reading it cannot cycle, so this reads " + "the award itself (receives_uc_carer_element)." ) definition_period = YEAR reference = ( @@ -38,9 +51,13 @@ class has_uc_award_with_carer_element(Variable): ) def formula(person, period, parameters): + if award_reads_no_other_variable(person.simulation, period): + return person("receives_uc_carer_element", period) benunit = person.benunit - on_claim = benunit("claims_universal_credit", period) & benunit( - "is_uc_eligible", period + on_claim = ( + benunit("claims_universal_credit", period) + & benunit("is_uc_eligible", period) + & (benunit("uc_award_upper_bound", period) > 0) ) included = person("uc_carer_element_included_for_claimant", period) return on_claim & included diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py new file mode 100644 index 0000000000..16f7cca626 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -0,0 +1,97 @@ +from policyengine_uk.model_api import * + + +class uc_award_upper_bound(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit award before the non-dependant deduction" + documentation = ( + "An amount the family's award before the benefit cap " + "(universal_credit_pre_benefit_cap) never exceeds, found without the " + "non-dependant deduction. The award is the maximum amount less the " + "income reduction (UC Regs 2013 reg 22). Its housing costs element " + "(uc_housing_costs_element) is the rent a renter may claim less the " + "non-dependant deductions, and its work allowance is the one with " + "housing where that element is positive. A deduction smaller than " + "the rent leaves the award at most what it would be with no " + "deduction; a deduction as large as the rent removes the element and " + "gives the work allowance without housing. This is the larger of " + "those two awards. It repeats the award's steps in the same order, " + "rounding where they store a value, so it is never below the award. " + "Where it is nil the family has no award, since an amount below one " + "penny is no award (Welfare Reform Act 2012 s.5(1)(b) and (2)(b); " + "reg 17). The deduction spares a non-dependant on Pension Credit, so " + "it reads Pension Credit, whose severe disability addition reads who " + "has an award with the carer element. This amount reads neither, so " + "that test can read it (has_uc_award_with_carer_element). It can be " + "positive where the award is nil: a non-dependant deduction can take " + "the award to nil, and a family that takes Tax-Free Childcare instead " + "gets none (forgoes_universal_credit_for_tax_free_childcare)." + ) + definition_period = YEAR + unit = GBP + defined_for = "is_uc_eligible" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test + # Housing costs before any non-dependant deduction, as in + # uc_housing_costs_element. + tenure_type = benunit("benunit_tenure_type", period) + tenure_types = tenure_type.possible_values + rent = benunit("benunit_rent", period) + capped_rent_amount = min_(benunit("uc_LHA_cap", period), rent) + max_housing_costs = select( + [ + (tenure_type == tenure_types.RENT_FROM_COUNCIL) + | (tenure_type == tenure_types.RENT_FROM_HA), + tenure_type == tenure_types.RENT_PRIVATELY, + ], + [rent, capped_rent_amount], + default=0, + ) + no_deduction = max_(max_housing_costs, 0).astype(np.float32) + elements = [ + benunit(name, period) + for name in ( + "uc_standard_allowance", + "uc_child_element", + "uc_disability_elements", + "uc_carer_element", + ) + ] + childcare = benunit("uc_childcare_element", period) + earned_income = benunit("uc_earned_income_before_work_allowance", period) + unearned_income = benunit("uc_unearned_income", period) + work_allowance_eligible = benunit("is_uc_work_allowance_eligible", period) + awards = [] + for housing in (no_deduction, np.zeros_like(no_deduction)): + # The steps of uc_maximum_amount (its adds, in order), + # uc_work_allowance, uc_earned_income, uc_income_reduction and + # universal_credit_pre_benefit_cap, each rounded to float32 where + # that variable stores its value, so no rounding puts the bound + # below the award. + maximum = 0 + for element in elements: + maximum = maximum + element + maximum = maximum + housing + childcare + monthly_allowance = where( + housing > 0, + p.work_allowance.with_housing, + p.work_allowance.without_housing, + ) + work_allowance = where( + work_allowance_eligible, + (monthly_allowance * MONTHS_IN_YEAR).astype(np.float32), + 0, + ).astype(np.float32) + earned = max_(0, earned_income - work_allowance) + total_reduction = p.reduction_rate * earned + unearned_income + reduction = min_(maximum, total_reduction).astype(np.float32) + awards.append(maximum - reduction) + return max_(awards[0], awards[1]) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index 5579a2b6af..dfabb17911 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -9,28 +9,43 @@ class council_tax_reduction_carer_premium(Variable): documentation = ( "The carer premium in a working-age Council Tax Reduction applicable " "amount. Outside Scotland's working-age scheme it is carer_premium. " - "Scotland's scheme (SSI 2021/249 Sch 1 paras 5 and 6, from 1 April " - "2022) pays it in respect of each applicant or partner who has " - "regular and substantial caring responsibilities for a severely " - "disabled person. Para 6(1) defines those as (a) receiving Carer's " - "Allowance or Carer Support Payment, (b) being entitled to one that " - "the overlapping benefits rules reduce to nil, or (c) having an award " - "of Universal Credit which includes the carer element. Limbs (a) and " - "(b) are carer_premium's test (is_entitled_to_carer_benefit); limb (c) " - "is receives_uc_carer_element, which needs a positive award, since an " - "income that tapers the award to nil leaves no award (Welfare Reform " - "Act 2012 s.5(1)(b), UC Regs 2013 reg 17). A person with both an " - "allowance and a carer element qualifies once: the premium is for each " - "partner who qualifies (para 5(3)), not for each award. Partners " - "caring for the same severely disabled person get one premium (para " - "5(3) and (4); partners_care_for_same_severely_disabled_person), as in " - "carer_premium. Para 5(4) then pays it in respect of the one whose " - "Universal Credit award includes the carer element, then one in " - "receipt of an allowance, then one entitled to an allowance reduced " - "to nil; that choice does not change the amount. Before 1 April 2022 " - "the scheme for persons under pension age, SSI 2012/303 Sch 1 para " - "14, tested entitlement to Carer's Allowance only " - "(carer_premium_counts_uc_carer_element)." + "In Scotland's scheme (SSI 2021/249 Sch 1 paras 5 and 6, from 1 April " + "2022) councils pay it in respect of each applicant or partner who " + "has regular and substantial caring responsibilities for a severely " + "disabled person. The Scottish Ministers define those in para 6(1) " + "as (a) receiving Carer's Allowance or Carer Support Payment, (b) " + "being entitled to one that the overlapping benefits rules reduce to " + "nil, or (c) having an award of Universal Credit which includes the " + "carer element. Limbs (a) and (b) are carer_premium's test " + "(is_entitled_to_carer_benefit). For limb (c), the Scottish Ministers " + "defined an award of Universal Credit to include one with a value of " + "zero from 1 April 2022 (reg 4, as amended by SSI 2022/52 reg 3), and " + "from 1 April 2025 as an award made under Part 1 of the Welfare Reform " + "Act 2012 (SSI 2025/24 reg 8), under which an income that tapers the " + "award to nil leaves no award (s.5(1)(b); UC Regs 2013 reg 17). So " + "while the earlier definition holds " + "(uc_award_includes_zero_value_award), limb (c) reads the award's " + "conditions: the family claims Universal Credit, meets its basic " + "conditions and does not take Tax-Free Childcare instead, and the " + "award includes the carer element for this claimant. Afterwards it " + "reads the positive award (receives_uc_carer_element). From 1 April " + "2025 the Scottish Ministers also count two nil awards as awards (reg " + "10A): one a sanction reduces to nil, and one whose claimant the " + "Secretary of State treats as reclaiming after earnings end it (UC " + "Claims and Payments Regs 2013 reg 32A). The model has neither " + "sanctions nor reclaims, so it pays no premium for those. A person " + "with both an allowance and a carer element qualifies once: councils " + "pay the premium for each partner who qualifies (para 5(3)), not for " + "each award. Partners caring for the same severely disabled person " + "get one premium (para 5(3) and (4); " + "partners_care_for_same_severely_disabled_person), as in " + "carer_premium. Councils give it, under para 5(4), in respect of the " + "one whose Universal Credit award includes the carer element first, " + "then one in receipt of an allowance, then one entitled to an " + "allowance reduced to nil; that choice does not change the amount. " + "Before 1 April 2022 councils paid the premium to persons under " + "pension age on entitlement to Carer's Allowance only (SSI 2012/303 " + "Sch 1 para 14; carer_premium_counts_uc_carer_element)." ) definition_period = YEAR unit = GBP @@ -38,6 +53,9 @@ class council_tax_reduction_carer_premium(Variable): "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/5", "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", "https://www.legislation.gov.uk/ssi/2021/249/regulation/1", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2024-04-30", + "https://www.legislation.gov.uk/ssi/2025/24/regulation/8/made", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/10A", "https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01", "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", "https://www.legislation.gov.uk/ukpga/2012/5/section/5", @@ -55,11 +73,22 @@ def formula(benunit, period, parameters): ) & ~benunit("council_tax_reduction_pensioner", period) person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) - # SSI 2021/249 Sch 1 para 6(1)(a) and (b), as in carer_premium, or - # (c) an award of Universal Credit which includes the carer element. - caring = person("is_entitled_to_carer_benefit", period) | person( - "receives_uc_carer_element", period - ) + # SSI 2021/249 Sch 1 para 6(1)(c): an award of Universal Credit which + # includes the carer element. Until 31 March 2025 an award with a + # value of zero counts (reg 4), so the award's conditions decide. + carer_element_award = person("receives_uc_carer_element", period) + if scotland.uc_award_includes_zero_value_award: + family = person.benunit + on_claim = ( + family("claims_universal_credit", period) + & family("is_uc_eligible", period) + & ~family("forgoes_universal_credit_for_tax_free_childcare", period) + ) + carer_element_award = carer_element_award | ( + on_claim & person("uc_carer_element_included_for_claimant", period) + ) + # Para 6(1)(a) and (b), as in carer_premium, or (c). + caring = person("is_entitled_to_carer_benefit", period) | carer_element_award qualifying_carers = benunit.sum(claimant_or_partner & caring) same_person = benunit("partners_care_for_same_severely_disabled_person", period) qualifying_carers = where( diff --git a/policyengine_uk/variables/household/demographic/carer_premium.py b/policyengine_uk/variables/household/demographic/carer_premium.py index bc9adbff80..5a0a9aa9bf 100644 --- a/policyengine_uk/variables/household/demographic/carer_premium.py +++ b/policyengine_uk/variables/household/demographic/carer_premium.py @@ -25,10 +25,10 @@ class carer_premium(Variable): "person caps the benefit unit at one amount. The premium enters the " "Income Support, Housing Benefit and council tax reduction applicable " "amounts; the model takes income-based JSA and income-related ESA " - "from reported awards. Scotland's working-age council tax reduction " - "also pays it for an award of Universal Credit which includes the " - "carer element (council_tax_reduction_carer_premium); no other " - "scheme's carer premium has that limb." + "from reported awards. In Scotland's working-age council tax " + "reduction, councils also pay it for an award of Universal Credit " + "which includes the carer element (council_tax_reduction_carer_premium); " + "no other scheme's carer premium has that limb." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 3598aa6d40..f730177fa1 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -46,13 +46,15 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "whose allowance an overlapping benefit, such as a higher State " "Pension, reduces to nil is entitled but not in receipt, so is not " "attributed for the allowance. A carer element award is " - "has_uc_award_with_carer_element, read from the claim and the award's " - "conditions rather than its amount, because the Universal Credit " - "non-dependant deduction exemption reads Pension Credit, which reads " - "the addition. So a carer whose income tapers the award to nil, who " - "has no award in law (Welfare Reform Act 2012 s.5), is still " - "attributed, and can withhold a premium or addition the law would " - "pay. A carer with both an allowance and a carer element is one " + "has_uc_award_with_carer_element, read from the claim, the award's " + "conditions and the award before any non-dependant deduction, because " + "the Universal Credit non-dependant deduction exemption reads Pension " + "Credit, which reads the addition. A carer whose income tapers the " + "award to nil has no award (Welfare Reform Act 2012 s.5) and is not " + "attributed for it. A carer whose award a non-dependant deduction " + "takes to nil, or whose family takes Tax-Free Childcare instead, is " + "still attributed, and can withhold a premium or addition the law " + "would pay. A carer with both an allowance and a carer element is one " "carer. Partners who care for the same severely disabled person " "(partners_care_for_same_severely_disabled_person) have one relevant " "entitlement between them (s.70(7ZA) and (7A)), so where one of them " diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 4e0fb09af5..161fdfe801 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -21,8 +21,9 @@ class pension_age_severe_disability_premium(Variable): "claim, as a couple may agree under HB(SPC) reg 63(1) and the CTR " "schemes (England SI 2012/2885 Sch 8 para 4(1)); carers are attributed " "by is_cared_for_by_carer_benefit_recipient, which counts a Universal " - "Credit award with the carer element from the award's conditions " - "rather than its amount. The Pension Credit severe " + "Credit award with the carer element from the award's conditions and " + "the award before any non-dependant deduction. The Pension Credit " + "severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 0147125bfc..3d2b665905 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -23,9 +23,10 @@ class severe_disability_premium(Variable): "attributed by is_cared_for_by_carer_benefit_recipient, shared with " "the Pension Credit severe disability addition. It counts an award " "of Universal Credit which includes the carer element from the " - "award's conditions rather than its amount " + "award's conditions and the award before any non-dependant deduction " "(has_uc_award_with_carer_element), so a carer whose income tapers " - "the award to nil still bars the premium. Not modelled: a carer's " + "the award to nil does not bar the premium, but one whose award a " + "non-dependant deduction takes to nil does. Not modelled: a carer's " "allowance or carer element that would be paid but for a loss of " "benefit restriction under section 6B or 7 of the Social Security " "Fraud Act 2001 also counts (HB Regs 2006 Sch 3 para 14(7), IS Regs " From 3ab4884597c92547cfc383b3a920883afcba0717 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:27:09 -0500 Subject: [PATCH 048/168] Read the fast path's rates as the slope of the tax schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast path looked up each rate by the last threshold passed, which goes wrong once a Gift Aid gift lifts the UK basic and higher rate limits above the £10 million extra bracket: a person whose income sits below the lifted basic rate limit read as paying the additional rate, so the fast path could keep the old order where moving deductions saves tax. The fast path now sums the rate steps the income has passed, the slope of the tax the search evaluates, and requires every step to be non-negative. The property test gains that person. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_tax_allocation.py | 4 ++ .../utils/income_tax_allocation.py | 71 ++++++------------- 2 files changed, 26 insertions(+), 49 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 27118b5bc9..dc27183bd9 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -378,6 +378,9 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # Pension relief and a trading loss. (60_000, 0, 9_000, 4_000, 3_000, 10_000, 0, 0, False, 500), (110_000, 0, 20_000, 30_000, 0, 0, 0, 0, False, 0), + # A gift that lifts the UK limits above the £10 million extra bracket, so + # the thresholds no longer ascend. + (15_050_070, 0, 0, 10_000, 0, 12_570, 0, 15_000_000, False, 0), ] @@ -493,6 +496,7 @@ def assert_fast_path_equals_search(income_tax, inputs, default): suppress_health_check=[HealthCheck.too_slow], ) @given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2025, 0) @example(2026, 0) @example(2027, 0) @example(2028, 1) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index bdf23a1d57..57ae81db5f 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -201,33 +201,20 @@ def _build(inputs, income_tax): gift=gift, scottish=columns["scottish"], uk=_schedule(rates.uk, gift), - uk_rates=list(rates.uk.rates), scotland=_schedule( rates.scotland.rates, gift, first_extended_scottish_threshold(rates.scotland.rates), len(rates.scotland.rates.thresholds), ), - scotland_rates=list(rates.scotland.rates.rates), dividend_scale=_schedule(rates.dividends, gift), - dividend_rates=list(rates.dividends.rates), bands=( uk_thresholds[0], uk_thresholds[1] + gift, uk_thresholds[2] + gift, ), savings_rates=savings_rates, - savings_rate_list=[ - savings_rates.basic, - savings_rates.higher, - savings_rates.additional, - ], property_rates=property_rates, - property_rate_list=[ - property_rates.basic, - property_rates.higher, - property_rates.additional, - ], starting_limit=rates.savings_starter_rate.allowance, phase_out=rates.savings_starter_rate.phase_out, savings_allowance=columns["savings_allowance"], @@ -477,18 +464,20 @@ def old_order( ) -def _rate(thresholds, rates, income, above=True): - """The rate a scale charges just above ``income`` (or just below it).""" - rate = np.zeros(np.shape(income)) - for threshold, value in zip(thresholds, rates): - rate = np.where( - income >= threshold if above else income > threshold, value, rate - ) - return rate +def _rate(schedule, income, above=True): + """The rate ``_tax`` charges on a schedule just above ``income`` (or just + below it): the sum of the steps passed, whatever order the thresholds + take once a Gift Aid gift raises some of them.""" + thresholds, steps = schedule + return sum( + step * (income >= threshold if above else income > threshold) + for threshold, step in zip(thresholds, steps) + ) -def _rising(rates): - return rates[0] >= 0 and all(b >= a for a, b in zip(rates, rates[1:])) +def _steps(rates): + """The steps of a basic, higher and additional rate.""" + return [rates.basic, rates.higher - rates.basic, rates.additional - rates.higher] def _old_order_is_least(c, to_savings, to_dividends): @@ -505,17 +494,12 @@ def _old_order_is_least(c, to_savings, to_dividends): & (c.step_3_income <= TIE) ) low, basic_limit, higher_limit = c.bands + savings_scale = ((low, basic_limit, higher_limit), _steps(c.savings_rates)) + property_scale = ((-np.inf, basic_limit, higher_limit), _steps(c.property_rates)) + scales = (c.uk, c.scotland, c.dividend_scale, savings_scale, property_scale) + # Rates that rise with income from nil: no step below nil. if not ( - all( - _rising(rates) - for rates in ( - c.uk_rates, - c.scotland_rates, - c.dividend_rates, - c.savings_rate_list, - c.property_rate_list, - ) - ) + all(step >= 0 for _, steps in scales for step in steps) and low <= 0 and c.phase_out >= 0 ): @@ -528,37 +512,26 @@ def _old_order_is_least(c, to_savings, to_dividends): nil_rated = ~savings_taxed & (c.dividends <= c.dividend_allowance) # Test 3. The first component's rate just above x. if c.other_first: - _, basic_upper, higher_upper = c.bands rho_n = np.where( - c.scottish, - _rate(c.scotland[0], c.scotland_rates, other + x), - _rate( - (-np.inf, basic_upper, higher_upper), c.property_rate_list, other + x - ), + c.scottish, _rate(c.scotland, other + x), _rate(property_scale, other + x) ) in_reach = in_first else: # With no property income, Marriage Allowance only moves every band # position down by its amount. position = x - c.earned_exclusions - rho_n = np.where( - c.scottish, - _rate(c.scotland[0], c.scotland_rates, position), - _rate(c.uk[0], c.uk_rates, position), - ) + rho_n = np.where(c.scottish, _rate(c.scotland, position), _rate(c.uk, position)) in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) - savings_scale = ((low, basic_limit, higher_limit), c.savings_rate_list) # dx <= c.shared and dw <= min(dx, dividends). The top of the nil rates # moves up by at most dx and never passes the top of the savings. dw = np.minimum(c.shared, c.dividends) - sigma_s = _rate(*savings_scale, np.minimum(start + c.shared, top + dw), above=False) - rho_s = _rate(*savings_scale, top) + sigma_s = _rate(savings_scale, np.minimum(start + c.shared, top + dw), above=False) + rho_s = _rate(savings_scale, top) # The dividends end at the top of income, which no share moves. sigma_v = np.where( c.dividends > c.dividend_allowance, _rate( - c.dividend_scale[0], - c.dividend_rates, + c.dividend_scale, top + np.minimum(c.dividend_allowance + dw, c.dividends), above=False, ), From 1f87a159d965b545aca6d308666d6f24f0977640 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:51:00 -0500 Subject: [PATCH 049/168] Test that the award bound reads neither the award nor Pension Credit A traced calculation of uc_award_upper_bound for a pensioner and her blind son, who rent privately, reaches none of pension_credit, the housing costs element, the non-dependant deduction or its exemption, the maximum amount, the income reduction, the award or the attribution. The changelog now gives the Scottish nil-award dates. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2120.fixed.md | 2 +- .../test_uc_carer_element_award_properties.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md index 3e754bdf83..443c7a80b7 100644 --- a/changelog.d/2120.fixed.md +++ b/changelog.d/2120.fixed.md @@ -1 +1 @@ -The model now withholds the severe disability premium and the Pension Credit severe disability addition when a carer's Universal Credit award includes the carer element for the claimant or partner, and pays the carer premium in Scotland's working-age council tax reduction for such an award from 1 April 2022 (#2120, #2124). +The model now withholds the severe disability premium and the Pension Credit severe disability addition when a carer's Universal Credit award includes the carer element for the claimant or partner, and pays the carer premium in Scotland's working-age council tax reduction for such an award from 1 April 2022, counting an award of nil until 31 March 2025 (#2120, #2124). diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 96b91f99e3..40202f4e6c 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -626,3 +626,54 @@ def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): require(forgoes[0] and award[0] == 0 and bound[0] > 0, "the family claims") severe = sim.calculate("severe_disability_premium", YEAR) assert severe[1] == pytest.approx(86.05 * WEEKS) + + +def test_the_bound_reads_neither_the_award_nor_pension_credit(): + # The carer test reads uc_award_upper_bound because the award reads + # Pension Credit (through the non-dependant deduction's exemption), whose + # addition reads the attribution. A pensioner on Attendance Allowance + # shares a privately rented home with her blind son, who claims Universal + # Credit with the carer element; the trace of the bound reaches none of + # those variables. + forbidden = { + "pension_credit", + "uc_housing_costs_element", + "uc_non_dep_deductions", + "uc_non_dep_deduction_exempt", + "uc_maximum_amount", + "uc_income_reduction", + "universal_credit_pre_benefit_cap", + "universal_credit", + "is_cared_for_by_carer_benefit_recipient", + } + sim = Simulation( + situation=situation( + [ + dict( + country="ENGLAND", + tenure="RENT_PRIVATELY", + rent=9_000, + units=[ + dict(adults=[single(75, disability="aa_higher")], children=[]), + dict( + adults=[single(40, blind=True, care_hours=35)], + children=[], + ), + ], + ) + ] + ) + ) + sim.trace = True + sim.calculate("uc_award_upper_bound", YEAR) + reached = set() + + def visit(node): + reached.add(node.name) + for child in node.children: + visit(child) + + for tree in sim.tracer.trees: + visit(tree) + assert "uc_carer_element" in reached + assert not reached & forbidden, reached & forbidden From c477b5d9d1acbd4d99ecb762b5a4c250782ea710 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 03:22:44 -0500 Subject: [PATCH 050/168] Describe the award bound and the carer test in active voice Docstrings only: uc_award_upper_bound names who deducts, awards and sets the minimum, and has_uc_award_with_carer_element states the nil-bound rule plainly. Co-Authored-By: Claude Opus 5.5 --- .../has_uc_award_with_carer_element.py | 8 ++-- .../universal_credit/uc_award_upper_bound.py | 45 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index fe18c62e1d..97cd94c2d3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -30,10 +30,10 @@ class has_uc_award_with_carer_element(Variable): "the age, pension-route and capital conditions (is_uc_eligible) and " "has an award before the non-dependant deduction (uc_award_upper_bound " "positive), and the award includes the carer element in respect of " - "this claimant (uc_carer_element_included_for_claimant). Where the " - "bound is nil, the award is nil and there is no award (Welfare Reform " - "Act 2012 s.5(1)(b) and (2)(b); the minimum is one penny, reg 17), so " - "a carer whose income tapers the award to nil does not count. A carer " + "this claimant (uc_carer_element_included_for_claimant). A nil bound " + "leaves a nil award, and so no award (Welfare Reform Act 2012 s.5(1)(b) " + "and (2)(b); DWP sets the minimum at one penny, reg 17), so a carer " + "whose income tapers the award to nil does not count. A carer " "whose award a non-dependant deduction takes to nil still counts, and " "so does a family that takes Tax-Free Childcare instead " "(forgoes_universal_credit_for_tax_free_childcare reads the award). " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index 16f7cca626..2514014f1b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -6,27 +6,30 @@ class uc_award_upper_bound(Variable): entity = BenUnit label = "Universal Credit award before the non-dependant deduction" documentation = ( - "An amount the family's award before the benefit cap " - "(universal_credit_pre_benefit_cap) never exceeds, found without the " - "non-dependant deduction. The award is the maximum amount less the " - "income reduction (UC Regs 2013 reg 22). Its housing costs element " - "(uc_housing_costs_element) is the rent a renter may claim less the " - "non-dependant deductions, and its work allowance is the one with " - "housing where that element is positive. A deduction smaller than " - "the rent leaves the award at most what it would be with no " - "deduction; a deduction as large as the rent removes the element and " - "gives the work allowance without housing. This is the larger of " - "those two awards. It repeats the award's steps in the same order, " - "rounding where they store a value, so it is never below the award. " - "Where it is nil the family has no award, since an amount below one " - "penny is no award (Welfare Reform Act 2012 s.5(1)(b) and (2)(b); " - "reg 17). The deduction spares a non-dependant on Pension Credit, so " - "it reads Pension Credit, whose severe disability addition reads who " - "has an award with the carer element. This amount reads neither, so " - "that test can read it (has_uc_award_with_carer_element). It can be " - "positive where the award is nil: a non-dependant deduction can take " - "the award to nil, and a family that takes Tax-Free Childcare instead " - "gets none (forgoes_universal_credit_for_tax_free_childcare)." + "The family's award before the benefit cap " + "(universal_credit_pre_benefit_cap) never exceeds this amount, which " + "leaves out the non-dependant deduction. DWP awards the maximum " + "amount less the income reduction (UC Regs 2013 reg 22). The maximum " + "amount includes the housing costs element (uc_housing_costs_element): " + "the rent a renter may claim less DWP's non-dependant deductions. " + "Where that element is positive, DWP applies the work allowance with " + "housing. Where DWP deducts less than the rent, the award stays at or " + "below the award with no deduction; where it deducts at least the " + "rent, the award loses the housing costs element and takes the work " + "allowance without housing. This amount takes the larger of those two " + "awards. It repeats the award's steps in their order, rounding where " + "they store a value, so it never falls below the award. A family " + "whose bound comes to nil has a nil award, and so no award: " + "Parliament makes an amount payable of at least the prescribed " + "minimum a financial condition (Welfare Reform Act 2012 s.5(1)(b) " + "and (2)(b)), and DWP sets that minimum at one penny (reg 17). The " + "deduction spares a non-dependant on Pension Credit, so it reads " + "Pension Credit, whose severe disability addition reads who has an " + "award with the carer element. This amount reads neither, so that " + "test can read it (has_uc_award_with_carer_element). It can exceed a " + "nil award: a non-dependant deduction can take the award to nil, and " + "a family that takes Tax-Free Childcare instead gets none " + "(forgoes_universal_credit_for_tax_free_childcare)." ) definition_period = YEAR unit = GBP From 20edd2e7e7b76ce8a5d5d96330f6538061524ddf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 03:28:21 -0500 Subject: [PATCH 051/168] Give the carer docstrings their actors and the bound an exact label Docstrings and one label only. uc_award_upper_bound's label names it an upper bound on the award; the attribution, addition, CTR carer premium and CTR premiums docstrings name who reads or pays. Co-Authored-By: Claude Opus 5.5 --- .../severe_disability_minimum_guarantee_addition.py | 5 +++-- .../carer_element/has_uc_award_with_carer_element.py | 4 ++-- .../gov/dwp/universal_credit/uc_award_upper_bound.py | 2 +- .../council_tax_reduction_carer_premium.py | 8 ++++---- .../council_tax_reduction_premiums.py | 2 +- .../is_cared_for_by_carer_benefit_recipient.py | 10 +++++----- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 9afa73c9ae..b20611b3b8 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -29,8 +29,9 @@ class severe_disability_minimum_guarantee_addition(Variable): "is_cared_for_by_carer_benefit_recipient, which the legacy severe " "disability premium shares; a carer benefit there includes an award " "of Universal Credit which includes the carer element (para 1(4)), " - "read from the award's conditions and the award before any " - "non-dependant deduction (has_uc_award_with_carer_element). Blindness " + "which has_uc_award_with_carer_element reads from the award's " + "conditions and the award before any non-dependant deduction. " + "Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index 97cd94c2d3..98d426f738 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -28,8 +28,8 @@ class has_uc_award_with_carer_element(Variable): "reads the addition. So this reads the award's conditions instead. " "The family claims Universal Credit (claims_universal_credit), meets " "the age, pension-route and capital conditions (is_uc_eligible) and " - "has an award before the non-dependant deduction (uc_award_upper_bound " - "positive), and the award includes the carer element in respect of " + "has a positive upper bound on its award (uc_award_upper_bound), and " + "the award includes the carer element in respect of " "this claimant (uc_carer_element_included_for_claimant). A nil bound " "leaves a nil award, and so no award (Welfare Reform Act 2012 s.5(1)(b) " "and (2)(b); DWP sets the minimum at one penny, reg 17), so a carer " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index 2514014f1b..c408c27841 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -4,7 +4,7 @@ class uc_award_upper_bound(Variable): value_type = float entity = BenUnit - label = "Universal Credit award before the non-dependant deduction" + label = "Upper bound on the Universal Credit award" documentation = ( "The family's award before the benefit cap " "(universal_credit_pre_benefit_cap) never exceeds this amount, which " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index dfabb17911..0c439c7e16 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -8,7 +8,7 @@ class council_tax_reduction_carer_premium(Variable): label = "Working-age CTR carer premium" documentation = ( "The carer premium in a working-age Council Tax Reduction applicable " - "amount. Outside Scotland's working-age scheme it is carer_premium. " + "amount. Outside Scotland's working-age scheme it equals carer_premium. " "In Scotland's scheme (SSI 2021/249 Sch 1 paras 5 and 6, from 1 April " "2022) councils pay it in respect of each applicant or partner who " "has regular and substantial caring responsibilities for a severely " @@ -16,7 +16,7 @@ class council_tax_reduction_carer_premium(Variable): "as (a) receiving Carer's Allowance or Carer Support Payment, (b) " "being entitled to one that the overlapping benefits rules reduce to " "nil, or (c) having an award of Universal Credit which includes the " - "carer element. Limbs (a) and (b) are carer_premium's test " + "carer element. carer_premium tests limbs (a) and (b) " "(is_entitled_to_carer_benefit). For limb (c), the Scottish Ministers " "defined an award of Universal Credit to include one with a value of " "zero from 1 April 2022 (reg 4, as amended by SSI 2022/52 reg 3), and " @@ -24,8 +24,8 @@ class council_tax_reduction_carer_premium(Variable): "Act 2012 (SSI 2025/24 reg 8), under which an income that tapers the " "award to nil leaves no award (s.5(1)(b); UC Regs 2013 reg 17). So " "while the earlier definition holds " - "(uc_award_includes_zero_value_award), limb (c) reads the award's " - "conditions: the family claims Universal Credit, meets its basic " + "(uc_award_includes_zero_value_award), this variable reads limb (c) " + "from the award's conditions: the family claims Universal Credit, meets its basic " "conditions and does not take Tax-Free Childcare instead, and the " "award includes the carer element for this claimant. Afterwards it " "reads the positive award (receives_uc_carer_element). From 1 April " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index ae8018c0e3..912651908a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -22,7 +22,7 @@ class council_tax_reduction_premiums(Variable): "her non-dependant (everyone else in the household, since she has no " "partner or children, unless they receive a qualifying benefit or are " "blind); and the carer premium if she is entitled to a carer benefit. " - "At working age the benefit unit's carer premium is " + "At working age the benefit unit's carer premium comes from " "council_tax_reduction_carer_premium, which in Scotland also counts " "an award of Universal Credit which includes the carer element. " "Whom a carer benefit is paid for caring for comes from " diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index f730177fa1..582cf03c66 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -45,11 +45,11 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "Support Payment after the overlapping-benefit adjustment. A carer " "whose allowance an overlapping benefit, such as a higher State " "Pension, reduces to nil is entitled but not in receipt, so is not " - "attributed for the allowance. A carer element award is " - "has_uc_award_with_carer_element, read from the claim, the award's " - "conditions and the award before any non-dependant deduction, because " - "the Universal Credit non-dependant deduction exemption reads Pension " - "Credit, which reads the addition. A carer whose income tapers the " + "attributed for the allowance. has_uc_award_with_carer_element gives " + "a carer element award from the claim, the award's conditions and the " + "award before any non-dependant deduction, because the Universal " + "Credit non-dependant deduction exemption reads Pension Credit, which " + "reads the addition. A carer whose income tapers the " "award to nil has no award (Welfare Reform Act 2012 s.5) and is not " "attributed for it. A carer whose award a non-dependant deduction " "takes to nil, or whose family takes Tax-Free Childcare instead, is " From 16766c1e28f2de1675d71bec31fc6ea004bab836 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 03:35:24 -0500 Subject: [PATCH 052/168] Test pay that stops at the basic rate limit with nil-rate savings above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 12 sets £270 of the allowance against dividends above the basic rate limit, with £300 of savings between the pay and the dividends inside the savings allowance. The search finds that share only through the line where the pay reaches the basic rate limit. The changelog fragment now names the model as the actor. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2106.fixed.md | 2 +- .../least_tax_allocation.yaml | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md index 7329fd05e7..fb0a881aa8 100644 --- a/changelog.d/2106.fixed.md +++ b/changelog.d/2106.fixed.md @@ -1 +1 @@ -Income Tax now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), instead of always in the order non-savings, savings, dividends. +The model now deducts the Personal Allowance, other allowances and trade loss relief from non-savings, savings and dividend income so that the taxpayer pays the least tax (ITA 2007 s. 25(2)). From 2027-28 it takes them off other income before property, savings and dividend income (s. 25(3A)). It previously always deducted them from non-savings income first, then savings, then dividends. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 94e5ccba29..c6cbd7b012 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -218,3 +218,29 @@ received_allowances_dividend_income: 0 dividend_income_tax: 6_971.25 income_tax: 14_457.25 + +- name: Case 12, pay stops at the basic rate limit with nil-rate savings above it + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 300 + dividend_income: 20_000 + output: + # As in case 8, £270 of the allowance comes off dividends, which sit + # above the basic rate limit at 35.75%, rather than off pay at 20%, until + # the pay reaches the £37,700 limit; past it, pay would pay 40%. The + # £300 of savings sits between the pay and the dividends, within the + # £500 savings allowance of a higher rate taxpayer (s. 12B), so no + # allowance comes off it. Pay 37,700 x 20% = 7,540. Dividends of £19,730 + # from £38,000: £500 at the nil rate and (57,730 - 38,500) x 35.75% = + # 6,874.725. All the allowance against pay first would charge 7,486 + + # (57,730 - 38,230) x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 270 + savings_income_tax: 0 + earned_income_tax: 7_540 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 From 466519e490f3538abba639c7aba4754252d5ac16 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:06:47 -0500 Subject: [PATCH 053/168] Name who acts in the disclosed-limit test comments Comments only: DWP deducts and the council counts, and the disabled person keeps the premium in law. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_carer_element_award_properties.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 40202f4e6c..80d446d0bb 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -589,13 +589,14 @@ def tax_free_childcare_situation(): ), ) def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award(): - # The non-dependant's deduction (UC Regs 2013 Sch 4 paras 13 and 16; a - # blind non-dependant has no exemption, and the carer is no exempt renter - # under para 15) takes the carer's award to nil, so she has no award - # (Welfare Reform Act 2012 s.5(1)(b); reg 17). The disabled person is a - # joint tenant, not her non-dependant (para 9(2)(d)), and she is not his - # (HB Regs 2006 reg 3(2)(d)); a blind non-dependant does not bar the - # premium (Sch 3 para 14(4)). So the law pays him the premium. + # DWP's deduction for the non-dependant (UC Regs 2013 Sch 4 paras 13 and + # 16; blindness brings no exemption, and para 15 does not exempt the + # carer as a renter) takes the carer's award to nil, so she has no award + # (Welfare Reform Act 2012 s.5(1)(b); reg 17). DWP does not count the + # disabled person, a joint tenant, as her non-dependant (para 9(2)(d)), + # the council does not count her as his (HB Regs 2006 reg 3(2)(d)), and a + # blind non-dependant does not bar the premium (Sch 3 para 14(4)). So he + # keeps the premium in law. sim = Simulation(situation=non_dependant_situation()) award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) bound = sim.calculate("uc_award_upper_bound", YEAR) @@ -615,10 +616,10 @@ def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award() ), ) def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): - # The family is better off with the Tax-Free Childcare top-up, so it - # takes no award (forgoes_universal_credit_for_tax_free_childcare); with - # no award, no one has one in respect of caring for the disabled person, - # and the law pays him the premium. + # The family gets more from the Tax-Free Childcare top-up than from the + # award, so it takes no award (forgoes_universal_credit_for_tax_free_ + # childcare). No one then has an award in respect of caring for the + # disabled person, so he keeps the premium in law. sim = Simulation(situation=tax_free_childcare_situation()) forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) From e6ba856af975f8a88062b45a9884461b46f76c29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:15:18 -0500 Subject: [PATCH 054/168] Expect the least-tax share in the property income band base savings case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property income of £40,000 and savings of £20,000 in 2026-27: under ITA 2007 s. 25(2) the model sets £10,270 of the Personal Allowance against savings, which keeps the property income at the basic rate limit and puts the £500 savings allowance against savings taxed at 40%. Savings tax falls from £5,846 to £3,692 and income tax from £11,332 to £11,232. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/property_income_band_base.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 5a7f5ad0fd..7e704dc489 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -39,9 +39,15 @@ members: [person] country: ENGLAND output: - # £500 savings allowance, then 9,770 at 20% (1,954) to the basic rate - # limit and 9,730 at 40% (3,892). - savings_income_tax: 5_846 + # ITA 2007 s. 25(2): the Personal Allowance keeps the property income up + # to the basic rate limit, £37,700, and the other £10,270 comes off + # savings, so the £500 savings allowance covers savings taxed at 40% + # rather than 20%. Savings left: 9,730, less £500 at the nil rate + # (s. 12A), at 40%: 3,692. Property income 37,700 at 20%: 7,540. The old + # order, with all the allowance against property income, charged 5,486 + # and 5,846: £100 more. + savings_income_tax: 3_692 + income_tax: 7_540 + 3_692 - name: Case 3, pay and property income together form the band base. period: 2026 From 09e0c0872b8e07737714fc1ea11ec95ee2b64afa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:59:44 -0500 Subject: [PATCH 055/168] Compare whole Pension Credit definitions before inferring nil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pension_credit_follows_model_formulas now asks replaces_definition about all five Pension Credit variables. It compares every attribute Core calculates a variable from (formula for the period, default, defined_for, adds and subtracts, period, neutralization, end) with the model's own class, and counts an input for the period. A reform that keeps the formula but gives a family outside defined_for a default of £2,000 now drops the age and take-up conditions, so the man aged 40 who would get that £2,000 gets no ESA. Tests: the four reforms (entitlement default, entitlement default with another payment formula, payment default, eligibility default), a reform that changes only the guarantee credit's coverage, the model's own definitions registered again (shortcut kept), and an exhaustive change of each attribute of each variable. The parameter reform test now also asserts the shortcut on the lifted couple. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 215 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 72 ++++-- .../variables/gov/dwp/esa_income_eligible.py | 41 ++-- 3 files changed, 285 insertions(+), 43 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 67e808d202..7cc517d9be 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -64,9 +64,13 @@ branch inside it reads the couple's award as nil, and the screen reads no Pension Credit condition without a couple's claim that passes the other tests. Others enter Pension Credit, or a part of it, as an input, or reform -it: an entered award, a reform that pays every family and a frozen award each +it: an entered award, a reform that pays every family, a frozen award and a +reform that changes the default or coverage of a Pension Credit variable each make the model read the award for every couple, and a parameter reform keeps -the age and take-up conditions. +the age and take-up conditions. One test changes each attribute of each +Pension Credit variable in turn, and requires the model to drop the age and +take-up conditions for every change to what Core calculates the variable +from. """ import numpy as np @@ -82,8 +86,15 @@ from policyengine_uk.tests.legacy_award_work_reference import esa_screen from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( NO_INCOME_RELATED_ESA_BRANCH, + PENSION_CREDIT_VARIABLES, pension_credit_follows_model_formulas, ) +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit as model_guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible as model_eligibility, +) from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( pension_credit as model_pension_credit, ) @@ -683,6 +694,7 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): assert no_branch_opens_beside(log) lifted = Simulation(situation=combine(working_age, mixed), reform=reform) + assert follows_model_formulas(lifted, year) assert lifted.calculate("meets_pension_credit_age_conditions", year).tolist() == [ False, True, @@ -763,6 +775,205 @@ def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_condition ) +def follows_model_formulas(sim, year): + return pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class entitlement_with_a_default(Reform): + """The model's Pension Credit entitlement, which gives £2,000 to a family + outside its defined_for.""" + + def apply(self): + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(pension_credit_entitlement) + + +class payment_with_a_default(Reform): + """The model's Pension Credit formula for a family eligible for Pension + Credit, and £2,000 for any other.""" + + def apply(self): + class pension_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + formula = model_pension_credit.formula + + self.update_variable(pension_credit) + + +class eligibility_with_a_default(Reform): + """The model's eligibility formula for a family that meets the age + conditions, and eligibility for any other.""" + + def apply(self): + class is_pension_credit_eligible(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + default_value = True + defined_for = "meets_pension_credit_age_conditions" + formula = model_eligibility.formula + + self.update_variable(is_pension_credit_eligible) + + +class guarantee_credit_for_every_family_it_tests(Reform): + """The model's guarantee credit and entitlement for every family with + income within the minimum guarantee (is_guarantee_credit_eligible), not + only one eligible for Pension Credit. Only their coverage changes.""" + + def apply(self): + class guarantee_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + formula = model_guarantee_credit.formula + + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(guarantee_credit) + self.update_variable(pension_credit_entitlement) + + +class model_pension_credit_registered_again(Reform): + """The model's own five Pension Credit variables, registered again.""" + + def apply(self): + for variable in PENSION_CREDIT_VARIABLES: + self.update_variable(variable) + + +@pytest.mark.parametrize( + "reform, follows, pension_credit_at_40, esa", + [ + (entitlement_with_a_default, False, 2_000, [5_000, 0, 0]), + ( + (entitlement_with_a_default, pension_credit_by_another_formula), + False, + 2_000, + [5_000, 0, 0], + ), + (payment_with_a_default, False, 2_000, [5_000, 0, 0]), + (eligibility_with_a_default, False, 8_091.20, [5_000, 0, 0]), + (guarantee_credit_for_every_family_it_tests, False, 8_091.20, [5_000, 0, 0]), + (model_pension_credit_registered_again, True, 0, [5_000, 5_000, 0]), + ], + ids=[ + "entitlement_default", + "entitlement_default_another_formula", + "payment_default", + "eligibility_default", + "guarantee_credit_coverage", + "model_definitions_again", + ], +) +def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( + reform, follows, pension_credit_at_40, esa +): + """In 2016 the man aged 63 who would not claim Pension Credit gets none + and keeps his £5,000 award, and the man aged 63 who would claim gets the + £8,091.20 minimum guarantee (52 x £155.60) and no award. The man aged 40 + does not meet the age conditions. Under each reform but the last, a + family outside the definition's defined_for gets something other than + the model's: Pension Credit pays him £2,000 or the minimum guarantee, so + he has an entitlement to it (Sch 1 para 6(1)(c)) and gets no award. The + model's own definitions, registered again, keep the shortcut, and he + keeps his £5,000.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert follows_model_formulas(sim, year) is follows + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + False, + True, + ] + assert sim.calculate("pension_credit", year) == pytest.approx( + [0, pension_credit_at_40, 8_091.20], abs=0.01 + ) + assert sim.calculate("esa_income", year).tolist() == esa + + +def wrapped(formula): + def wrapper(entity, period, parameters): + return formula(entity, period, parameters) + + return wrapper + + +def changes(variable): + """For each attribute a reform can change, the attribute, a new value and + whether the change can alter what Core calculates.""" + formula = wrapped(variable.get_formula() or model_pension_credit.formula) + default = True if variable.value_type is bool else 2_000.0 + return [ + ("default_value", default, True), + ("defined_for", "is_guarantee_credit_eligible", True), + ("is_neutralized", True, True), + ("formulas", {"0001-01-01": formula}, True), + ("adds", ["guarantee_credit"], True), + ("definition_period", "month", True), + ("end", period_(2010).start.date, True), + ("label", "Changed", False), + ("documentation", "Changed.", False), + ("reference", ("https://www.legislation.gov.uk/ukpga/2002/16",), False), + ("unit", "currency-USD", False), + ("metadata", {"changed": True}, False), + ] + + +def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): + """Exhaustive over the five Pension Credit variables and the attributes a + reform can change: any change to what Core calculates a variable from + drops the age and take-up conditions, and a change to its description + alone keeps them.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + system = sim.tax_benefit_system + assert follows_model_formulas(sim, year) + for model_variable in PENSION_CREDIT_VARIABLES: + name = model_variable.__name__ + variable = system.variables[name] + for attribute, value, calculates in changes(variable): + changed = variable.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables[name] = changed + try: + assert follows_model_formulas(sim, year) is not calculates, ( + name, + attribute, + ) + finally: + system.variables[name] = variable + assert follows_model_formulas(sim, year) + + class ScreenReads: """Records each variable the income-related ESA screen reads, with the branch and year it reads in. A branch can change the family: the one that diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 74ca59f443..2496f50dad 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -33,6 +33,7 @@ the record. """ +from functools import cache from typing import List, Optional import numpy as np @@ -205,26 +206,63 @@ def stored_as_input(population, variable_name: str, period: Period) -> bool: ) -def replaces_formula( - population, variable_name: str, period: Period, default_formula -) -> bool: - """Whether the simulation gives ``variable_name`` for ``period`` a value - other than what ``default_formula`` calculates: a reform neutralizes the - variable, removes its formula for the period or gives it another, or a - caller set an input for the period (``stored_as_input``), as a scenario - does with its own value (``set_model_value``). A cached formula result is - not an input, even in populations taken from another simulation or in - their clones and branches. Housing Benefit's hb_uses_annual_override - makes the same test for its annual amounts.""" - variable = population.simulation.tax_benefit_system.get_variable(variable_name) +# Variable attributes that describe a variable without changing what Core +# calculates for it. +DESCRIPTIVE_ATTRIBUTES = frozenset( + { + "_explicit_attribute_names", + "baseline_variable", + "category", + "cerfa_field", + "documentation", + "hidden_input", + "index_in_module", + "label", + "metadata", + "module_name", + "reference", + "unit", + } +) + + +def calculation_definition(variable, period: Period) -> dict: + """Every attribute Core calculates ``variable`` from for ``period``: all + but the descriptive ones, with the formula for the period, by its code, + in place of the formulas, and the entity by its key.""" + definition = { + name: value + for name, value in vars(variable).items() + if name not in DESCRIPTIVE_ATTRIBUTES | {"entity", "formulas"} + } formula = variable.get_formula(period) - if ( - variable.is_neutralized - or formula is None - or formula.__code__ != default_formula.__code__ + definition["formula"] = None if formula is None else formula.__code__ + definition["entity"] = variable.entity.key + return definition + + +@cache +def _model_variable(variable_class): + return variable_class() + + +def replaces_definition(population, variable_class, period: Period) -> bool: + """Whether the simulation gives the variable that ``variable_class`` + defines a value for ``period`` other than that definition calculates. A + reform can change anything Core calculates it from: neutralize it, give + it another formula for the period or none, or change its default, the + variable it is defined for, what it sums or its period. Or a caller can + set an input for the period (``stored_as_input``), as a scenario does + with its own value (``set_model_value``). A cached formula result is not + an input, even in populations taken from another simulation or in their + clones and branches.""" + name = variable_class.__name__ + variable = population.simulation.tax_benefit_system.get_variable(name) + if calculation_definition(variable, period) != calculation_definition( + _model_variable(variable_class), period ): return True - return stored_as_input(population, variable_name, period) + return stored_as_input(population, name, period) def supplied_input( diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index d4b600e741..0ed7374b02 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.supplied_inputs import replaces_formula, stored_as_input +from policyengine_uk.utils.supplied_inputs import replaces_definition from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( guarantee_credit, ) @@ -19,10 +19,11 @@ # The branch in which the family has no income-related ESA. NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" -# The Pension Credit variables whose formulas the model's age and take-up -# conditions govern, with pension_credit_entitlement, which sums two of them. -PENSION_CREDIT_FORMULAS = [ +# The Pension Credit variables whose definitions pay Pension Credit only to a +# family that meets the age conditions and would claim. +PENSION_CREDIT_VARIABLES = [ pension_credit, + pension_credit_entitlement, is_pension_credit_eligible, guarantee_credit, savings_credit, @@ -38,17 +39,17 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): pension_credit equals the entitlement where the family would claim and nil otherwise. The entitlement sums the guarantee and savings credits for a family eligible for Pension Credit (is_pension_credit_eligible), and - nil for any other, and only a family that meets the age conditions is - eligible. A parameter reform keeps these formulas, and the age conditions - read its parameters. + gives nil to any other, and only a family that meets the age conditions + is eligible. A parameter reform keeps these definitions, and the age + conditions read its parameters. A caller can instead set pension_credit, its entitlement, its eligibility - or either credit as an input for the period. A reform can replace or - remove one of their formulas, change what the entitlement sums or which - families it covers, or freeze Pension Credit at the baseline's award - (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. - Pension Credit can then pay a family that does not meet the age - conditions or would not claim. + or either credit as an input for the period. A reform can change anything + Core calculates one of them from (replaces_definition), such as the + default a family outside its defined_for gets, or freeze Pension Credit + at the baseline's award (gov.contrib.freeze_pension_credit) whatever the + reform's age conditions. Pension Credit can then pay a family that does + not meet the age conditions or would not claim. """ simulation = benunit.simulation if ( @@ -56,17 +57,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): and simulation.baseline is not None ): return False - for default in PENSION_CREDIT_FORMULAS: - if replaces_formula(benunit, default.__name__, period, default.formula): - return False - name = pension_credit_entitlement.__name__ - entitlement = simulation.tax_benefit_system.get_variable(name) - return ( - not entitlement.is_neutralized - and entitlement.get_formula(period) is None - and entitlement.adds == pension_credit_entitlement.adds - and entitlement.defined_for == pension_credit_entitlement.defined_for - and not stored_as_input(benunit, name, period) + return not any( + replaces_definition(benunit, variable, period) + for variable in PENSION_CREDIT_VARIABLES ) From 3fc1e1d7891c01cd43b1ba280f045879de53a6fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:00:22 -0500 Subject: [PATCH 056/168] Read Pension Credit for every couple when take-up has a formula The screen read would_claim_pc in the simulation itself to skip couples that would not claim. A reform that gives take-up a formula reading the family's ESA then closed esa_income -> esa_income_eligible -> would_claim_pc -> esa_income. pension_credit_follows_model_formulas now returns false when would_claim_pc has a formula for the period, so the screen reads Pension Credit without the award for every couple whose claim passes the other tests. Supplied take-up and parameter reforms keep the shortcut. Tests: the take-up formula and its control with Pension Credit by another formula (ESA [0, 5000, 0], Pension Credit [8091.20, 0, 8091.20]), and the exhaustive guard test's take-up rows (formula drops the shortcut; default and input keep it). Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_pension_age_properties.py | 88 +++++++++++++++++-- .../variables/gov/dwp/esa_income_eligible.py | 11 ++- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 7cc517d9be..fcb6be45be 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -64,13 +64,13 @@ branch inside it reads the couple's award as nil, and the screen reads no Pension Credit condition without a couple's claim that passes the other tests. Others enter Pension Credit, or a part of it, as an input, or reform -it: an entered award, a reform that pays every family, a frozen award and a -reform that changes the default or coverage of a Pension Credit variable each -make the model read the award for every couple, and a parameter reform keeps -the age and take-up conditions. One test changes each attribute of each -Pension Credit variable in turn, and requires the model to drop the age and -take-up conditions for every change to what Core calculates the variable -from. +it: an entered award, a reform that pays every family, a frozen award, a +reform that changes the default or coverage of a Pension Credit variable and +a reform that gives take-up a formula each make the model read the award for +every couple, and a parameter reform keeps the age and take-up conditions. +One test changes each attribute of each Pension Credit variable in turn, and +requires the model to drop the age and take-up conditions for every change to +what Core calculates the variable from. """ import numpy as np @@ -918,6 +918,55 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( assert sim.calculate("esa_income", year).tolist() == esa +class take_up_without_income_related_esa(Reform): + """A family claims Pension Credit while it gets no income-related ESA.""" + + def apply(self): + class would_claim_pc(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + + def formula(benunit, period, parameters): + return benunit("esa_income", period) == 0 + + self.update_variable(would_claim_pc) + + +@pytest.mark.parametrize( + "reform", + [ + take_up_without_income_related_esa, + (take_up_without_income_related_esa, pension_credit_by_another_formula), + ], + ids=["take_up_formula", "take_up_formula_another_formula"], +) +def test_a_take_up_formula_makes_the_model_read_pension_credit_for_every_couple( + reform, +): + """Take-up that reads the family's ESA would close a cycle if the screen + read take-up in the simulation itself, so the model reads Pension Credit + without the award for every couple. Without the award both men aged 63 + in 2016 would claim and get the £8,091.20 minimum guarantee, so neither + gets the award, and both then claim. The man aged 40 gets no Pension + Credit, so he keeps his £5,000 and does not claim.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert not follows_model_formulas(sim, year) + assert sim.calculate("esa_income", year).tolist() == [0, 5_000, 0] + assert sim.calculate("pension_credit", year) == pytest.approx( + [8_091.20, 0, 8_091.20], abs=0.01 + ) + assert sim.calculate("would_claim_pc", year).tolist() == [True, False, True] + + def wrapped(formula): def wrapper(entity, period, parameters): return formula(entity, period, parameters) @@ -950,7 +999,8 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): """Exhaustive over the five Pension Credit variables and the attributes a reform can change: any change to what Core calculates a variable from drops the age and take-up conditions, and a change to its description - alone keeps them.""" + alone keeps them. A take-up formula drops them too, and a take-up input or + default keeps them, since the screen reads take-up itself.""" year = 2016 sim = Simulation(situation=household([MAN_40], year)) system = sim.tax_benefit_system @@ -971,6 +1021,28 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): ) finally: system.variables[name] = variable + take_up = system.variables["would_claim_pc"] + for attribute, value, follows in [ + ("formulas", {"0001-01-01": lambda benunit, period, parameters: True}, False), + ("default_value", False, True), + ]: + changed = take_up.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables["would_claim_pc"] = changed + try: + assert follows_model_formulas(sim, year) is follows, attribute + finally: + system.variables["would_claim_pc"] = take_up + branch = sim.get_branch("entered_take_up") + try: + branch.set_input("would_claim_pc", year, np.array([False])) + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), system.parameters + ) + finally: + sim.branches.pop("entered_take_up", None) assert follows_model_formulas(sim, year) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 0ed7374b02..fa776a77b5 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -49,7 +49,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): default a family outside its defined_for gets, or freeze Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) whatever the reform's age conditions. Pension Credit can then pay a family that does - not meet the age conditions or would not claim. + not meet the age conditions or would not claim. A reform can also give + would_claim_pc a formula, which could read the couple's ESA; the screen + would then close a cycle if it read take-up in the simulation itself. """ simulation = benunit.simulation if ( @@ -57,10 +59,13 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): and simulation.baseline is not None ): return False - return not any( + if any( replaces_definition(benunit, variable, period) for variable in PENSION_CREDIT_VARIABLES - ) + ): + return False + take_up = simulation.tax_benefit_system.get_variable("would_claim_pc") + return take_up.get_formula(period) is None def pension_credit_without_income_related_esa(benunit, period): From 31d90e93b37be71f794219661d1e076bea2a158c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:00:33 -0500 Subject: [PATCH 057/168] Name who acts in the ESA bar's comments and test notes DWP applies the higher earnings disregard while the claimant or partner receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)); a member outside the couple keeps their own claim; the 40-year-old claims in their own right; nobody owes income tax or primary Class 1 contributions in cases 13 and 20. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 6 ++-- .../esa_income/esa_income_pension_age.yaml | 28 ++++++++++--------- .../test_esa_income_pension_age_properties.py | 13 +++++---- .../variables/gov/dwp/esa_income_eligible.py | 10 +++---- 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 2c5a45f939..d3e7bc8bf5 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -42,9 +42,9 @@ couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 s.1(6)), so each of them has reached pensionable age and has no claim - under s.1(3)(c), whether or not Pension Credit pays. Pension Credit - entitlement ends only the couple's claim, not that of a member claiming in - their own right. + under s.1(3)(c), whether or not Pension Credit pays. A member outside the + couple keeps their own claim even when the couple has an entitlement to + Pension Credit. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index a5a1bbc8ae..e2518410b1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -388,8 +388,8 @@ # The 70-year-old is a single claimant of Pension Credit: 52 x 238.00. meets_pension_credit_age_conditions: true pension_credit: 12_376 - # The 40-year-old is not a member of the 70-year-old's couple, so para - # 6(1)(d) does not reach them: their own award of 5_000. + # The 40-year-old is not a member of the 70-year-old's couple, claims in + # their own right and keeps their award of 5_000 (para 6(1)(d)). esa_income_eligible: true esa_income: 5_000 claimant_or_partner_esa_income: 0 @@ -429,7 +429,7 @@ # payable to them. is_on_income_related_esa: [false, true] -- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple's award brings neither benefit cap exception. +- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple gets neither benefit cap exception. period: 2026 absolute_error_margin: 0.01 input: @@ -469,9 +469,10 @@ # Regs 2002 reg 6(1)(b)), and DWP disregards £5 a week of a single claimant's # earnings (Sch VI para 5), or £20 a week while the claimant is on employment # and support allowance (para 4(1)(a)(vii)). Below 65 no savings -# credit is due (SPCA 2002 s.3(1)). His income is below the personal -# allowance (£11,000) and his earnings below the National Insurance primary -# threshold (£155 a week), so neither takes anything off. +# credit is due (SPCA 2002 s.3(1)). His income stays under the personal +# allowance (£11,000) and his earnings under the National Insurance primary +# threshold (£155 a week), so he owes no income tax or primary Class 1 +# contributions. - name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 @@ -592,9 +593,10 @@ output: # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state - # is consistent: Pension Credit would end the award, and without the - # award Pension Credit pays nothing. B(0) pays nothing, so the award - # stands, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 = 525.20. + # is consistent: with Pension Credit in payment, the claimant could not + # have ESA; without ESA, he gets no Pension Credit. With B(0) nil, the + # model pays the award, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 + # = 525.20. esa_income_eligible: true esa_income: 5_491.20 pension_credit_earnings_disregard: 1_040 @@ -681,10 +683,10 @@ output: # In 2018-19 the couple minimum guarantee is £248.80 a week. B(0): 200 + # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so - # nil either way. (Each member's income is under the £11,850 personal - # allowance, and £124 a week is under the £162 National Insurance primary - # threshold, so neither takes anything off.) The younger member meets - # para 6(1)(d). + # nil either way. (Each member's income stays under the £11,850 personal + # allowance, and £124 a week under the £162 National Insurance primary + # threshold, so neither member owes income tax or primary Class 1 + # contributions.) The younger member meets para 6(1)(d). pension_credit: 0 esa_income: 2_537.60 claimant_or_partner_esa_income: 2_537.60 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index fcb6be45be..4ac452cf73 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -27,9 +27,11 @@ (is_on_income_related_esa), and a family whose every report comes from such a member gets none. -A family that gets Pension Credit only with the award, through the higher -earnings disregard the award brings, gets both: no state is consistent there. -The test counts such families and asserts nothing more of them. +DWP disregards £20 a week of earnings while the claimant or partner +receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), so some families get +Pension Credit only with the award. Such a family gets both: no state is +consistent there. The test counts such families and asserts nothing more of +them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -40,8 +42,9 @@ Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report working tax credit, which HMRC pays without its income test to a -family on the award (TCA 2002 s.7(2)). A member outside the couple claims in their own right: the couple's -Pension Credit leaves their claim alone, and their pensionable age does not. +family on the award (TCA 2002 s.7(2)). A member outside the couple claims in +their own right and keeps that claim while below pensionable age, whatever +the couple's Pension Credit. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index fa776a77b5..bb061d6789 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -145,11 +145,11 @@ class esa_income_eligible(Variable): "model's own formulas calculate Pension Credit " "(pension_credit_follows_model_formulas), they pay it only to a couple that " "meets the Pension Credit age conditions (meets_pension_credit_age_conditions) " - "and would claim, so the screen works it out only for such a couple. Where " - "the family gets no Pension Credit without the award but gets it with the " - "award, through the higher earnings disregard the award brings (SPC Regs 2002 " - "Sch VI para 4(1)(a)(vii)), no consistent state exists, and the family gets " - "both. A member who claims in their own right keeps their claim whatever the " + "and would claim, so the screen works it out only for such a couple. DWP " + "disregards £20 a week of earnings while the claimant or partner receives " + "ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Where the family gets Pension " + "Credit only with that disregard, no consistent state exists, and the family " + "gets both. A member who claims in their own right keeps their claim whatever the " "couple's Pension Credit. The claimant must not engage in remunerative work " "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " From cf7153e53b519feb25693201252e3c3dc7e59b0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:40:07 -0500 Subject: [PATCH 058/168] Check the awards before the shortcut flag in the new reform tests A mutant that keeps the shortcut then fails on the award it pays, or on the cycle it closes, rather than on the flag. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 4ac452cf73..dea6ea27fb 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -909,16 +909,16 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( ), reform=reform, ) - assert follows_model_formulas(sim, year) is follows + assert sim.calculate("esa_income", year).tolist() == esa + assert sim.calculate("pension_credit", year) == pytest.approx( + [0, pension_credit_at_40, 8_091.20], abs=0.01 + ) assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ True, False, True, ] - assert sim.calculate("pension_credit", year) == pytest.approx( - [0, pension_credit_at_40, 8_091.20], abs=0.01 - ) - assert sim.calculate("esa_income", year).tolist() == esa + assert follows_model_formulas(sim, year) is follows class take_up_without_income_related_esa(Reform): @@ -962,12 +962,12 @@ def test_a_take_up_formula_makes_the_model_read_pension_credit_for_every_couple( ), reform=reform, ) - assert not follows_model_formulas(sim, year) assert sim.calculate("esa_income", year).tolist() == [0, 5_000, 0] assert sim.calculate("pension_credit", year) == pytest.approx( [8_091.20, 0, 8_091.20], abs=0.01 ) assert sim.calculate("would_claim_pc", year).tolist() == [True, False, True] + assert not follows_model_formulas(sim, year) def wrapped(formula): From eec9f6480ceaa5990d8f4420477cf9995da8ac45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:17:57 -0500 Subject: [PATCH 059/168] Name the five Pension Credit variables in the exhaustive guard test The test looped over the model's own list, so a variable dropped from that list dropped out of the test too. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index dea6ea27fb..a48648faf2 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -998,6 +998,16 @@ def changes(variable): ] +# The five Pension Credit variables, named apart from the model's own list. +PENSION_CREDIT_NAMES = [ + "pension_credit", + "pension_credit_entitlement", + "is_pension_credit_eligible", + "guarantee_credit", + "savings_credit", +] + + def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): """Exhaustive over the five Pension Credit variables and the attributes a reform can change: any change to what Core calculates a variable from @@ -1008,8 +1018,7 @@ def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): sim = Simulation(situation=household([MAN_40], year)) system = sim.tax_benefit_system assert follows_model_formulas(sim, year) - for model_variable in PENSION_CREDIT_VARIABLES: - name = model_variable.__name__ + for name in PENSION_CREDIT_NAMES: variable = system.variables[name] for attribute, value, calculates in changes(variable): changed = variable.clone() From 4522b5d5104cfdbfb743825a2be7028658138ebb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:41:28 -0500 Subject: [PATCH 060/168] Restate the property income band base properties for the least-tax share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under ITA 2007 s. 25(2) the model can set allowances against savings or dividends while property income is left, so: - the accounting invariant subtracts the savings and dividends left after the allowances set against them; - the monotonic invariant tests income tax, since more property income can lower savings tax: property income of £30,000 to £40,000 with savings of £20,000 takes savings tax from £3,800 to £3,692 while income tax rises from £7,286 to £11,232, which a new test pins; - the higher-rate check pins savings tax of £3,692, income tax of £11,232 and non-savings taxable income of £37,700 for property income of £40,000 and savings of £20,000. The file now loads the model once through cloned_uk_tax_benefit_system, as other test files do. Co-Authored-By: Claude Opus 5.5 --- ...st_property_income_band_base_properties.py | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index 7d5730f867..6eb0d6ac0e 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -11,19 +11,28 @@ Scotland. (The taxable amount, because the £1,000 property allowance can relieve small property income in full.) 2. Accounting: with no Marriage Allowance, non_savings_taxable_income equals - adjusted net income less savings, dividends and allowances, floored at nil - (earned_taxable_income also subtracts Marriage Allowance; #1947). -3. Monotonic: more property income never lowers savings or dividend tax. + adjusted net income less the allowances and the savings and dividends left + after the allowances set against them, floored at nil + (earned_taxable_income also subtracts Marriage Allowance; #1947). ITA 2007 + s. 25(2) can set allowances against savings or dividends while + non-savings income is left, so the savings and dividends left, not their + full amounts, sit above it. +3. Monotonic: more property income never lowers the income tax. Savings or + dividend tax alone can fall: s. 25(2) can move allowances from property + income to savings once the property income reaches the basic rate limit. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) """ import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PEOPLE = 10 amount = st.integers(min_value=0, max_value=200_000) @@ -41,6 +50,9 @@ "taxable_savings_interest_income", "taxable_dividend_income", "allowances", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "income_tax", ] @@ -100,8 +112,11 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): expected = np.maximum( 0, v["adjusted_net_income"] - - v["taxable_savings_interest_income"] - - v["taxable_dividend_income"] + - ( + v["taxable_savings_interest_income"] + - v["received_allowances_savings_income"] + ) + - (v["taxable_dividend_income"] - v["received_allowances_dividend_income"]) - v["allowances"], ) np.testing.assert_allclose(v["non_savings_taxable_income"], expected, atol=0.01) @@ -109,19 +124,39 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): @SETTINGS @given(rows=rows_strategy, rise=st.integers(min_value=1, max_value=100_000)) -def test_more_property_income_never_lowers_savings_or_dividend_tax(rows, rise): +def test_more_property_income_never_lowers_income_tax(rows, rise): before = _calculate(rows, "ENGLAND") after = _calculate( [(pay, prop + rise, s, d) for pay, prop, s, d in rows], "ENGLAND" ) - for name in ["savings_income_tax", "dividend_income_tax"]: - assert np.all(after[name] >= before[name] - 0.01) + assert np.all(after["income_tax"] >= before["income_tax"] - 0.01) def test_savings_above_property_income_past_the_basic_rate_limit_pay_the_higher_rate(): - """£40,000 of property income leaves £10,270 of the basic-rate band, so - some of £20,000 of savings must pay 40%: savings tax exceeds 20% of - everything above the £500 savings allowance (£3,900). Main banded the - savings as if the property income were not there and charged £2,900.""" + """£40,000 of property income and £20,000 of savings. ITA 2007 s. 25(2) + sets £2,300 of the Personal Allowance against the property income, which + leaves it at the £37,700 basic rate limit, and the other £10,270 against + savings. All the savings left, £9,730, sit above the limit: £500 at the + nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once banded the savings + as if the property income were not there and charged £2,900.""" v = _calculate([(0, 40_000, 20_000, 0)] + [(0, 0, 0, 0)] * (PEOPLE - 1), "ENGLAND") - assert v["savings_income_tax"][0] > (20_000 - 500) * 0.20 + np.testing.assert_allclose(v["savings_income_tax"][0], 9_230 * 0.40, atol=0.01) + np.testing.assert_allclose(v["income_tax"][0], 37_700 * 0.20 + 3_692, atol=0.01) + # Non-savings taxable income is 37,700, not 60,000 - 20,000 - 12,570 = + # 27,430: invariant 2 subtracts only the savings left. + np.testing.assert_allclose(v["non_savings_taxable_income"][0], 37_700, atol=0.01) + + +def test_more_property_income_can_lower_savings_tax(): + """Why invariant 3 tests the income tax. With £30,000 of property income + and £20,000 of savings, taxable income of £37,430 stays within the basic + rate band, so the savings allowance is £1,000 (s. 12B(4)) and no share + changes the tax: £19,000 of savings at 20%, £3,800. £10,000 more property + income gives the £3,692 of the test above: savings tax falls by £108 while + income tax rises from £7,286 to £11,232.""" + before = _calculate([(0, 30_000, 20_000, 0)] * PEOPLE, "ENGLAND") + after = _calculate([(0, 40_000, 20_000, 0)] * PEOPLE, "ENGLAND") + np.testing.assert_allclose(before["savings_income_tax"], 3_800, atol=0.01) + np.testing.assert_allclose(after["savings_income_tax"], 3_692, atol=0.01) + np.testing.assert_allclose(before["income_tax"], 17_430 * 0.20 + 3_800, atol=0.01) + np.testing.assert_allclose(after["income_tax"], 7_540 + 3_692, atol=0.01) From abdcede2d5e084134c32978680ed6049e2baec3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:47:00 -0500 Subject: [PATCH 061/168] Name who acts in the remaining new comments and docstrings The generators draw tax-credit reports; Core reads a branch's inputs from its parent branches; the model pays the reported award and applies no Universal Credit cap in case 20. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 6 +++--- .../esa_income/esa_income_pension_age.yaml | 12 ++++++------ policyengine_uk/utils/supplied_inputs.py | 18 +++++++++--------- .../variables/gov/dwp/esa_income_eligible.py | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index d3e7bc8bf5..0630d8adea 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -200,9 +200,9 @@ def legacy_benefits_closed(couple, others, parameters): Reports by other members neither close nor shield it (SI 2014/1230 regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a - notice/deadline model. Tax-credit reports are generated only in years - with tax credits, in which they close nothing. Ages avoid the changing - Pension Credit qualifying-age boundary. + notice/deadline model. The generators draw tax-credit reports only in + years with tax credits, in which they close nothing. Ages avoid the + changing Pension Credit qualifying-age boundary. """ dwp = parameters.gov.dwp members = couple + others diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index e2518410b1..87e715ef82 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -11,11 +11,11 @@ # get with the award e. A mixed-age couple has no entitlement to State Pension # Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it # keeps the SI 2019/37 art 4 saving. -# Everyone here is a legacy claimant (would_claim_uc false), since a couple -# that claims Universal Credit loses its income-related ESA anyway. No case has -# capital, so no tariff income: an award that passes is the amount reported. -# Pension Credit with no income is the guarantee credit's standard minimum -# guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and +# Nobody here claims Universal Credit (would_claim_uc false), since a couple +# that claims it loses its income-related ESA anyway. No case has capital, so +# no tariff income: the model pays an award that passes at the amount +# reported. With no income, a family gets the guarantee credit's standard +# minimum guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and # £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social # Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so # 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here in 2026 reached @@ -695,7 +695,7 @@ # reg 83(1): the award with its support component. is_uc_benefit_cap_exempt_specified_benefit: true is_housing_benefit_benefit_cap_exempt_specified_benefit: true - # So no cap applies: uc_benefit_cap is unlimited. + # So the model applies no Universal Credit benefit cap. is_uc_benefit_cap_exempt: true # Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 2496f50dad..3328cbaafb 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -247,15 +247,15 @@ def _model_variable(variable_class): def replaces_definition(population, variable_class, period: Period) -> bool: - """Whether the simulation gives the variable that ``variable_class`` - defines a value for ``period`` other than that definition calculates. A - reform can change anything Core calculates it from: neutralize it, give - it another formula for the period or none, or change its default, the - variable it is defined for, what it sums or its period. Or a caller can - set an input for the period (``stored_as_input``), as a scenario does - with its own value (``set_model_value``). A cached formula result is not - an input, even in populations taken from another simulation or in their - clones and branches.""" + """Whether the simulation can give the variable that ``variable_class`` + defines a value for ``period`` other than the one that definition + calculates. A reform can change anything Core calculates it from: + neutralize it, give it another formula for the period or none, or change + its default, its defined_for, what it sums or its period. A caller can + also set an input for the period (``stored_as_input``), as a scenario + does with its own value (``set_model_value``). A cached formula result is + not an input, even in populations taken from another simulation or in + their clones and branches.""" name = variable_class.__name__ variable = population.simulation.tax_benefit_system.get_variable(name) if calculation_definition(variable, period) != calculation_definition( diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index bb061d6789..a25963a7f4 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -99,8 +99,8 @@ def pension_credit_without_income_related_esa(benunit, period): In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in the passport branch opens this one. Core reads a branch's inputs from - the branches it was made from as well, so a branch of either keeps both - sets of inputs. + its parent branches as well, so a branch of either keeps both sets of + inputs. """ simulation = benunit.simulation # get_branch returns an existing branch of that name, or this simulation From e5003937cc21efe4b93ee4e60666b06963ff5a0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 09:49:05 -0500 Subject: [PATCH 062/168] Name the model as the actor in the property income band base notes The notes now say the model sets the allowances under ITA 2007 s. 25(2), rather than the section or the allowance doing it. Comments and docstrings only; the code and the YAML data don't change. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/property_income_band_base.yaml | 14 +++---- ...st_property_income_band_base_properties.py | 42 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 7e704dc489..8bef478245 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -39,13 +39,13 @@ members: [person] country: ENGLAND output: - # ITA 2007 s. 25(2): the Personal Allowance keeps the property income up - # to the basic rate limit, £37,700, and the other £10,270 comes off - # savings, so the £500 savings allowance covers savings taxed at 40% - # rather than 20%. Savings left: 9,730, less £500 at the nil rate - # (s. 12A), at 40%: 3,692. Property income 37,700 at 20%: 7,540. The old - # order, with all the allowance against property income, charged 5,486 - # and 5,846: £100 more. + # Under ITA 2007 s. 25(2) the model sets £2,300 of the Personal Allowance + # against the property income, leaving it at the basic rate limit, + # £37,700, and the other £10,270 against savings, so the £500 savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 9,730, less £500 at the nil rate (s. 12A), at 40%: 3,692. Property + # income 37,700 at 20%: 7,540. The old order, with all the allowance + # against property income, charged 5,486 and 5,846: £100 more. savings_income_tax: 3_692 income_tax: 7_540 + 3_692 diff --git a/policyengine_uk/tests/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index 6eb0d6ac0e..5e4b716092 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -13,13 +13,13 @@ 2. Accounting: with no Marriage Allowance, non_savings_taxable_income equals adjusted net income less the allowances and the savings and dividends left after the allowances set against them, floored at nil - (earned_taxable_income also subtracts Marriage Allowance; #1947). ITA 2007 - s. 25(2) can set allowances against savings or dividends while - non-savings income is left, so the savings and dividends left, not their - full amounts, sit above it. + (earned_taxable_income also subtracts Marriage Allowance; #1947). Under + ITA 2007 s. 25(2) the model can set allowances against savings or + dividends while non-savings income is left, so the savings and dividends + left, not their full amounts, sit above it. 3. Monotonic: more property income never lowers the income tax. Savings or - dividend tax alone can fall: s. 25(2) can move allowances from property - income to savings once the property income reaches the basic rate limit. + dividend tax alone can fall, because under s. 25(2) the model can move + allowances from property income to savings as property income rises. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) """ @@ -133,27 +133,29 @@ def test_more_property_income_never_lowers_income_tax(rows, rise): def test_savings_above_property_income_past_the_basic_rate_limit_pay_the_higher_rate(): - """£40,000 of property income and £20,000 of savings. ITA 2007 s. 25(2) - sets £2,300 of the Personal Allowance against the property income, which - leaves it at the £37,700 basic rate limit, and the other £10,270 against - savings. All the savings left, £9,730, sit above the limit: £500 at the - nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once banded the savings - as if the property income were not there and charged £2,900.""" + """£40,000 of property income and £20,000 of savings. Under ITA 2007 + s. 25(2) the model sets £2,300 of the Personal Allowance against the + property income, which leaves it at the £37,700 basic rate limit, and the + other £10,270 against savings. All the savings left, £9,730, sit above the + limit: £500 at the nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once + banded the savings as if the property income were not there and charged + £2,900.""" v = _calculate([(0, 40_000, 20_000, 0)] + [(0, 0, 0, 0)] * (PEOPLE - 1), "ENGLAND") np.testing.assert_allclose(v["savings_income_tax"][0], 9_230 * 0.40, atol=0.01) np.testing.assert_allclose(v["income_tax"][0], 37_700 * 0.20 + 3_692, atol=0.01) - # Non-savings taxable income is 37,700, not 60,000 - 20,000 - 12,570 = - # 27,430: invariant 2 subtracts only the savings left. + # Non-savings taxable income comes to 37,700, not 60,000 - 20,000 - 12,570 + # = 27,430: invariant 2 subtracts only the savings left. np.testing.assert_allclose(v["non_savings_taxable_income"][0], 37_700, atol=0.01) def test_more_property_income_can_lower_savings_tax(): - """Why invariant 3 tests the income tax. With £30,000 of property income - and £20,000 of savings, taxable income of £37,430 stays within the basic - rate band, so the savings allowance is £1,000 (s. 12B(4)) and no share - changes the tax: £19,000 of savings at 20%, £3,800. £10,000 more property - income gives the £3,692 of the test above: savings tax falls by £108 while - income tax rises from £7,286 to £11,232.""" + """More property income can lower savings tax while income tax rises. + With £30,000 of property income and £20,000 of savings, taxable income of + £37,430 stays within the basic rate band, so the savings allowance comes + to £1,000 (s. 12B(4)) and no share changes the tax: £19,000 of savings at + 20%, £3,800. £10,000 more property income gives the £3,692 of the test + above: savings tax falls by £108 while income tax rises from £7,286 to + £11,232.""" before = _calculate([(0, 30_000, 20_000, 0)] * PEOPLE, "ENGLAND") after = _calculate([(0, 40_000, 20_000, 0)] * PEOPLE, "ENGLAND") np.testing.assert_allclose(before["savings_income_tax"], 3_800, atol=0.01) From 2588e563f46a2743543e46ea709566d0c36c2efb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:08:09 -0500 Subject: [PATCH 063/168] Pin the three nil-award routes to #2795 and test the bound against the award MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowlist the strict expected failures under #2795 so CI's outcome check accepts them, and add the third route: a carer with limited capability for work and a £600 council rent, whose award comes to nil under the lower work allowance while the bound's calculation without housing costs leaves £461.10. The test asserts the law's £4,474.60 premium and raises RuntimeError when a premise fails. test_upper_bound_matches_real_award_endpoints compares the bound exactly with the larger of two real awards, one with non-dependant deductions supplied as nil and one with the housing costs element supplied as nil, before the cap and finally, over every element, both work allowances and rows within a few float32 steps of a nil award. test_the_bound_reads_every_component_of_the_maximum_amount checks that the bound reads each element of uc_maximum_amount in order and every input of the award's steps apart from the deduction, the Tax-Free Childcare choice and the claim. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 18 + .../test_uc_carer_element_award_properties.py | 317 +++++++++++++++++- 2 files changed, 326 insertions(+), 9 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index e3b947e134..93f5855ce8 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -25,6 +25,24 @@ outcome = "xfailed" reason = "#1947" why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a strict xfail until it is fixed." +[[allow]] +nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award" +outcome = "xfailed" +reason = "#2795" +why = "Known bug #2795 (a carer whose award a non-dependant deduction takes to nil still bars the severe disability premium), pinned by a strict xfail until it is fixed." + +[[allow]] +nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_family_that_takes_tax_free_childcare_instead_has_no_award" +outcome = "xfailed" +reason = "#2795" +why = "Known bug #2795 (a carer whose family takes Tax-Free Childcare instead of Universal Credit still bars the severe disability premium), pinned by a strict xfail until it is fixed." + +[[allow]] +nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_small_housing_costs_with_a_work_allowance_leave_no_award" +outcome = "xfailed" +reason = "#2795" +why = "Known bug #2795 (a carer with a work allowance and a small housing costs element whose award comes to nil still bars the severe disability premium), pinned by a strict xfail until it is fixed." + [[allow]] nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available" outcome = "skipped" diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 80d446d0bb..61b1ec50e2 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -13,8 +13,8 @@ (has_uc_award_with_carer_element, uc_award_upper_bound) for the severe disability carer test, since the award itself reads Pension Credit, unless the simulation stores the award. Scotland's carer premium reads the positive award -(receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when an -award of nil counted, the award's conditions. Each example simulates the +(receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when the +model counts a nil award as one with a value of zero, the award's conditions. Each example simulates the households as drawn, and again with has_uc_award_with_carer_element supplied as false for everyone, the attribution as it ran before the limb (its carers are then @@ -47,6 +47,11 @@ for than carers (carer benefit recipients and carer element holders). 7. No cycle: every reader calculates on one simulation, whichever is requested first, and also where the simulation stores the award. +8. Endpoint parity: uc_award_upper_bound equals, exactly, the larger of two + awards the model's own formulas calculate, one with non-dependant + deductions supplied as nil and one with the housing costs element supplied + as nil, at the rows test_upper_bound_matches_real_award_endpoints draws, + and it reads every element of uc_maximum_amount in its order. A reform that makes is_uc_eligible read Pension Credit would cycle through the attribution, which reads eligibility; these tests do not cover reforms. @@ -154,9 +159,9 @@ def benefit_unit(draw): @st.composite def households(draw): - # Renters pay the housing costs element, and the household head's family - # bears a deduction for each non-dependant in another benefit unit, which - # the upper bound leaves out. + # DWP includes housing costs for renters and deducts an amount for each + # non-dependant in another benefit unit. The upper bound leaves that + # deduction out. tenure, rent = draw( st.sampled_from( [ @@ -507,7 +512,8 @@ def apply(self): def require(condition, message): - # A broken premise is an error, not the disclosed failure. + # Raise RuntimeError when a premise fails, so xfail accepts only the + # premium assertion. if not condition: raise RuntimeError(message) @@ -584,8 +590,8 @@ def tax_free_childcare_situation(): raises=AssertionError, strict=True, reason=( - "Disclosed limit: the carer test reads the award before the " - "non-dependant deduction, which takes this award to nil." + "#2795: the carer test reads the bound, which leaves out the " + "non-dependant deduction that takes this award to nil." ), ) def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award(): @@ -611,7 +617,7 @@ def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award() raises=AssertionError, strict=True, reason=( - "Disclosed limit: the carer test cannot read whether a family takes " + "#2795: the carer test cannot read whether a family takes " "Tax-Free Childcare instead, which reads the award." ), ) @@ -629,6 +635,76 @@ def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): assert severe[1] == pytest.approx(86.05 * WEEKS) +def small_housing_situation(): + """A blind carer (35) earning £25,000, whom DWP has determined to have + limited capability for work, rents from the council at £600 a year; the + disabled person (60) she cares for lives in another family of the + household.""" + people = { + "carer": { + "age": {YEAR: 35}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 25_000}, + "uc_limited_capability_for_work": {YEAR: True}, + "is_household_head": {YEAR: True}, + }, + "disabled": {"age": {YEAR: 60}, "pip_dl_category": {YEAR: "STANDARD"}}, + } + benunits = { + "carer_unit": {"members": ["carer"]}, + "disabled_unit": {"members": ["disabled"], "would_claim_uc": {YEAR: False}}, + } + household = { + "members": ["carer", "disabled"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: 600}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +@pytest.mark.xfail( + raises=AssertionError, + strict=True, + reason=( + "#2795: the bound's calculation without housing costs takes the " + "higher work allowance and stays positive when this award is nil." + ), +) +def test_small_housing_costs_with_a_work_allowance_leave_no_award(): + # DWP has determined that the carer has limited capability for work, so + # her award has a work allowance (UC Regs 2013 reg 22(1)(b)), and she + # gets the carer element with it: reg 29(4) gives the LCWRA element in + # its place only for limited capability for work and work-related + # activity, and reg 30(1)(a)(i) and (2) ignore the allowance's earnings + # limit and the missing claim. Her award has a housing costs element of + # £600, so DWP applies the lower work allowance, £427 a month (reg + # 22(2)(b)). The taper then exceeds the maximum amount, and the award + # comes to nil (Welfare Reform Act 2012 s.5(1)(b); reg 17). The bound + # also works out the award with no housing costs element and the higher + # work allowance, £710 a month, which leaves £461.10. The council + # ignores the blind carer as a resident (HB Regs 2006 Sch 3 para + # 14(4)(b)), and with no award she does not bar the premium (para + # 14(2)(a)(iii)). So the disabled person keeps the premium in law. + sim = Simulation(situation=small_housing_situation()) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + bound = sim.calculate("uc_award_upper_bound", YEAR) + housing = sim.calculate("uc_housing_costs_element", YEAR) + allowance = sim.calculate("uc_work_allowance", YEAR) + deductions = sim.calculate("uc_non_dep_deductions", YEAR) + forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) + lcwra = sim.calculate("uc_LCWRA_element", YEAR) + carer_element = sim.calculate("uc_carer_element", YEAR) + require(award[0] == 0 and bound[0] > 0, "the carer's award is not nil") + require(housing[0] > 0 and allowance[0] == 427 * 12, "no lower work allowance") + require(deductions[0] == 0 and not forgoes[0], "another route takes it to nil") + require(lcwra[0] == 0 and carer_element[0] > 0, "the award has no carer element") + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) + + def test_the_bound_reads_neither_the_award_nor_pension_credit(): # The carer test reads uc_award_upper_bound because the award reads # Pension Credit (through the non-dependant deduction's exemption), whose @@ -678,3 +754,226 @@ def visit(node): visit(tree) assert "uc_carer_element" in reached assert not reached & forbidden, reached & forbidden + + +# Families for the endpoint comparison, each in its own English household: +# every element of the maximum amount, both work allowances, and owners, +# private, council and housing association renters. The award reads the +# earned and unearned income each row supplies. +PARITY_FAMILIES = [ + # A single owner, with no work allowance. + dict(adults=[dict(age=30)], children=[], tenure="OWNED_OUTRIGHT", rent=0), + # A lone parent renting privately, with a child (3) in childcare. + dict( + adults=[dict(age=30, employment_income=30_000)], + children=[dict(age=3, childcare_expenses=6_000)], + tenure="RENT_PRIVATELY", + rent=9_000, + ), + # A lone parent of two who owns her home. + dict( + adults=[dict(age=35, employment_income=20_000)], + children=[dict(age=4), dict(age=7)], + tenure="OWNED_OUTRIGHT", + rent=0, + ), + # A council renter with limited capability for work and work-related + # activity. + dict( + adults=[dict(age=45, uc_limited_capability_for_WRA=True)], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=6_000, + ), + # A carer with limited capability for work and a small council rent, + # where the calculation without housing costs gives the larger award. + dict( + adults=[dict(age=35, care_hours=35, uc_limited_capability_for_work=True)], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=600, + ), + # A couple, one caring, with a disabled child, renting from a housing + # association. + dict( + adults=[ + dict(age=40, care_hours=35), + dict(age=38, employment_income=15_000), + ], + children=[dict(age=6, dla_sc_category="MIDDLE")], + tenure="RENT_FROM_HA", + rent=7_200, + ), +] +# Earned income before the work allowance and unearned income, a year. +PARITY_INCOMES = [(0, 0), (6_000, 0), (18_000, 0), (12_000, 1_500)] +BOUNDARY_EARNINGS = 6_000 +# The endpoints: the award with non-dependant deductions supplied as nil, and +# the award with the housing costs element supplied as nil. +ENDPOINTS = ("uc_non_dep_deductions", "uc_housing_costs_element") + + +def parity_situation(rows, endpoint=None): + """One household per (family, earned income, unearned income) row. Each + family claims and takes no Tax-Free Childcare instead, with no benefit cap + reduction or debt deduction, so the final award equals the award before + the cap. endpoint, a variable in ENDPOINTS, is supplied as nil.""" + people, benunits, households = {}, {}, {} + for i, (family, earned, unearned) in enumerate(rows): + members = [] + for role, group in (("a", family["adults"]), ("c", family["children"])): + for j, inputs in enumerate(group): + name = f"r{i}_{role}{j}" + people[name] = {k: {YEAR: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {YEAR: role == "a"} + members.append(name) + unit = { + "members": members, + "claims_universal_credit": {YEAR: True}, + "forgoes_universal_credit_for_tax_free_childcare": {YEAR: False}, + "uc_benefit_cap_reduction_after_floor": {YEAR: 0}, + "uc_deductions": {YEAR: 0}, + "uc_earned_income_before_work_allowance": {YEAR: float(earned)}, + "uc_unearned_income": {YEAR: float(unearned)}, + } + if endpoint is not None: + unit[endpoint] = {YEAR: 0} + benunits[f"r{i}"] = unit + households[f"r{i}"] = { + "members": members, + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: family["tenure"]}, + "rent": {YEAR: family["rent"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def endpoint_values(rows, variables): + """Each variable at each endpoint, from its own simulation.""" + results = [] + for endpoint in ENDPOINTS: + sim = Simulation(situation=parity_situation(rows, endpoint)) + results.append({v: np.asarray(sim.calculate(v, YEAR)) for v in variables}) + return results + + +def boundary_rows(): + """Rows whose unearned income brings each endpoint's income reduction to + within a few float32 steps of its maximum amount, where the award + cancels to nil or a fraction of a penny.""" + seeds = [(family, BOUNDARY_EARNINGS, 0) for family in PARITY_FAMILIES] + values = endpoint_values(seeds, ("uc_maximum_amount", "uc_income_reduction")) + rows = [] + for at_endpoint in values: + maximum = at_endpoint["uc_maximum_amount"].astype(np.float32) + reduction = at_endpoint["uc_income_reduction"].astype(np.float32) + assert (reduction < maximum).all(), "a seed's taper reaches its maximum" + for f, family in enumerate(PARITY_FAMILIES): + step = np.spacing(maximum[f]) + for k in range(-3, 4): + unearned = np.float32(maximum[f] - reduction[f] + k * step) + rows.append((family, BOUNDARY_EARNINGS, float(unearned))) + return rows + + +def test_upper_bound_matches_real_award_endpoints(): + # The bound repeats the award's arithmetic at two endpoints, since the + # carer test cannot read the award. At each row the bound must equal, + # exactly, the larger of the two awards the model's own formulas give: + # one with non-dependant deductions supplied as nil, one with the + # housing costs element supplied as nil. Comparing the award before the + # cap and the final award also catches a new amount the award adds + # after the cap stage. Drift either way at these rows fails the test. + rows = [ + (family, earned, unearned) + for family in PARITY_FAMILIES + for earned, unearned in PARITY_INCOMES + ] + n_seed_rows = len(rows) + rows += boundary_rows() + components = ( + "uc_standard_allowance", + "uc_child_element", + "uc_disability_elements", + "uc_carer_element", + "uc_housing_costs_element", + "uc_childcare_element", + ) + variables = components + ( + "uc_work_allowance", + "universal_credit_pre_benefit_cap", + "universal_credit", + ) + with_housing, without_housing = endpoint_values(rows, variables) + base = Simulation(situation=parity_situation(rows)) + bound = np.asarray(base.calculate("uc_award_upper_bound", YEAR)) + + # The rows cover every element, both work allowances, both endpoints + # deciding the bound, and awards that cancel to nil or under a penny. + for component in components: + assert (with_housing[component] > 0).any(), component + assert (with_housing["uc_work_allowance"] == 427 * 12).any() + assert (without_housing["uc_work_allowance"] == 710 * 12).any() + pre_cap = [ + values["universal_credit_pre_benefit_cap"] + for values in (with_housing, without_housing) + ] + assert (pre_cap[0] > pre_cap[1]).any() and (pre_cap[1] > pre_cap[0]).any() + for award in pre_cap: + boundary = award[n_seed_rows:] + assert (boundary == 0).any() and ((boundary > 0) & (boundary < 0.01)).any() + + for stage in ("universal_credit_pre_benefit_cap", "universal_credit"): + larger = np.maximum(with_housing[stage], without_housing[stage]) + mismatched = np.flatnonzero(bound != larger) + assert not mismatched.size, (stage, mismatched, bound[mismatched]) + + +# The award's steps that the bound repeats, and the reads it leaves out: the +# non-dependant deduction and the Tax-Free Childcare choice, which read +# Pension Credit and the award, and the claim, which the carer test reads +# beside the bound. +AWARD_STEPS = ( + "uc_maximum_amount", + "uc_housing_costs_element", + "uc_work_allowance", + "uc_earned_income", + "uc_income_reduction", + "universal_credit_pre_benefit_cap", +) +LEFT_OUT = { + "uc_non_dep_deductions", + "forgoes_universal_credit_for_tax_free_childcare", + "claims_universal_credit", +} + + +def test_the_bound_reads_every_component_of_the_maximum_amount(): + # A new element in uc_maximum_amount, or a new read in any step of the + # award, must reach the bound too. The couple with a disabled child + # rents and has a carer, so every step runs. + sim = Simulation(situation=parity_situation([(PARITY_FAMILIES[5], 12_000, 1_500)])) + sim.trace = True + sim.calculate("universal_credit_pre_benefit_cap", YEAR) + sim.calculate("uc_award_upper_bound", YEAR) + reads = {} + + def visit(node): + if node.children: + # A variable's formula runs once; later reads hit the cache. + reads.setdefault(node.name, [child.name for child in node.children]) + for child in node.children: + visit(child) + + for tree in sim.tracer.trees: + visit(tree) + bound_reads = reads["uc_award_upper_bound"] + adds = sim.tax_benefit_system.get_variable("uc_maximum_amount").adds + # The elements in uc_maximum_amount's order, which float32 addition + # depends on; the bound rebuilds the housing costs element from rent. + assert [name for name in bound_reads if name in adds] == [ + name for name in adds if name != "uc_housing_costs_element" + ] + for step in AWARD_STEPS: + missing = set(reads[step]) - set(AWARD_STEPS) - LEFT_OUT - set(bound_reads) + assert not missing, (step, missing) From ca1e24310ad5f3d06173b808297e46a52b17cfcc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:08:25 -0500 Subject: [PATCH 064/168] Describe the bound's third nil-award route and qualify Scotland's 2022-25 reading The bound's documentation and the readers that cite it now name all three ways it can exceed a nil award (#2795): a non-dependant deduction, Tax-Free Childcare, and a small housing costs element with a work allowance. They limit the upper-bound claim to awards the model's formulas calculate; a stored award can exceed it, and the carer test then reads it. The Scottish premium's documentation, parameters, YAML comments and changelog state the 2022-23 to 2024-25 nil-award rule as the model's reading of reg 4, which UC Claims and Payments Regs reg 32A(1)(a), the SSI 2025/24 policy note and Ipswich BC v TD [2024] UKUT 117 (AAC) leave open. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2120.fixed.md | 2 +- ...carer_premium_counts_uc_carer_element.yaml | 2 +- .../uc_award_includes_zero_value_award.yaml | 2 +- .../council_tax_reduction_carer_premium.yaml | 13 ++++- ...e_disability_minimum_guarantee_addition.py | 4 +- .../has_uc_award_with_carer_element.py | 20 ++++--- .../universal_credit/uc_award_upper_bound.py | 56 +++++++++++-------- .../council_tax_reduction_carer_premium.py | 36 ++++++++---- ...is_cared_for_by_carer_benefit_recipient.py | 18 +++--- .../pension_age_severe_disability_premium.py | 4 +- .../demographic/severe_disability_premium.py | 11 ++-- 11 files changed, 102 insertions(+), 66 deletions(-) diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md index 443c7a80b7..b8d218f489 100644 --- a/changelog.d/2120.fixed.md +++ b/changelog.d/2120.fixed.md @@ -1 +1 @@ -The model now withholds the severe disability premium and the Pension Credit severe disability addition when a carer's Universal Credit award includes the carer element for the claimant or partner, and pays the carer premium in Scotland's working-age council tax reduction for such an award from 1 April 2022, counting an award of nil until 31 March 2025 (#2120, #2124). +The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022, and until 31 March 2025 it also counts a nil award that earnings bring about, reading the Scottish definition of an award with a value of zero to include one (#2120, #2124). diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml index ad47b6bfb2..98947fb13a 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml @@ -1,4 +1,4 @@ -description: Scotland's working-age Council Tax Reduction scheme pays the carer premium to an applicant or partner whose award of Universal Credit includes the carer element if this is true, and only on entitlement to Carer's Allowance or Carer Support Payment otherwise. +description: Whether Scottish councils pay the working-age Council Tax Reduction carer premium to an applicant or partner whose award of Universal Credit includes the carer element, as well as on entitlement to Carer's Allowance or Carer Support Payment. values: 2013-04-01: false 2022-04-01: true diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml index c9037e7852..06ff155820 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml @@ -1,4 +1,4 @@ -description: Scotland's working-age Council Tax Reduction scheme treats an award of Universal Credit with a value of zero as an award of Universal Credit if this is true, so a nil award that includes the carer element meets the carer premium's caring condition. +description: Whether Scottish councils count a Universal Credit award with a value of zero when deciding the working-age Council Tax Reduction carer premium. The model reads that to include a nil award that earnings bring about, a reading the sources leave open. values: 2013-04-01: false 2022-04-01: true diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 788cbac06f..4256889796 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -12,7 +12,12 @@ # a value of zero" (reg 4, as amended by SSI 2022/52 reg 3); from 1 April 2025 # it "means an award of universal credit made under Part 1 of the 2012 Act" # (SSI 2025/24 reg 8), and an income that tapers the award to nil leaves no -# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). +# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). The model +# reads the earlier definition as reaching a carer whose earnings take the +# award to nil. The sources leave that reading open: UC Claims and Payments +# Regs 2013 reg 32A(1)(a) covers a claim on which DWP makes no award, and +# Ipswich BC v TD [2024] UKUT 117 (AAC) finds no Universal Credit +# entitlement in a nil award without construing the Scottish text. # # Amounts: carer premium £48.15 a week from 1 April 2026 (SSI 2021/249 Sch 1 # para 5(1), substituted by SSI 2026/56), £46.40 in 2025-26, £45.60 in @@ -177,12 +182,14 @@ receives_uc_carer_element: false council_tax_reduction_carer_premium: 0 -- name: In 2024 a Scottish carer's nil award still counts as an award with the carer element +- name: In 2024 the model counts a Scottish carer's nil award as an award with the carer element # The same carer in 2024-25. The maximum amount is (393.45 + 198.31) x 12 # = 7,101.12, and the taper on £20,000 of earnings less income tax and # National Insurance takes more, so the award is nil. Until 31 March 2025 # an "award of universal credit" included one "with a value of zero" (reg - # 4), so para 6(1)(c) applies: carer premium 45.60 x 52 = 2,371.20. + # 4). The model reads that as reaching this nil award, a reading the + # sources leave open (see the header), so para 6(1)(c) applies: carer + # premium 45.60 x 52 = 2,371.20. period: 2024 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index b20611b3b8..7b69991bd7 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -30,8 +30,8 @@ class severe_disability_minimum_guarantee_addition(Variable): "disability premium shares; a carer benefit there includes an award " "of Universal Credit which includes the carer element (para 1(4)), " "which has_uc_award_with_carer_element reads from the award's " - "conditions and the award before any non-dependant deduction. " - "Blindness " + "conditions and the upper bound on the award (uc_award_upper_bound), " + "with that variable's limits. Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index 98d426f738..1b26f254e4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -23,20 +23,24 @@ class has_uc_award_with_carer_element(Variable): "premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b)) and of the " "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " "1(1) and (4)), through is_cared_for_by_carer_benefit_recipient. " - "Those cannot read the award: its non-dependant deduction spares a " - "non-dependant on Pension Credit, so it reads Pension Credit, which " - "reads the addition. So this reads the award's conditions instead. " + "The premium and addition cannot read the award: the non-dependant " + "deduction reads Pension Credit, whose addition reads the " + "attribution. has_uc_award_with_carer_element therefore reads the " + "award's conditions and bound. " "The family claims Universal Credit (claims_universal_credit), meets " "the age, pension-route and capital conditions (is_uc_eligible) and " "has a positive upper bound on its award (uc_award_upper_bound), and " "the award includes the carer element in respect of " "this claimant (uc_carer_element_included_for_claimant). A nil bound " "leaves a nil award, and so no award (Welfare Reform Act 2012 s.5(1)(b) " - "and (2)(b); DWP sets the minimum at one penny, reg 17), so a carer " - "whose income tapers the award to nil does not count. A carer " - "whose award a non-dependant deduction takes to nil still counts, and " - "so does a family that takes Tax-Free Childcare instead " - "(forgoes_universal_credit_for_tax_free_childcare reads the award). " + "and (2)(b); DWP sets the minimum at one penny, reg 17). A carer " + "whose income reduces both bound calculations to nil does not count. " + "A carer can still count after income reduces the actual award to " + "nil when the bound's calculation without housing costs remains " + "positive. A carer whose award a non-dependant deduction takes to nil " + "still counts, and so does one whose family takes Tax-Free Childcare " + "instead (forgoes_universal_credit_for_tax_free_childcare reads the " + "award); #2795 tracks all three. " "Where an input or a scenario stores universal_credit for the period, " "or a reform neutralizes it, reading it cannot cycle, so this reads " "the award itself (receives_uc_carer_element)." diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index c408c27841..6bd0f517d6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -6,30 +6,38 @@ class uc_award_upper_bound(Variable): entity = BenUnit label = "Upper bound on the Universal Credit award" documentation = ( - "The family's award before the benefit cap " - "(universal_credit_pre_benefit_cap) never exceeds this amount, which " - "leaves out the non-dependant deduction. DWP awards the maximum " - "amount less the income reduction (UC Regs 2013 reg 22). The maximum " - "amount includes the housing costs element (uc_housing_costs_element): " - "the rent a renter may claim less DWP's non-dependant deductions. " - "Where that element is positive, DWP applies the work allowance with " - "housing. Where DWP deducts less than the rent, the award stays at or " - "below the award with no deduction; where it deducts at least the " - "rent, the award loses the housing costs element and takes the work " - "allowance without housing. This amount takes the larger of those two " - "awards. It repeats the award's steps in their order, rounding where " - "they store a value, so it never falls below the award. A family " - "whose bound comes to nil has a nil award, and so no award: " - "Parliament makes an amount payable of at least the prescribed " - "minimum a financial condition (Welfare Reform Act 2012 s.5(1)(b) " - "and (2)(b)), and DWP sets that minimum at one penny (reg 17). The " - "deduction spares a non-dependant on Pension Credit, so it reads " - "Pension Credit, whose severe disability addition reads who has an " - "award with the carer element. This amount reads neither, so that " - "test can read it (has_uc_award_with_carer_element). It can exceed a " - "nil award: a non-dependant deduction can take the award to nil, and " - "a family that takes Tax-Free Childcare instead gets none " - "(forgoes_universal_credit_for_tax_free_childcare)." + "An amount that the family's award before the benefit cap " + "(universal_credit_pre_benefit_cap), as the model calculates it, " + "never exceeds, worked out without reading the non-dependant " + "deduction. DWP awards the maximum amount less the income reduction " + "(UC Regs 2013 reg 22). The maximum amount includes the housing costs " + "element (uc_housing_costs_element): the rent a renter may claim less " + "DWP's non-dependant deductions. Where that element is positive, DWP " + "applies the lower work allowance (reg 22(2)(b)). Where DWP deducts " + "less than the rent, the award stays at or below the award with no " + "deduction. Where it deducts at least the rent, DWP calculates the " + "award with no housing costs element and the higher work allowance " + "(reg 22(2)(a); Sch 4 para 13). This amount takes the larger of those " + "two awards. It repeats the award's steps in their order, rounding " + "where they store a value, so it never falls below the award the " + "model's formulas calculate. An input or a scenario can store a " + "larger award; the carer test then reads the stored award " + "(has_uc_award_with_carer_element). A family whose bound comes to nil " + "has a nil award, and so no award: Parliament makes an amount " + "payable of at least the prescribed minimum a financial condition " + "(Welfare Reform Act 2012 s.5(1)(b) and (2)(b)), and DWP sets that " + "minimum at one penny (reg 17). The deduction spares a non-dependant " + "on Pension Credit, so it reads Pension Credit, whose severe " + "disability addition reads who has an award with the carer element. " + "This amount reads neither, so that test can read it. It can exceed " + "a nil award (#2795). A non-dependant deduction can take the award " + "to nil. A family that takes Tax-Free Childcare instead gets no award " + "(forgoes_universal_credit_for_tax_free_childcare). And for a family " + "with a work allowance and a small housing costs element, the " + "calculation without housing costs applies the higher work " + "allowance, whose extra earnings disregard can outweigh the housing " + "costs it drops, so it can stay positive when the award comes to nil, " + "even with no non-dependant deduction." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index 0c439c7e16..c9f6dfd020 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -14,21 +14,32 @@ class council_tax_reduction_carer_premium(Variable): "has regular and substantial caring responsibilities for a severely " "disabled person. The Scottish Ministers define those in para 6(1) " "as (a) receiving Carer's Allowance or Carer Support Payment, (b) " - "being entitled to one that the overlapping benefits rules reduce to " - "nil, or (c) having an award of Universal Credit which includes the " - "carer element. carer_premium tests limbs (a) and (b) " + "being entitled to an allowance that DWP or Social Security Scotland " + "reduces to nil under the overlapping benefits rules, or (c) having " + "an award of Universal Credit which includes the carer element. " + "carer_premium tests limbs (a) and (b) " "(is_entitled_to_carer_benefit). For limb (c), the Scottish Ministers " "defined an award of Universal Credit to include one with a value of " "zero from 1 April 2022 (reg 4, as amended by SSI 2022/52 reg 3), and " "from 1 April 2025 as an award made under Part 1 of the Welfare Reform " "Act 2012 (SSI 2025/24 reg 8), under which an income that tapers the " - "award to nil leaves no award (s.5(1)(b); UC Regs 2013 reg 17). So " - "while the earlier definition holds " - "(uc_award_includes_zero_value_award), this variable reads limb (c) " - "from the award's conditions: the family claims Universal Credit, meets its basic " - "conditions and does not take Tax-Free Childcare instead, and the " - "award includes the carer element for this claimant. Afterwards it " - "reads the positive award (receives_uc_carer_element). From 1 April " + "award to nil leaves no award (s.5(1)(b); UC Regs 2013 reg 17). The " + "model reads the earlier definition as reaching a carer whose " + "earnings take the award to nil, and so counts that nil award from " + "2022-23 to 2024-25. The sources leave that reading open. UC Claims " + "and Payments Regs 2013 reg 32A(1)(a) says DWP makes no award where " + "income fails that condition. The Scottish Government's policy note " + "to SSI 2025/24 records a 2022 intention to treat such a claimant as " + "having an award, but calls reg 10A an expansion that gives the " + "intention effect. Ipswich BC v TD [2024] UKUT 117 (AAC) holds that " + "a nil award carries no Universal Credit entitlement, without " + "construing the Scottish definition. So while the earlier definition " + "holds (uc_award_includes_zero_value_award), this variable reads limb " + "(c) from the award's conditions: the family claims Universal Credit, " + "meets its basic conditions and does not take Tax-Free Childcare " + "instead, and the award includes the carer element for this " + "claimant. Afterwards it reads the positive award " + "(receives_uc_carer_element). From 1 April " "2025 the Scottish Ministers also count two nil awards as awards (reg " "10A): one a sanction reduces to nil, and one whose claimant the " "Secretary of State treats as reclaiming after earnings end it (UC " @@ -74,8 +85,9 @@ def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) # SSI 2021/249 Sch 1 para 6(1)(c): an award of Universal Credit which - # includes the carer element. Until 31 March 2025 an award with a - # value of zero counts (reg 4), so the award's conditions decide. + # includes the carer element. Until 31 March 2025 the model counts a + # nil award as one "with a value of zero" (reg 4), a reading the + # sources leave open, so the award's conditions decide. carer_element_award = person("receives_uc_carer_element", period) if scotland.uc_award_includes_zero_value_award: family = person.benunit diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 582cf03c66..bd5bf01c36 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -46,15 +46,17 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "whose allowance an overlapping benefit, such as a higher State " "Pension, reduces to nil is entitled but not in receipt, so is not " "attributed for the allowance. has_uc_award_with_carer_element gives " - "a carer element award from the claim, the award's conditions and the " - "award before any non-dependant deduction, because the Universal " - "Credit non-dependant deduction exemption reads Pension Credit, which " - "reads the addition. A carer whose income tapers the " - "award to nil has no award (Welfare Reform Act 2012 s.5) and is not " - "attributed for it. A carer whose award a non-dependant deduction " + "a carer element award from the claim, the award's conditions and an " + "upper bound on the award (uc_award_upper_bound), because the " + "Universal Credit non-dependant deduction exemption reads Pension " + "Credit, which reads the addition. A carer whose bound comes to nil " + "has no award (Welfare Reform Act 2012 s.5) and is not attributed for " + "it. The bound can stay positive after income reduces the actual " + "award to nil, when its calculation without housing costs gives a " + "higher work allowance. A carer whose award a non-dependant deduction " "takes to nil, or whose family takes Tax-Free Childcare instead, is " - "still attributed, and can withhold a premium or addition the law " - "would pay. A carer with both an allowance and a carer element is one " + "still attributed too, and can withhold a premium or addition the law " + "would pay (#2795). A carer with both an allowance and a carer element is one " "carer. Partners who care for the same severely disabled person " "(partners_care_for_same_severely_disabled_person) have one relevant " "entitlement between them (s.70(7ZA) and (7A)), so where one of them " diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 161fdfe801..54767f5f1d 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -22,8 +22,8 @@ class pension_age_severe_disability_premium(Variable): "schemes (England SI 2012/2885 Sch 8 para 4(1)); carers are attributed " "by is_cared_for_by_carer_benefit_recipient, which counts a Universal " "Credit award with the carer element from the award's conditions and " - "the award before any non-dependant deduction. The Pension Credit " - "severe " + "the upper bound on the award (uc_award_upper_bound), with that " + "variable's limits. The Pension Credit severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 3d2b665905..e4f8560906 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -23,10 +23,13 @@ class severe_disability_premium(Variable): "attributed by is_cared_for_by_carer_benefit_recipient, shared with " "the Pension Credit severe disability addition. It counts an award " "of Universal Credit which includes the carer element from the " - "award's conditions and the award before any non-dependant deduction " - "(has_uc_award_with_carer_element), so a carer whose income tapers " - "the award to nil does not bar the premium, but one whose award a " - "non-dependant deduction takes to nil does. Not modelled: a carer's " + "award's conditions and an upper bound on the award " + "(has_uc_award_with_carer_element). A carer whose bound comes to nil " + "does not bar the premium. The model can still withhold it where a " + "non-dependant deduction takes the award to nil, where the carer's " + "family takes Tax-Free Childcare instead, or where a small housing " + "costs element leaves the bound's calculation without housing costs " + "positive (#2795). Not modelled: a carer's " "allowance or carer element that would be paid but for a loss of " "benefit restriction under section 6B or 7 of the Social Security " "Fraud Act 2001 also counts (HB Regs 2006 Sch 3 para 14(7), IS Regs " From b35b61fb760f3d3278b2ddb2325757254f899a1f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:06:04 -0500 Subject: [PATCH 065/168] Split the changelog's Scottish sentence and quote the zero-value words Co-Authored-By: Claude Opus 5.5 --- changelog.d/2120.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md index b8d218f489..e67509c102 100644 --- a/changelog.d/2120.fixed.md +++ b/changelog.d/2120.fixed.md @@ -1 +1 @@ -The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022, and until 31 March 2025 it also counts a nil award that earnings bring about, reading the Scottish definition of an award with a value of zero to include one (#2120, #2124). +The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022. Until 31 March 2025 it also counts a nil award that earnings bring about, reading Scotland's definition of an award "with a value of zero" to include one (#2120, #2124). From f15ff9be504e6434abda351cf1e54ee657fd89af Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:20:02 -0500 Subject: [PATCH 066/168] Put actors in the carer docs, YAML comments and test notes The attribution's documentation, the two YAML files' comments and case names, the property test's docstring and comments, and the allowlist reasons now name who acts and use verbs in place of be-verb complements. No formula or expected value changes. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 6 +- .../council_tax_reduction_carer_premium.yaml | 28 ++++---- .../uc_carer_element_carer_test.yaml | 36 +++++----- .../test_uc_carer_element_award_properties.py | 67 ++++++++++--------- .../universal_credit/uc_award_upper_bound.py | 4 +- .../council_tax_reduction_carer_premium.py | 2 +- ...is_cared_for_by_carer_benefit_recipient.py | 28 ++++---- 7 files changed, 89 insertions(+), 82 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 93f5855ce8..7279d3d3b5 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -29,19 +29,19 @@ why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award" outcome = "xfailed" reason = "#2795" -why = "Known bug #2795 (a carer whose award a non-dependant deduction takes to nil still bars the severe disability premium), pinned by a strict xfail until it is fixed." +why = "Known bug #2795: a carer whose award a non-dependant deduction takes to nil still bars the severe disability premium. A strict xfail pins it until the model pays the premium." [[allow]] nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_family_that_takes_tax_free_childcare_instead_has_no_award" outcome = "xfailed" reason = "#2795" -why = "Known bug #2795 (a carer whose family takes Tax-Free Childcare instead of Universal Credit still bars the severe disability premium), pinned by a strict xfail until it is fixed." +why = "Known bug #2795: a carer whose family takes Tax-Free Childcare instead of Universal Credit still bars the severe disability premium. A strict xfail pins it until the model pays the premium." [[allow]] nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_small_housing_costs_with_a_work_allowance_leave_no_award" outcome = "xfailed" reason = "#2795" -why = "Known bug #2795 (a carer with a work allowance and a small housing costs element whose award comes to nil still bars the severe disability premium), pinned by a strict xfail until it is fixed." +why = "Known bug #2795: a carer with a work allowance and a small housing costs element whose award comes to nil still bars the severe disability premium. A strict xfail pins it until the model pays the premium." [[allow]] nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available" diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 4256889796..2546b3a853 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -30,8 +30,8 @@ - name: The issue's Scottish carer aged 40 with 35 hours and no Carer's Allowance claim gets the carer premium # Para 6(1)(c): the award includes the carer element, 209.34 x 12 = - # 2,512.08, so the applicable amount is (95.55 + 48.15) x 52 = 7,472.40, - # not 95.55 x 52 = 4,968.60. + # 2,512.08, so the applicable amount comes to (95.55 + 48.15) x 52 = + # 7,472.40, not 95.55 x 52 = 4,968.60. period: 2026 absolute_error_margin: 0.01 input: @@ -58,7 +58,7 @@ council_tax_reduction_applicable_amount: (95.55 + 48.15) * 52 - name: The Scottish carer premium for a carer element starts with the 2021 Regulations on 1 April 2022 - # Model year 2022 is 2022-23, read at 30 April 2022. Carer element + # The model reads model year 2022 (2022-23) at 30 April 2022. Carer element # 168.81 x 12 = 2,025.72; carer premium 38.85 x 52 = 2,020.20; applicable # amount (77.00 + 38.85) x 52. period: 2022 @@ -83,8 +83,8 @@ council_tax_reduction_applicable_amount: (77.00 + 38.85) * 52 - name: Before 1 April 2022 the Scottish scheme for persons under pension age tested Carer's Allowance only - # SSI 2012/303 Sch 1 para 14: "entitled to a carer's allowance". Model - # year 2021 is 2021-22, read at 30 April 2021, so the carer element, + # SSI 2012/303 Sch 1 para 14: "entitled to a carer's allowance". The model + # reads model year 2021 (2021-22) at 30 April 2021, so the carer element, # 163.73 x 12 = 1,964.76, gives no premium. period: 2021 absolute_error_margin: 0.01 @@ -155,8 +155,8 @@ council_tax_reduction_applicable_amount: 95.55 * 52 - name: A Scottish carer whose income tapers the award to nil has no award and no carer premium - # With £20,000 of earnings the award is nil, so in 2026 there is no award - # (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17) and para 6(1)(c) + # With £20,000 of earnings the award comes to nil, which in 2026 leaves no + # award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and para 6(1)(c) # does not apply, though the maximum amount still includes the carer # element. period: 2026 @@ -185,7 +185,7 @@ - name: In 2024 the model counts a Scottish carer's nil award as an award with the carer element # The same carer in 2024-25. The maximum amount is (393.45 + 198.31) x 12 # = 7,101.12, and the taper on £20,000 of earnings less income tax and - # National Insurance takes more, so the award is nil. Until 31 March 2025 + # National Insurance takes more, so the award comes to nil. Until 31 March 2025 # an "award of universal credit" included one "with a value of zero" (reg # 4). The model reads that as reaching this nil award, a reading the # sources leave open (see the header), so para 6(1)(c) applies: carer @@ -215,11 +215,11 @@ carer_premium: 0 council_tax_reduction_carer_premium: 45.60 * 52 -- name: From 1 April 2025 the same Scottish carer's nil award is no award +- name: From 1 April 2025 the same Scottish carer's nil award leaves no award # The same carer in 2025-26. The maximum amount is (400.14 + 201.68) x 12 = - # 7,221.84, and the taper again takes more, so the award is nil. From 1 - # April 2025 an award of universal credit is one "made under Part 1 of the - # 2012 Act" (SSI 2025/24 reg 8), and an income that tapers it to nil leaves + # 7,221.84, and the taper again takes more, so the award comes to nil. From + # 1 April 2025 reg 4 defines an award of universal credit as one "made under + # Part 1 of the 2012 Act" (SSI 2025/24 reg 8), and an income that tapers it to nil leaves # no award, so para 6(1)(c) does not apply: no carer premium. Neither of # reg 10A's nil awards (a sanction, or a reclaim under the UC Claims and # Payments Regs 2013 reg 32A) arises. @@ -248,8 +248,8 @@ council_tax_reduction_carer_premium: 0 - name: A Scottish carer with both Carer Support Payment and the carer element gets one premium - # The premium is paid in respect of each person who qualifies (para 5(3)), - # not for each award: 48.15 x 52 = 2,503.80, not twice that. + # Councils pay the premium in respect of each person who qualifies (para + # 5(3)), not for each award: 48.15 x 52 = 2,503.80, not twice that. period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml index 66edc75834..1cae9f34b9 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -2,11 +2,12 @@ # carer benefit in the carer condition of the legacy severe disability premium # (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b); HB(SPC) Regs 2006 Sch 3 # para 6(2)) and of the Pension Credit severe disability addition (SPC Regs -# 2002 Sch I para 1(1)(a)(iii) and (b), with para 1(4)): no premium or -# addition is paid for a person someone "has an award of universal credit which -# includes the carer element in respect of caring for". The carer element is -# for regular and substantial caring for a severely disabled person, whether or -# not the carer claims Carer's Allowance (UC Regs 2013 regs 29 and 30(2)). +# 2002 Sch I para 1(1)(a)(iii) and (b), with para 1(4)): DWP and councils +# withhold the premium or addition when a carer "has an award of universal +# credit which includes the carer element in respect of caring for" the +# claimant. DWP includes the carer element for regular and substantial caring +# for a severely disabled person, whether or not the carer claims Carer's +# Allowance (UC Regs 2013 regs 29 and 30(2)). # # Amounts for 2026-27 (DWP benefit and pension rates 2026 to 2027; the Social # Security Benefits Up-rating Order 2026, SI 2026/148): severe disability @@ -18,7 +19,8 @@ # The carer is a non-dependant who is blind, so the non-dependant condition # ignores them (para 14(4)(b)); their award bars the premium instead. # Carer element 209.34 x 12 = 2,512.08; award (424.90 + 209.34) x 12 = - # 7,610.88. Without the limb the premium was 86.05 x 52 = 4,474.60. + # 7,610.88. Without the limb the model paid the premium, 86.05 x 52 = + # 4,474.60. period: 2026 input: people: @@ -79,9 +81,9 @@ - name: A son's Universal Credit carer element in another benefit unit bars a pensioner's severe disability addition # SPC Regs 2002 Sch I para 1(1)(a)(iii) and (4). The son is blind, so the - # residence condition ignores him (para 2). Without the limb the addition - # was 86.05 x 52 = 4,474.60, and so was the pension-age Housing Benefit - # premium (HB(SPC) Regs 2006 Sch 3 para 6(2)(a)(iii)). + # residence condition ignores him (para 2). Without the limb the model paid + # the addition, 86.05 x 52 = 4,474.60, and the pension-age Housing Benefit + # premium at the same amount (HB(SPC) Regs 2006 Sch 3 para 6(2)(a)(iii)). period: 2026 input: people: @@ -109,7 +111,7 @@ severe_disability_minimum_guarantee_addition: [0, 0] pension_age_severe_disability_premium: [0, 0] -- name: A carer on both Carer's Allowance and Universal Credit is one carer +- name: A carer on both Carer's Allowance and Universal Credit counts once # The carer receives Carer's Allowance, 86.45 x 52 = 4,495.40, and a # Universal Credit award with the carer element, (424.90 + 209.34) x 12 - # 4,495.40 = 3,115.48. They care for one person (SSCBA 1992 s.70(7) and @@ -147,7 +149,7 @@ is_cared_for_by_carer_benefit_recipient: [true, false, false] severe_disability_minimum_guarantee_addition: [4_474.60, 0] -- name: A partner's carer element is not a second carer where the other partner receives the allowance for the same person +- name: A partner's carer element adds no second carer where the other partner receives the allowance for the same person # Both partners care 35 hours; the younger claims Carer's Allowance and the # joint award includes the carer element for the elder (UC Regs 2013 reg # 29(3); the model takes the elder). Partners caring for the same severely @@ -194,8 +196,8 @@ # exceeds the maximum amount even with no non-dependant deduction # (uc_award_upper_bound; the disabled person's family, which does not # claim, has a bound of its standard allowance, 424.90 x 12 = 5,098.80), so - # the award is nil and there is no award (Welfare Reform Act 2012 s.5(1)(b); - # UC Regs 2013 reg 17). No one has an award in + # the award comes to nil, which leaves no award (Welfare Reform Act 2012 + # s.5(1)(b); UC Regs 2013 reg 17). No one has an award in # respect of caring for the disabled person, who keeps the premium, 86.05 x # 52 = 4,474.60. period: 2026 @@ -310,10 +312,10 @@ severe_disability_minimum_guarantee_addition: [4_474.60, 0] pension_age_severe_disability_premium: [4_474.60, 0] -- name: A stored award is read as the award, even without a claim +- name: The carer test reads a stored award as the award, even without a claim # The carer of the first case earns £20,000, which tapers the award the - # model would work out to nil, and neither family claims. Universal Credit - # is supplied instead: nil for the disabled person's family and £100 for + # model would work out to nil, and neither family claims. The case supplies + # Universal Credit instead: nil for the disabled person's family and £100 for # the carer's. A stored award reads no other variable, so the carer test # reads it (receives_uc_carer_element): the carer's award includes the # carer element and bars the premium. @@ -349,7 +351,7 @@ is_cared_for_by_carer_benefit_recipient: [true, false] severe_disability_premium: [0, 0] -- name: Without a Universal Credit claim there is no carer element award +- name: Without a Universal Credit claim no one has a carer element award # The same carer as in the first case, in a family that does not claim # Universal Credit and does not claim Carer's Allowance: no one has an award # in respect of caring for the disabled person, who keeps the premium, diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 61b1ec50e2..c603307602 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -14,12 +14,12 @@ disability carer test, since the award itself reads Pension Credit, unless the simulation stores the award. Scotland's carer premium reads the positive award (receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when the -model counts a nil award as one with a value of zero, the award's conditions. Each example simulates the -households as drawn, and -again with has_uc_award_with_carer_element supplied as false for everyone, the -attribution as it ran before the limb (its carers are then -receives_carer_benefit alone). The Scottish premium's counterpart without the -limb is carer_premium. +model counts a nil award as one with a value of zero, the award's conditions. +Each example simulates the households as drawn, and again with +has_uc_award_with_carer_element supplied as false for everyone, the +attribution as it ran before the limb (its carers then come from +receives_carer_benefit alone). carer_premium gives the Scottish premium's +counterpart without the limb. Invariants, on generated households under baseline law: @@ -31,22 +31,24 @@ 2. Never raises: no carer element raises the severe disability premium (the shared, council tax reduction and pension-age premiums) or the Pension Credit addition. -3. Never lowers, and only in Scotland: council_tax_reduction_carer_premium is - never below carer_premium, and differs from it only for a Scottish +3. Never lowers, and only in Scotland: council_tax_reduction_carer_premium + never falls below carer_premium, and differs from it only for a Scottish working-age family. -4. Bounds: the Scottish premium is a whole number of weekly amounts, at most +4. Bounds: the Scottish premium comes to a whole number of weekly amounts, at most the number of claimants and partners, and at most one where partners care for the same person; a person with both an allowance and a carer element counts once. -5. Where the bound is nil, the award is nil: uc_award_upper_bound is at least - universal_credit_pre_benefit_cap, which is at least universal_credit, so - receives_uc_carer_element implies has_uc_award_with_carer_element. That - implication also holds where the simulation stores the award. -6. Attribution: no one is their own carer, everyone cared for is severely - disabled for Carer's Allowance, and a household has no more people cared - for than carers (carer benefit recipients and carer element holders). -7. No cycle: every reader calculates on one simulation, whichever is requested - first, and also where the simulation stores the award. +5. A nil bound leaves a nil award: uc_award_upper_bound >= + universal_credit_pre_benefit_cap >= universal_credit for awards the + model's formulas calculate, so receives_uc_carer_element implies + has_uc_award_with_carer_element. That implication also holds where the + simulation stores the award, which can exceed the bound. +6. Attribution: the attribution makes no one their own carer, everyone it + counts as cared for meets Carer's Allowance's severe disability test, and + no household has more people cared for than carers (carer benefit + recipients and carer element holders). +7. No cycle: every reader calculates on one simulation, whichever the test + requests first, and also where the simulation stores the award. 8. Endpoint parity: uc_award_upper_bound equals, exactly, the larger of two awards the model's own formulas calculate, one with non-dependant deductions supplied as nil and one with the housing costs element supplied @@ -223,9 +225,9 @@ def situation(population, without_limb=()): def with_and_without_limb(population, variables): """Each variable for the households as drawn, and without the limb. - Without the limb, has_uc_award_with_carer_element is supplied as false - for everyone, in a second simulation: a variable supplied for some people - takes its default, not its formula, for the rest. + Without the limb, a second simulation supplies + has_uc_award_with_carer_element as false for everyone: a variable supplied + for some people takes its default, not its formula, for the rest. """ sim = Simulation(situation=situation(population)) sim_without = Simulation( @@ -315,8 +317,8 @@ def test_issue_households_read_the_carer_element_award(): # #2120: the blind carer's award includes the carer element, 209.34 x 12, # so the disabled person has no severe disability premium (86.05 x 52 # without it). #2124: the Scottish carer's applicable amount includes the - # carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. Only variables - # that predate the fix are read, so the test also runs on the old code. + # carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. The test reads + # only variables that predate the fix, so it also runs on the old code. sim = Simulation(situation=situation(EXAMPLES[:1] + EXAMPLES[2:3])) severe = sim.calculate("severe_disability_premium", YEAR) carer_element = sim.calculate("uc_carer_element", YEAR) @@ -353,8 +355,8 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati benunit_household = household_index(sim, "benunit") person_household = household_index(sim, "person") - # 5. Where the bound is nil the award is nil, exactly, so the carer test - # misses no award. + # 5. A nil bound leaves a nil award, exactly, so the carer test misses no + # award. bound = drawn["uc_award_upper_bound"] award = drawn["universal_credit_pre_benefit_cap"] assert (bound >= award).all() @@ -411,9 +413,9 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati same_person = drawn["partners_care_for_same_severely_disabled_person"] assert (np.round(weekly[same_person]) <= 1).all() - # 6. Everyone cared for is severely disabled for Carer's Allowance; no - # household has more people cared for than carers, and a lone carer is - # not cared for. + # 6. Everyone cared for meets Carer's Allowance's severe disability test; no + # household has more people cared for than carers, and the attribution + # never counts a lone carer as cared for. assert not (cared_for & ~drawn["is_severely_disabled_for_carers_allowance"]).any() carer = drawn["receives_carer_benefit"] | has_award for h in np.unique(person_household): @@ -445,8 +447,8 @@ def test_every_reader_calculates_on_one_simulation_in_any_order(population, orde def stored_award_situation(stored=True): """The issue's blind carer on £20,000, which tapers the award the model - works out to nil, with neither family claiming; the award is supplied - instead: nil for the disabled person's family, £100 for the carer's.""" + works out to nil, with neither family claiming; the situation supplies + the award instead: nil for the disabled person's family, £100 for the carer's.""" sim_situation = situation( [ dict( @@ -496,7 +498,7 @@ def test_a_stored_award_is_the_award_and_reads_no_cycle(order): def test_a_reform_that_abolishes_universal_credit_leaves_no_carer_element_award(): - # A neutralized award reads no other variable and is nil, so no one has + # A neutralized award reads no other variable and comes to nil, so no one has # an award and the blind carer of the issue no longer bars the premium. from policyengine_core.reforms import Reform @@ -817,7 +819,8 @@ def parity_situation(rows, endpoint=None): """One household per (family, earned income, unearned income) row. Each family claims and takes no Tax-Free Childcare instead, with no benefit cap reduction or debt deduction, so the final award equals the award before - the cap. endpoint, a variable in ENDPOINTS, is supplied as nil.""" + the cap. The situation supplies endpoint, a variable in ENDPOINTS, as + nil.""" people, benunits, households = {}, {}, {} for i, (family, earned, unearned) in enumerate(rows): members = [] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index 6bd0f517d6..f05e1291c0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -32,10 +32,10 @@ class uc_award_upper_bound(Variable): "This amount reads neither, so that test can read it. It can exceed " "a nil award (#2795). A non-dependant deduction can take the award " "to nil. A family that takes Tax-Free Childcare instead gets no award " - "(forgoes_universal_credit_for_tax_free_childcare). And for a family " + "(forgoes_universal_credit_for_tax_free_childcare). For a family " "with a work allowance and a small housing costs element, the " "calculation without housing costs applies the higher work " - "allowance, whose extra earnings disregard can outweigh the housing " + "allowance. Its extra earnings disregard can outweigh the housing " "costs it drops, so it can stay positive when the award comes to nil, " "even with no non-dependant deduction." ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index c9f6dfd020..3d8f2b2983 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -33,7 +33,7 @@ class council_tax_reduction_carer_premium(Variable): "having an award, but calls reg 10A an expansion that gives the " "intention effect. Ipswich BC v TD [2024] UKUT 117 (AAC) holds that " "a nil award carries no Universal Credit entitlement, without " - "construing the Scottish definition. So while the earlier definition " + "construing the Scottish definition. While the earlier definition " "holds (uc_award_includes_zero_value_award), this variable reads limb " "(c) from the award's conditions: the family claims Universal Credit, " "meets its basic conditions and does not take Tax-Free Childcare " diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index bd5bf01c36..2b37522e7f 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -13,10 +13,11 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "para 14(2)(a)(iii) and (2)(b); IS, ESA and JSA alike) and of the " "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " "1(1) and (4)). The data say who receives a carer benefit but not whom " - "they care for, so each award is attributed. A carer benefit or carer " - "element is paid for caring for a person who is severely disabled for " - "Carer's Allowance (is_severely_disabled_for_carers_allowance; UC Regs " - "2013 reg 30), a carer has one award (SSCBA 1992 s.70(7)), and only " + "they care for, so the model attributes each award. DWP and Social " + "Security Scotland pay a carer benefit or carer element for caring " + "for a person severely disabled for Carer's Allowance " + "(is_severely_disabled_for_carers_allowance; UC Regs 2013 reg 30), a " + "carer has one award (SSCBA 1992 s.70(7)), and only " "one person has an allowance or a carer element for caring for the " "same severely disabled person (s.70(7ZA) to (7ZC) and (7A); both " "rules were in s.70(7) before 16 November 2023). No one is their own " @@ -44,23 +45,24 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "is receives_carer_benefit: a positive Carer's Allowance or Carer " "Support Payment after the overlapping-benefit adjustment. A carer " "whose allowance an overlapping benefit, such as a higher State " - "Pension, reduces to nil is entitled but not in receipt, so is not " - "attributed for the allowance. has_uc_award_with_carer_element gives " + "Pension, reduces to nil keeps the entitlement but receives nothing, " + "so the model does not attribute the allowance. " + "has_uc_award_with_carer_element gives " "a carer element award from the claim, the award's conditions and an " "upper bound on the award (uc_award_upper_bound), because the " "Universal Credit non-dependant deduction exemption reads Pension " "Credit, which reads the addition. A carer whose bound comes to nil " - "has no award (Welfare Reform Act 2012 s.5) and is not attributed for " - "it. The bound can stay positive after income reduces the actual " + "has no award (Welfare Reform Act 2012 s.5), and the model attributes " + "none. The bound can stay positive after income reduces the actual " "award to nil, when its calculation without housing costs gives a " "higher work allowance. A carer whose award a non-dependant deduction " - "takes to nil, or whose family takes Tax-Free Childcare instead, is " - "still attributed too, and can withhold a premium or addition the law " - "would pay (#2795). A carer with both an allowance and a carer element is one " - "carer. Partners who care for the same severely disabled person " + "takes to nil, or whose family takes Tax-Free Childcare instead, " + "still counts too, and can withhold a premium or addition the law " + "would pay (#2795). A carer with both an allowance and a carer element " + "counts once. Partners who care for the same severely disabled person " "(partners_care_for_same_severely_disabled_person) have one relevant " "entitlement between them (s.70(7ZA) and (7A)), so where one of them " - "receives a carer benefit, the other's carer element is not a second " + "receives a carer benefit, the other's carer element adds no second " "carer." ) definition_period = YEAR From 1d6df1a43cfbf7c445d16def69181a02797b088b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:58:23 -0500 Subject: [PATCH 067/168] Name the model as the payer in the ESA Pension Credit docstrings Co-Authored-By: Claude Opus 5.5 --- .../tests/test_esa_income_pension_age_properties.py | 11 +++++------ .../test_income_support_eligibility_properties.py | 12 ++++++------ .../variables/gov/dwp/esa_income_eligible.py | 6 +++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index a48648faf2..37ae283b49 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -894,12 +894,11 @@ def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( """In 2016 the man aged 63 who would not claim Pension Credit gets none and keeps his £5,000 award, and the man aged 63 who would claim gets the £8,091.20 minimum guarantee (52 x £155.60) and no award. The man aged 40 - does not meet the age conditions. Under each reform but the last, a - family outside the definition's defined_for gets something other than - the model's: Pension Credit pays him £2,000 or the minimum guarantee, so - he has an entitlement to it (Sch 1 para 6(1)(c)) and gets no award. The - model's own definitions, registered again, keep the shortcut, and he - keeps his £5,000.""" + does not meet the age conditions. Under each reform but the last, the + model pays him £2,000 of Pension Credit or the minimum guarantee outside + the definition's defined_for, so he has an entitlement to it (Sch 1 para + 6(1)(c)) and gets no award. The model's own definitions, registered + again, keep the shortcut, and he keeps his £5,000.""" year = 2016 sim = Simulation( situation=combine( diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index c494b0446e..3d41cc4c07 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -59,9 +59,9 @@ (has_attained_state_pension_credit_qualifying_age) and the model's proxy for being on Pension Credit (meets_pension_credit_age_conditions and would_claim_pc) from the model, and, for the income-related ESA screen, -pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether Pension Credit -pays the couple (Sch 1 para 6(1)(c) and (d)): no one drawn here has earnings, -and in 2025 no one has tax credits, so the model's pension_credit is the same +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether the model pays +the couple Pension Credit (Sch 1 para 6(1)(c) and (d)): no one drawn here has +earnings, and in 2025 no one has tax credits, so the model's pension_credit is the same with or without the couple's income-related ESA; and the means test is out of scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit @@ -406,9 +406,9 @@ def reference_eligibility( """The model's Income Support gate, read family by family. on_pension_credit is the model's proxy for s.124(1)(g); - entitled_to_pension_credit is whether Pension Credit pays the couple, - which the income-related ESA screen reads (WRA 2007 Sch 1 para 6(1)(c) - and (d)).""" + entitled_to_pension_credit records whether the model pays the couple + Pension Credit, which the income-related ESA screen reads (WRA 2007 Sch 1 + para 6(1)(c) and (d)).""" IS = parameters.gov.dwp.income_support WORK = IS.eligibility.remunerative_work JSA = parameters.gov.dwp.JSA.income diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index a25963a7f4..b966270258 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -48,9 +48,9 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): Core calculates one of them from (replaces_definition), such as the default a family outside its defined_for gets, or freeze Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) whatever the - reform's age conditions. Pension Credit can then pay a family that does - not meet the age conditions or would not claim. A reform can also give - would_claim_pc a formula, which could read the couple's ESA; the screen + reform's age conditions. The model can then pay Pension Credit to a + family that does not meet the age conditions or would not claim. A + reform can also give would_claim_pc a formula, which could read the couple's ESA; the screen would then close a cycle if it read take-up in the simulation itself. """ simulation = benunit.simulation From 4613ac60ffcddfead4f7a118d99a26a1d61f2c40 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:58:44 -0500 Subject: [PATCH 068/168] Give the bound's documentation a main verb and number every test invariant The bound's documentation opens with what never exceeds it and names has_uc_award_with_carer_element as its reader; the Scottish premium cites the financial condition reg 32A(1)(a) turns on; the attribution comment matches its documentation; and the property test's docstring numbers the trace, parity and component checks 8 to 10, as the pull request does. No formula changes. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_carer_element_award_properties.py | 10 +++++++--- .../gov/dwp/universal_credit/uc_award_upper_bound.py | 9 +++++---- .../council_tax_reduction_carer_premium.py | 5 +++-- .../is_cared_for_by_carer_benefit_recipient.py | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index c603307602..57034831b5 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -49,11 +49,15 @@ recipients and carer element holders). 7. No cycle: every reader calculates on one simulation, whichever the test requests first, and also where the simulation stores the award. -8. Endpoint parity: uc_award_upper_bound equals, exactly, the larger of two +8. The bound reads neither the award nor Pension Credit: its trace reaches + none of the award's steps, the non-dependant deduction or the attribution. +9. Endpoint parity: uc_award_upper_bound equals, exactly, the larger of two awards the model's own formulas calculate, one with non-dependant deductions supplied as nil and one with the housing costs element supplied - as nil, at the rows test_upper_bound_matches_real_award_endpoints draws, - and it reads every element of uc_maximum_amount in its order. + as nil, at the rows test_upper_bound_matches_real_award_endpoints draws. +10. Every component: the bound reads each element of uc_maximum_amount in its + order, and every input of the award's steps apart from the non-dependant + deduction, the Tax-Free Childcare choice and the claim. A reform that makes is_uc_eligible read Pension Credit would cycle through the attribution, which reads eligibility; these tests do not cover reforms. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index f05e1291c0..6907e11f96 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -6,10 +6,10 @@ class uc_award_upper_bound(Variable): entity = BenUnit label = "Upper bound on the Universal Credit award" documentation = ( - "An amount that the family's award before the benefit cap " + "The family's award before the benefit cap " "(universal_credit_pre_benefit_cap), as the model calculates it, " - "never exceeds, worked out without reading the non-dependant " - "deduction. DWP awards the maximum amount less the income reduction " + "never exceeds this amount, which the model works out without reading " + "the non-dependant deduction. DWP awards the maximum amount less the income reduction " "(UC Regs 2013 reg 22). The maximum amount includes the housing costs " "element (uc_housing_costs_element): the rent a renter may claim less " "DWP's non-dependant deductions. Where that element is positive, DWP " @@ -29,7 +29,8 @@ class uc_award_upper_bound(Variable): "minimum at one penny (reg 17). The deduction spares a non-dependant " "on Pension Credit, so it reads Pension Credit, whose severe " "disability addition reads who has an award with the carer element. " - "This amount reads neither, so that test can read it. It can exceed " + "This amount reads neither, so has_uc_award_with_carer_element can " + "read it. It can exceed " "a nil award (#2795). A non-dependant deduction can take the award " "to nil. A family that takes Tax-Free Childcare instead gets no award " "(forgoes_universal_credit_for_tax_free_childcare). For a family " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index 3d8f2b2983..9ee13545f3 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -27,8 +27,9 @@ class council_tax_reduction_carer_premium(Variable): "model reads the earlier definition as reaching a carer whose " "earnings take the award to nil, and so counts that nil award from " "2022-23 to 2024-25. The sources leave that reading open. UC Claims " - "and Payments Regs 2013 reg 32A(1)(a) says DWP makes no award where " - "income fails that condition. The Scottish Government's policy note " + "and Payments Regs 2013 reg 32A(1)(a) covers a claim on which DWP " + "makes no award because income fails the financial condition in " + "s.5(1)(b). The Scottish Government's policy note " "to SSI 2025/24 records a 2022 intention to treat such a claimant as " "having an award, but calls reg 10A an expansion that gives the " "intention effect. Ipswich BC v TD [2024] UKUT 117 (AAC) holds that " diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 2b37522e7f..a3a0fda087 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -83,7 +83,7 @@ def formula(person, period, parameters): could_be_cared_for = person("is_severely_disabled_for_carers_allowance", period) receives = person("receives_carer_benefit", period) # An award of Universal Credit which includes the carer element counts - # as a carer too. A partner's carer element is not a second carer for + # as a carer too. A partner's carer element adds no second carer for # a couple caring for the same person where one of them receives a # carer benefit: they have one relevant entitlement between them # (SSCBA 1992 s.70(7ZA) and (7A)). From 9d8d3e08bcb0a84b43ac1451b913fe08c0c9cebc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:14:36 -0500 Subject: [PATCH 069/168] Leave the Marriage Allowance out of the least-tax search ITA 2007 s. 55B(1) gives the Marriage Allowance as a tax reduction, which Step 6 of s. 23 deducts (s. 26 lists Chapter 3A), not as a deduction at Step 3. The search now leaves marriage_allowance out of the earned income exclusions it reads, so the s. 55B(2)(b) rate test can read the share without a cycle. Case 13 of least_tax_allocation.yaml fails when the search reads the allowance again. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 31 ++++++++++++++++++ .../tests/test_income_tax_allocation.py | 7 ++-- .../utils/income_tax_allocation.py | 32 ++++++++++++------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index c6cbd7b012..65688c2112 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -244,3 +244,34 @@ earned_income_tax: 7_540 dividend_income_tax: 6_874.725 income_tax: 14_414.725 + +- name: Case 13, the share leaves out the Marriage Allowance a recipient receives + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 5_000 + property_income: 15_000 + savings_interest_income: 15_000 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # The partner's unused allowance gives the recipient a Marriage Allowance + # of £1,260: 10% of £12,570, rounded up to a multiple of £10 (s. 55B(4) + # and (5)). Section 55B(1) gives it as a tax reduction, which Step 6 of + # s. 23 deducts, not as a deduction at Step 3, so the model leaves it out + # of the share. Pay, property income and the savings above the £1,000 + # savings allowance all bear 20%, and the starting rate band has gone + # (s. 12), so no share lowers the Step 5 tax, and the allowance comes off + # the pay and then the property income. Property 7,430 x 20% = 1,486; + # savings of £15,000 from £7,430: £1,000 at the savings nil rate and + # 14,000 x 20% = 2,800. + marriage_allowance: [1_260, 0] + received_allowances_earned_income: [12_570, 0] + received_allowances_savings_income: [0, 0] + income_tax_pre_charges: [4_286, 0] diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index dc27183bd9..49ddc55e62 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -339,8 +339,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): LIMITS = [500, 1_000, 5_000, 6_000, 12_570, 17_570, 37_700, 50_270, 100_000, 125_140] # Curated people at the edges of each test: other non-savings income, property, # savings, dividends, trade loss relief, allowances beyond the Personal -# Allowance, Marriage Allowance received, grossed-up Gift Aid, Scottish, and -# personal savings allowance. +# Allowance, an exclusion from earned income, grossed-up Gift Aid, Scottish, +# and personal savings allowance. EDGES = [ # Pay and savings across the basic rate limit: the search moves £270. (50_000, 0, 10_000, 0, 0, 0, 0, 0, False, 500), @@ -351,7 +351,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): (50_269, 0, 10_000, 0, 0, 0, 0, 0, False, 500), # Savings and dividends with no other income. (0, 0, 4_000, 20_000, 0, 0, 0, 0, False, 1_000), - # Marriage Allowance moves the savings below the basic rate limit. + # An exclusion from earned income moves the savings below the basic rate + # limit. (51_000, 0, 10_000, 0, 0, 0, 1_260, 0, False, 500), (49_000, 0, 0, 20_000, 0, 0, 1_260, 0, False, 500), # Savings exactly at the savings allowance, dividends at the allowance. diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 57ae81db5f..3087cc61f9 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -53,13 +53,16 @@ That argument needs the starting rate limit at or below the basic rate limit and a phase-out of one pound of starting rate band for each pound of -non-savings income (s. 12), as in every year modelled. Before 2027-28 it also -needs no Marriage Allowance: the model subtracts that allowance from earned -income (#1947) rather than giving the s. 55B tax reduction, which moves the -savings and dividends down by up to that amount. The search still evaluates -every candidate with the full tax, so with Marriage Allowance it takes the best -of them. In every case it keeps the order non-savings, savings, dividends -unless another share gives less tax, so it never charges more than that order. +non-savings income (s. 12), as in every year modelled. The search keeps the +order non-savings, savings, dividends unless another share gives less tax, so +it never charges more than that order. + +The search ignores the Marriage Allowance. Section 55B(1) gives it as a tax +reduction, which Step 6 deducts, not as a deduction at Step 3, and its rate +test (s. 55B(2)(b)) reads the share. The model still subtracts the allowance +from earned income (#1947), which moves a recipient's savings and dividends +down by up to that amount, so for a recipient the search's tax can differ from +the model's. Who cannot gain --------------- @@ -90,8 +93,9 @@ because the starting rate band shrinks by at most a pound for each pound of non-savings income (s. 12) and the savings and dividend nil rates cover fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no - Marriage Allowance or no property income: with property income, the - model's Marriage Allowance breaks the rising rate on the first component. + property income or no exclusion from earned income that the search reads: + with property income, such an exclusion breaks the rising rate on the + first component. Where any test holds, the search would keep the old order too, since it changes the share only for a saving above ``TOLERANCE``. So the result is the @@ -119,6 +123,10 @@ "taxable_property_income", "received_allowances_earned_income", ) +# The exclusions the search leaves out. s. 55B(1) gives the Marriage Allowance +# as a tax reduction, not a deduction at Step 3, and its rate test reads the +# share, so the search shares a recipient's deductions as if they had none. +IGNORED_EXCLUSIONS = ("marriage_allowance",) def _schedule(scale, gift, first=1, last=3): @@ -148,7 +156,7 @@ def values(variable): exclusions = [ variable for variable in income_tax.earned_taxable_income_exclusions - if variable not in ALLOCATED_EXCLUSIONS + if variable not in ALLOCATED_EXCLUSIONS + IGNORED_EXCLUSIONS ] savings = values("taxable_savings_interest_income") return dict( @@ -517,8 +525,8 @@ def _old_order_is_least(c, to_savings, to_dividends): ) in_reach = in_first else: - # With no property income, Marriage Allowance only moves every band - # position down by its amount. + # With no property income, an exclusion from earned income only moves + # every band position down by its amount. position = x - c.earned_exclusions rho_n = np.where(c.scottish, _rate(c.scotland, position), _rate(c.uk, position)) in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) From e6c2465b44311a69c7a45f53d6abda52a52b2fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:52:04 +0000 Subject: [PATCH 070/168] Start SDLT's 2016 and 2017 changes on their statutory dates Date first-time buyers' relief from 22 November 2017 (FA 2018 s 41(8)), the higher rates for additional dwellings from 1 April 2016 (FA 2016 s 128(5)) and the non-residential rates and 2% lease rent band from 17 March 2016 (FA 2016 s 127(12)), with pre-2015 entries so backdating cannot carry them back. Charge non-residential purchases before then on the whole price, and charge SDLT in Wales until LTT starts on 1 April 2018. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6H63fPR8DRJbSWXuci --- changelog.d/2801.fixed.md | 1 + .../gov/hmrc/stamp_duty/applies_in_wales.yaml | 22 ++ .../stamp_duty/non_residential/purchase.yaml | 34 +- .../purchase_charged_on_whole_price.yaml | 22 ++ .../hmrc/stamp_duty/non_residential/rent.yaml | 31 +- .../residential/purchase/additional/min.yaml | 16 +- .../residential/purchase/additional/rate.yaml | 114 +++++- .../residential/purchase/main/first/max.yaml | 20 +- .../residential/purchase/main/first/rate.yaml | 10 +- .../stamp_duty_land_tax_commencement.yaml | 331 ++++++++++++++++++ .../variables/gov/hmrc/sdlt_liable.py | 12 +- ...n_non_residential_property_transactions.py | 26 +- .../variables/gov/wra/ltt_liable.py | 11 +- 13 files changed, 618 insertions(+), 32 deletions(-) create mode 100644 changelog.d/2801.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml diff --git a/changelog.d/2801.fixed.md b/changelog.d/2801.fixed.md new file mode 100644 index 0000000000..afeae0051b --- /dev/null +++ b/changelog.d/2801.fixed.md @@ -0,0 +1 @@ +Stamp Duty Land Tax now starts first-time buyers' relief on 22 November 2017, the higher rates for additional dwellings on 1 April 2016 and the 2016 non-residential rates on 17 March 2016, charges a non-residential purchase before then at one percentage of the whole price, and charges Welsh buyers SDLT until 31 March 2018 and Land Transaction Tax from 1 April 2018. diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml new file mode 100644 index 0000000000..d87fa92458 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml @@ -0,0 +1,22 @@ +description: Whether HMRC charges Stamp Duty Land Tax on land in Wales. When it does not, the Welsh Revenue Authority charges Land Transaction Tax there instead. +values: + 2003-07-10: + value: true + metadata: + reference: + - title: Finance Act 2003 s. 48(1)(a), as at 30 April 2017 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30 + 2018-04-01: + value: false + metadata: + reference: + - title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) Regulations 2018, schedule + href: https://www.legislation.gov.uk/wsi/2018/128/schedule +metadata: + unit: bool + label: Stamp Duty applies in Wales + reference: + - title: Finance Act 2003 s. 48(1)(a), as at 30 April 2017 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30 + - title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) Regulations 2018, schedule + href: https://www.legislation.gov.uk/wsi/2018/128/schedule diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml index 3f73561c63..d7ab62889a 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml @@ -5,27 +5,51 @@ brackets: 2003-07-10: 0 - rate: 2003-07-10: 0.01 - 2016-09-15: 0.02 + 2016-03-17: + value: 0.02 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 150000 - rate: 2003-07-10: 0.03 - 2016-09-15: 0.05 + 2016-03-17: + value: 0.05 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 250000 - rate: 2003-07-10: 0.04 - 2016-09-15: null + 2016-03-17: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 500000 - 2016-09-15: null -description: Stamp Duty Land Tax progressive rates for non-residential property transfers. + 2016-03-17: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) +description: Stamp Duty Land Tax rates for non-residential property transfers, by band of the price. The purchase_charged_on_whole_price parameter beside this one sets whether HMRC charges the rate for the band the price falls in on the whole price, or each band's rate on the slice of the price in that band. metadata: economy: false label: Stamp Duty on non-residential property propagate_metadata_to_children: true rate_unit: /1 reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + title: Finance Act 2003 s. 55(2) Table B, as at 30 April 2015 + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127 - href: https://www.legislation.gov.uk/ukpga/2003/14/part/4 title: Finance Act 2003 s. 55 threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml new file mode 100644 index 0000000000..bc6ffb1e66 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml @@ -0,0 +1,22 @@ +description: Whether HMRC charges a non-residential purchase at the single percentage of the whole price that the non-residential purchase rates set for the band the price falls in, rather than at each band's rate on the slice of the price in that band. +values: + 2003-07-10: + value: true + metadata: + reference: + - title: Finance Act 2003 s. 55(2), as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-03-17: + value: false + metadata: + reference: + - title: Finance Act 2016 s. 127(12) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted +metadata: + unit: bool + label: Stamp Duty on non-residential purchases charged on the whole price + reference: + - title: Finance Act 2003 s. 55(2), as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 127(12) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml index 7139990825..b55ae172ed 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml @@ -8,9 +8,32 @@ brackets: threshold: 2003-07-10: 150000 - rate: - 2016-09-15: 0.02 + 2003-07-10: + value: null + metadata: + note: Before 17 March 2016 HMRC charged no band above the 1% band. + reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + 2016-03-17: + value: 0.02 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: - 2016-09-15: 5000000 + 2003-07-10: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + 2016-03-17: + value: 5000000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) description: Stamp Duty Land Tax progressive rates for cumulative rents on non-residential property. metadata: @@ -21,4 +44,8 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5 title: Finance Act 2003 schedule 5 + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml index 2640c724b5..8c13b17247 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml @@ -1,6 +1,20 @@ description: Minimum value for taxability of additional residential property. values: - 2016-09-15: 40_000 + 2014-12-04: + value: 0 + metadata: + note: Before the higher rates for additional dwellings started, a buyer of an additional dwelling paid the main rates in s. 55(1B) Table A at any price. + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 40_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(3)(4)(b) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA metadata: unit: currency-GBP label: Stamp Duty secondary residence minimum value diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml index a8eca87163..3a2d8bbc69 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml @@ -1,12 +1,43 @@ description: Stamp Duty Land Tax progressive rates for residential property transfers that are not replacements of an individual's primary residence. brackets: - rate: - 2016-09-15: 0.03 + 2014-12-04: + value: 0 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 0.03 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(1)(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.05 threshold: - 2016-09-15: 0 + 2014-12-04: + value: 0 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 - rate: - 2016-09-15: 0.05 + 2014-12-04: + value: 0.02 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 0.05 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(1)(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: null metadata: @@ -24,7 +55,12 @@ brackets: 2022-09-23: null 2025-04-01: 0.07 threshold: - 2016-09-15: 125_000 + 2014-12-04: + value: 125_000 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2020-07-08: value: null metadata: @@ -42,10 +78,28 @@ brackets: 2022-09-23: null 2025-04-01: 125_000 - rate: - 2016-09-15: 0.08 + 2014-12-04: + value: 0.05 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 0.08 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(1)(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.10 threshold: - 2016-09-15: 250_000 + 2014-12-04: + value: 250_000 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2020-07-08: value: 500_000 metadata: @@ -61,22 +115,62 @@ brackets: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1), (3), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - rate: - 2016-09-15: 0.13 + 2014-12-04: + value: 0.10 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 0.13 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(1)(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.15 threshold: - 2016-09-15: 925_000 + 2014-12-04: + value: 925_000 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 - rate: - 2016-09-15: 0.15 + 2014-12-04: + value: 0.12 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: 0.15 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA(1)(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.17 threshold: - 2016-09-15: 1_500_000 + 2014-12-04: + value: 1_500_000 + metadata: + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 metadata: threshold_unit: currency-GBP rate_unit: /1 label: Stamp Duty on secondary residences reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 - title: Finance Act 2003 schedule 4ZA(1)(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted - title: Finance Act 2021 s. 87 diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml index 6c8ccd9dbd..3641a1a79c 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml @@ -1,6 +1,22 @@ description: Maximum value for a first-property purchase to receive the Stamp Duty relief for first-time buyers. Increasing this value will underestimate the number of claims for FTBR, as the model will not add new claims. values: - 2018-03-15: 500_000 + 2014-12-04: + value: 0 + metadata: + note: Before Parliament inserted Schedule 6ZA, a first-time buyer paid the main rates in s. 55(1B) Table A, so no purchase qualifies for the relief. + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted + 2017-11-22: + value: 500_000 + metadata: + reference: + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted + - title: Finance Act 2003 schedule 6ZA(1)(3) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 2020-07-08: value: 0 metadata: @@ -24,6 +40,8 @@ metadata: reference: - title: Finance Act 2003 schedule 6ZA(1)(3) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1(5) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1 - title: Finance Act 2021 s. 87 diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml index 8033317913..dd83480f85 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml @@ -1,12 +1,12 @@ brackets: - rate: - 2018-03-15: 0 + 2017-11-22: 0 threshold: - 2018-03-15: 0 + 2017-11-22: 0 - rate: - 2018-03-15: 0.05 + 2017-11-22: 0.05 threshold: - 2018-03-15: 300000 + 2017-11-22: 300000 2022-09-23: 425000 2025-04-01: 300000 description: Stamp Duty Land Tax progressive rates for residential property transfers. @@ -18,6 +18,8 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 title: Finance Act 2003 schedule 6ZA(4) + - href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted + title: Finance Act 2018 s. 41(8) - href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(5) threshold_unit: currency-GBP diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml new file mode 100644 index 0000000000..57d4093cf4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml @@ -0,0 +1,331 @@ +# Purchases from issue #2801 in model years 2015 to 2018. Annual parameters +# sample 30 April, so model year 2015 reads the law on 30 April 2015, and so on. +# Commencement: Finance Act 2018 s. 41(8) (first-time buyers' relief, from 22 +# November 2017); Finance Act 2016 s. 128(5) (higher rates for additional +# dwellings, from 1 April 2016); Finance Act 2016 s. 127(12) (non-residential +# rates and the 2% lease rent band, from 17 March 2016). Before those dates: +# Finance Act 2003 s. 55(1B) Table A and s. 55(2) Table B, and Sch 5 para 2(3) +# Table B, each as at 30 April 2015. HMRC charges SDLT on land in Wales until +# 1 April 2018 (s. 48(1)(a) as at 30 April 2017); from then the Welsh Revenue +# Authority charges Land Transaction Tax (WSI 2018/128). + +- name: First-time buyer, £300,000, model year 2015, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2015 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2015, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £300,000, model year 2016, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2016, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £300,000, model year 2017, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2017 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2017, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2017 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £300,000, model year 2018, gets the relief + # Sch 6ZA para 4: 0% to £300,000. + period: 2018 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 0 + +- name: First-time buyer, £450,000, model year 2018, gets the relief + # Sch 6ZA para 4: 5% x 150,000. + period: 2018 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 7_500 + +- name: Additional dwelling, £300,000, model year 2015, pays the main rates + # Sch 4ZA starts on 1 April 2016 (FA 2016 s. 128(5)); before then s. 55(1B) Table A applies. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 5_000 + +- name: Additional dwelling, £100,000, model year 2015, pays the main rates + # 0% on the first £125,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 0 + +- name: Additional dwelling, £300,000, model year 2016, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2016 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2016, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2016 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Additional dwelling, £300,000, model year 2017, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2017 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2017, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2017 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Additional dwelling, £300,000, model year 2018, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2018 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2018, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2018 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Non-residential purchase, £1,000,000, model year 2015, pays one percentage of the whole price + # 4% x 1,000,000 under s. 55(2) Table B, before 17 March 2016 (FA 2016 s. 127(12)). + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 40_000 + +- name: Non-residential purchase, £1,000,000, model year 2016, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2016 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential purchase, £1,000,000, model year 2017, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2017 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential purchase, £1,000,000, model year 2018, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2018 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2015, pays 1% + # 1% x 1,000,000 under Sch 5 para 2(3) Table B, which had no band above 1% before 17 March 2016. + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 10_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2016, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2016 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2017, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2017 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2018, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2018 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Wales, main home, £300,000, model year 2015, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2015 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2016, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2016 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2017, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2017 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2018, pays LTT + # WSI 2018/128 from 1 April 2018: 3.5% x 70,000 + 5% x 50,000. + period: 2018 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: false + ltt_liable: true + stamp_duty_land_tax: 0 + land_transaction_tax: 4_950 + +- name: Wales, additional dwelling, £300,000, model year 2015, pays SDLT at the main rates + period: 2015 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2016, pays SDLT at the higher rates + period: 2016 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2017, pays SDLT at the higher rates + period: 2017 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2018, pays LTT + # LTT higher rates in gov.wra.land_transaction_tax.residential.higher_rate from 1 April 2018: 3% x 180,000 + 6.5% x 70,000 + 8% x 50,000. + period: 2018 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 0 + land_transaction_tax: 13_950 diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e450df98a9..0501fe143f 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -5,18 +5,24 @@ class sdlt_liable(Variable): label = "Liable for Stamp Duty" documentation = ( "Whether the household is liable for Stamp Duty Land Tax: it lives in " - "England or Northern Ireland and the tax has not been abolished." + "England or Northern Ireland, or in Wales while HMRC charges the tax " + "there, and the tax has not been abolished." ) entity = Household definition_period = YEAR value_type = bool + reference = "https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30" def formula(household, period, parameters): + stamp_duty = parameters(period).gov.hmrc.stamp_duty country = household("country", period) countries = country.possible_values in_england_or_northern_ireland = np.isin( country.decode_to_str(), [countries.ENGLAND.name, countries.NORTHERN_IRELAND.name], ) - abolished = parameters(period).gov.hmrc.stamp_duty.abolish - return in_england_or_northern_ireland & (not abolished) + in_wales = country == countries.WALES + in_charged_area = in_england_or_northern_ireland | ( + in_wales & stamp_duty.applies_in_wales + ) + return in_charged_area & (not stamp_duty.abolish) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py index 6f2fd883d2..f549d45a97 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py @@ -3,7 +3,14 @@ class sdlt_on_non_residential_property_transactions(Variable): label = "Stamp Duty on non-residential property" - documentation = "Tax charge from purchase of non-residential property" + documentation = ( + "Tax charge from purchase of non-residential property. When " + "gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price " + "is true, the formula charges the rate of the band the price falls in " + "on the whole price, where a price falls in the highest band whose " + "threshold it exceeds. Otherwise it charges each band's rate on the " + "slice of the price in that band." + ) entity = Household definition_period = YEAR value_type = float @@ -11,6 +18,19 @@ class sdlt_on_non_residential_property_transactions(Variable): reference = "https://www.legislation.gov.uk/ukpga/2003/14/section/55" def formula(household, period, parameters): - stamp_duty = parameters(period).gov.hmrc.stamp_duty + non_residential = parameters(period).gov.hmrc.stamp_duty.non_residential price = household("non_residential_property_purchased", period) - return stamp_duty.non_residential.purchase.calc(price) + scale = non_residential.purchase + thresholds = np.array(scale.thresholds, dtype=float) + rates = np.array(scale.rates, dtype=float) + # Each band covers prices that exceed its threshold and do not exceed + # the next one; a price of zero falls in the first band. + exceeds = np.asarray(price, dtype=float)[:, None] > thresholds[None, :] + band = max_(exceeds.sum(axis=1) - 1, 0) + whole_price_tax = rates[band] * price + slice_tax = scale.calc(price) + return where( + non_residential.purchase_charged_on_whole_price, + whole_price_tax, + slice_tax, + ) diff --git a/policyengine_uk/variables/gov/wra/ltt_liable.py b/policyengine_uk/variables/gov/wra/ltt_liable.py index 94b548761f..c6c9c3f267 100644 --- a/policyengine_uk/variables/gov/wra/ltt_liable.py +++ b/policyengine_uk/variables/gov/wra/ltt_liable.py @@ -4,12 +4,17 @@ class ltt_liable(Variable): label = "Liable for Land Transaction Tax" documentation = ( - "Whether the household is liable to pay the Wales Land Transaction Tax" + "Whether the household is liable to pay the Wales Land Transaction " + "Tax: it lives in Wales and HMRC no longer charges Stamp Duty Land " + "Tax there." ) entity = Household definition_period = YEAR value_type = bool + reference = "https://www.legislation.gov.uk/wsi/2018/128/schedule" - def formula(household, period): + def formula(household, period, parameters): country = household("country", period) - return country == country.possible_values.WALES + in_wales = country == country.possible_values.WALES + sdlt_applies_in_wales = parameters(period).gov.hmrc.stamp_duty.applies_in_wales + return in_wales & (not sdlt_applies_in_wales) From c29c2c40b74eddd3ca43664eda0898d78d95f9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:17:31 +0000 Subject: [PATCH 071/168] Test SDLT's statutory start dates and document them Add Hypothesis properties: a Welsh purchase is liable to exactly one of SDLT and LTT in every model year from 2015 to 2030; Table B charges the whole price in model year 2015; transaction taxes are never negative; and model years from 2018 match a grid pinned from main, apart from the years the 2020-25 relief dating changes. Add reform cases for the two new switches and describe the 2016-18 start dates on the stamp duty page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6H63fPR8DRJbSWXuci --- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 15 + .../fixtures/sdlt_ltt_main_grid_2018_2030.csv | 526 ++++++++++++++++++ .../stamp_duty_land_tax_commencement.yaml | 23 + ..._sdlt_statutory_commencement_properties.py | 251 +++++++++ 4 files changed, 815 insertions(+) create mode 100644 policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv create mode 100644 policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index 31976439f5..a6e28e0cdc 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -16,6 +16,21 @@ "The regulation of SDLT is defined in part 4 of the [Finance Act 2003](https://www.legislation.gov.uk/ukpga/2003/14/part/4)." ] }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start dates in 2016 to 2018\n", + "\n", + "The parameters date each change to the effective date Parliament set, and each model year reads the parameters in force on 30 April:\n", + "\n", + "- First-time buyers' relief starts on 22 November 2017 ([Finance Act 2018 s41(8)](https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted)), so the model first grants it in model year 2018.\n", + "- The higher rates for additional dwellings start on 1 April 2016 ([Finance Act 2016 s128(5)](https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted)). In model year 2015 a buyer of an additional dwelling pays the main rates at any price.\n", + "- The sliced non-residential rates and the 2% band on lease rent above £5 million start on 17 March 2016 ([Finance Act 2016 s127(12)](https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted)). In model year 2015 the model charges a non-residential purchase the percentage for the band its price falls in on the whole price, under [s55(2) Table B as at 30 April 2015](https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30): 0% up to £150,000, 1% up to £250,000, 3% up to £500,000 and 4% above. A £1,000,000 purchase pays £40,000 in model year 2015 and £39,500 from model year 2016.\n", + "- HMRC charges SDLT on land in Wales until 31 March 2018 ([Finance Act 2003 s48(1)(a) as at 30 April 2017](https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30)), and the Welsh Revenue Authority charges Land Transaction Tax from 1 April 2018. The model charges SDLT in Wales up to model year 2017 and Land Transaction Tax from model year 2018. The parameter `gov.hmrc.stamp_duty.applies_in_wales` sets the switch." + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv b/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv new file mode 100644 index 0000000000..59d0980666 --- /dev/null +++ b/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv @@ -0,0 +1,526 @@ +region,kind,price,sdlt_2018,ltt_2018,sdlt_2019,ltt_2019,sdlt_2020,ltt_2020,sdlt_2022,ltt_2022,sdlt_2025,ltt_2025,sdlt_2026,ltt_2026,sdlt_2027,ltt_2027,sdlt_2028,ltt_2028,sdlt_2029,ltt_2029,sdlt_2030,ltt_2030 +LONDON,main,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,main,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,main,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,main,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,main,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,main,125001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +LONDON,main,150000,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0 +LONDON,main,150001,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0 +LONDON,main,180000,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0 +LONDON,main,225000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,main,250000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,main,250001,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0 +LONDON,main,300000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,main,400000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,main,425000,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0 +LONDON,main,450000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +LONDON,main,500000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,main,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +LONDON,main,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +LONDON,main,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +LONDON,main,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +LONDON,main,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +LONDON,main,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,main,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +LONDON,main,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +LONDON,first,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,150001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,180000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,225000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,250000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,250001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,300000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,first,400000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,first,425000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,first,450000,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0 +LONDON,first,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,first,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +LONDON,first,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +LONDON,first,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +LONDON,first,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +LONDON,first,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +LONDON,first,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,first,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +LONDON,first,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +LONDON,additional,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,additional,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,additional,40000,1200.0,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,additional,100000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,additional,125000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,additional,125001,3750.05,0.0,3750.05,0.0,3750.05,0.0,3750.05,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0 +LONDON,additional,150000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,additional,150001,5000.05,0.0,5000.05,0.0,5000.05,0.0,5000.05,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0 +LONDON,additional,180000,6500.0,0.0,6500.0,0.0,6500.0,0.0,6500.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0 +LONDON,additional,225000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0 +LONDON,additional,250000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,additional,250001,10000.08,0.0,10000.08,0.0,10000.08,0.0,10000.08,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0 +LONDON,additional,300000,14000.0,0.0,14000.0,0.0,14000.0,0.0,14000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +LONDON,additional,400000,22000.0,0.0,22000.0,0.0,22000.0,0.0,22000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +LONDON,additional,425000,24000.0,0.0,24000.0,0.0,24000.0,0.0,24000.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0 +LONDON,additional,450000,26000.0,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0 +LONDON,additional,500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +LONDON,additional,500001,30000.08,0.0,30000.08,0.0,30000.08,0.0,30000.08,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0 +LONDON,additional,625000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0 +LONDON,additional,750000,50000.0,0.0,50000.0,0.0,50000.0,0.0,50000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0 +LONDON,additional,925000,64000.0,0.0,64000.0,0.0,64000.0,0.0,64000.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0 +LONDON,additional,1000000,73750.0,0.0,73750.0,0.0,73750.0,0.0,73750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,additional,1500000,138750.0,0.0,138750.0,0.0,138750.0,0.0,138750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0 +LONDON,additional,2000000,213750.0,0.0,213750.0,0.0,213750.0,0.0,213750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0 +LONDON,additional,6000000,813750.0,0.0,813750.0,0.0,813750.0,0.0,813750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0 +LONDON,non_residential,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential,150001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +LONDON,non_residential,180000,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0 +LONDON,non_residential,225000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +LONDON,non_residential,250000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,non_residential,250001,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0 +LONDON,non_residential,300000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +LONDON,non_residential,400000,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0 +LONDON,non_residential,425000,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0 +LONDON,non_residential,450000,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0 +LONDON,non_residential,500000,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0 +LONDON,non_residential,500001,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0 +LONDON,non_residential,625000,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0 +LONDON,non_residential,750000,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0 +LONDON,non_residential,925000,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0 +LONDON,non_residential,1000000,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0 +LONDON,non_residential,1500000,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0 +LONDON,non_residential,2000000,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0 +LONDON,non_residential,6000000,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0 +LONDON,residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,residential_lease,125001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +LONDON,residential_lease,150000,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0 +LONDON,residential_lease,150001,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0 +LONDON,residential_lease,180000,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0 +LONDON,residential_lease,225000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +LONDON,residential_lease,250000,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0 +LONDON,residential_lease,250001,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0 +LONDON,residential_lease,300000,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0 +LONDON,residential_lease,400000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +LONDON,residential_lease,425000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,residential_lease,450000,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0 +LONDON,residential_lease,500000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0 +LONDON,residential_lease,500001,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0 +LONDON,residential_lease,625000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,residential_lease,750000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,residential_lease,925000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,residential_lease,1000000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0 +LONDON,residential_lease,1500000,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0 +LONDON,residential_lease,2000000,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0 +LONDON,residential_lease,6000000,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0 +LONDON,non_residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease,150001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +LONDON,non_residential_lease,180000,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0 +LONDON,non_residential_lease,225000,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0 +LONDON,non_residential_lease,250000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +LONDON,non_residential_lease,250001,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0 +LONDON,non_residential_lease,300000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +LONDON,non_residential_lease,400000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,non_residential_lease,425000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +LONDON,non_residential_lease,450000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,non_residential_lease,500000,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0 +LONDON,non_residential_lease,500001,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0 +LONDON,non_residential_lease,625000,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0 +LONDON,non_residential_lease,750000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +LONDON,non_residential_lease,925000,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0 +LONDON,non_residential_lease,1000000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +LONDON,non_residential_lease,1500000,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0 +LONDON,non_residential_lease,2000000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +LONDON,non_residential_lease,6000000,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0 +LONDON,non_residential_lease_above_5m,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +LONDON,non_residential_lease_above_5m,39999,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0 +LONDON,non_residential_lease_above_5m,40000,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0 +LONDON,non_residential_lease_above_5m,100000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,non_residential_lease_above_5m,125000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,non_residential_lease_above_5m,125001,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0 +LONDON,non_residential_lease_above_5m,150000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,non_residential_lease_above_5m,150001,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0 +LONDON,non_residential_lease_above_5m,180000,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0 +LONDON,non_residential_lease_above_5m,225000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +LONDON,non_residential_lease_above_5m,250000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,non_residential_lease_above_5m,250001,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0 +LONDON,non_residential_lease_above_5m,300000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +LONDON,non_residential_lease_above_5m,400000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,non_residential_lease_above_5m,425000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +LONDON,non_residential_lease_above_5m,450000,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0 +LONDON,non_residential_lease_above_5m,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,non_residential_lease_above_5m,500001,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0 +LONDON,non_residential_lease_above_5m,625000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +LONDON,non_residential_lease_above_5m,750000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,non_residential_lease_above_5m,925000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +LONDON,non_residential_lease_above_5m,1000000,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +LONDON,non_residential_lease_above_5m,1500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +LONDON,non_residential_lease_above_5m,2000000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +LONDON,non_residential_lease_above_5m,6000000,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0 +WALES,main,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,150001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,180000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,225000,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,main,250000,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0 +WALES,main,250001,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06 +WALES,main,300000,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,main,400000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0 +WALES,main,425000,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0 +WALES,main,450000,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0 +WALES,main,500000,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0 +WALES,main,500001,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07 +WALES,main,625000,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0 +WALES,main,750000,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0 +WALES,main,925000,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0 +WALES,main,1000000,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0 +WALES,main,1500000,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0 +WALES,main,2000000,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0 +WALES,main,6000000,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0 +WALES,first,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,150001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,180000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,225000,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,first,250000,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0 +WALES,first,250001,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06 +WALES,first,300000,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,first,400000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0 +WALES,first,425000,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0 +WALES,first,450000,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0 +WALES,first,500000,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0 +WALES,first,500001,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07 +WALES,first,625000,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0 +WALES,first,750000,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0 +WALES,first,925000,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0 +WALES,first,1000000,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0 +WALES,first,1500000,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0 +WALES,first,2000000,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0 +WALES,first,6000000,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0 +WALES,additional,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,additional,39999,0.0,1199.97,0.0,1199.97,0.0,1199.97,0.0,1599.96,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95 +WALES,additional,40000,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,1600.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,additional,100000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,4000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0 +WALES,additional,125000,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,5000.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0 +WALES,additional,125001,0.0,3750.03,0.0,3750.03,0.0,3750.03,0.0,5000.04,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05 +WALES,additional,150000,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,6000.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0 +WALES,additional,150001,0.0,4500.03,0.0,4500.03,0.0,4500.03,0.0,6000.04,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05 +WALES,additional,180000,0.0,5400.0,0.0,5400.0,0.0,5400.0,0.0,7200.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,additional,225000,0.0,8325.0,0.0,8325.0,0.0,8325.0,0.0,10575.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0 +WALES,additional,250000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,12450.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0 +WALES,additional,250001,0.0,9950.08,0.0,9950.08,0.0,9950.08,0.0,12450.09,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1 +WALES,additional,300000,0.0,13950.0,0.0,13950.0,0.0,13950.0,0.0,16950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0 +WALES,additional,400000,0.0,21950.0,0.0,21950.0,0.0,21950.0,0.0,25950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0 +WALES,additional,425000,0.0,24575.0,0.0,24575.0,0.0,24575.0,0.0,28825.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0 +WALES,additional,450000,0.0,27200.0,0.0,27200.0,0.0,27200.0,0.0,31700.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0 +WALES,additional,500000,0.0,32450.0,0.0,32450.0,0.0,32450.0,0.0,37450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0 +WALES,additional,500001,0.0,32450.11,0.0,32450.11,0.0,32450.11,0.0,37450.11,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12 +WALES,additional,625000,0.0,45575.0,0.0,45575.0,0.0,45575.0,0.0,51825.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0 +WALES,additional,750000,0.0,58700.0,0.0,58700.0,0.0,58700.0,0.0,66200.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0 +WALES,additional,925000,0.0,81450.0,0.0,81450.0,0.0,81450.0,0.0,90700.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0 +WALES,additional,1000000,0.0,91200.0,0.0,91200.0,0.0,91200.0,0.0,101200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0 +WALES,additional,1500000,0.0,156200.0,0.0,156200.0,0.0,156200.0,0.0,171200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0 +WALES,additional,2000000,0.0,231200.0,0.0,231200.0,0.0,231200.0,0.0,251200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0 +WALES,additional,6000000,0.0,831200.0,0.0,831200.0,0.0,831200.0,0.0,891200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0 +WALES,non_residential,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,150001,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,180000,0.0,300.0,0.0,300.0,0.0,300.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,225000,0.0,750.0,0.0,750.0,0.0,750.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential,250000,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0 +WALES,non_residential,250001,0.0,1000.05,0.0,1000.05,0.0,1000.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05 +WALES,non_residential,300000,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0 +WALES,non_residential,400000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0 +WALES,non_residential,425000,0.0,9750.0,0.0,9750.0,0.0,9750.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,non_residential,450000,0.0,11000.0,0.0,11000.0,0.0,11000.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0 +WALES,non_residential,500000,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0 +WALES,non_residential,500001,0.0,13500.05,0.0,13500.05,0.0,13500.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05 +WALES,non_residential,625000,0.0,19750.0,0.0,19750.0,0.0,19750.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0 +WALES,non_residential,750000,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0 +WALES,non_residential,925000,0.0,34750.0,0.0,34750.0,0.0,34750.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0 +WALES,non_residential,1000000,0.0,38500.0,0.0,38500.0,0.0,38500.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0 +WALES,non_residential,1500000,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0 +WALES,non_residential,2000000,0.0,98500.0,0.0,98500.0,0.0,98500.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0 +WALES,non_residential,6000000,0.0,338500.0,0.0,338500.0,0.0,338500.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0 +WALES,residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,150001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,180000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,225000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,250000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,250001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,300000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,400000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,425000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,450000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,500000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,500001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,625000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,750000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,925000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,1000000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,1500000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,2000000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,residential_lease,6000000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,150001,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,180000,0.0,300.0,0.0,300.0,0.0,300.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,225000,0.0,750.0,0.0,750.0,0.0,750.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease,250000,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0 +WALES,non_residential_lease,250001,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01 +WALES,non_residential_lease,300000,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0 +WALES,non_residential_lease,400000,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0 +WALES,non_residential_lease,425000,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,non_residential_lease,450000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0 +WALES,non_residential_lease,500000,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0 +WALES,non_residential_lease,500001,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01 +WALES,non_residential_lease,625000,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0 +WALES,non_residential_lease,750000,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0 +WALES,non_residential_lease,925000,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0 +WALES,non_residential_lease,1000000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0 +WALES,non_residential_lease,1500000,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0 +WALES,non_residential_lease,2000000,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0 +WALES,non_residential_lease,6000000,0.0,98500.0,0.0,98500.0,0.0,98500.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0 +WALES,non_residential_lease_above_5m,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +WALES,non_residential_lease_above_5m,39999,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98 +WALES,non_residential_lease_above_5m,40000,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0 +WALES,non_residential_lease_above_5m,100000,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,non_residential_lease_above_5m,125000,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0 +WALES,non_residential_lease_above_5m,125001,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02 +WALES,non_residential_lease_above_5m,150000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0 +WALES,non_residential_lease_above_5m,150001,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02 +WALES,non_residential_lease_above_5m,180000,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0 +WALES,non_residential_lease_above_5m,225000,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,non_residential_lease_above_5m,250000,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0 +WALES,non_residential_lease_above_5m,250001,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02 +WALES,non_residential_lease_above_5m,300000,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0 +WALES,non_residential_lease_above_5m,400000,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0 +WALES,non_residential_lease_above_5m,425000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0 +WALES,non_residential_lease_above_5m,450000,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,non_residential_lease_above_5m,500000,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0 +WALES,non_residential_lease_above_5m,500001,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02 +WALES,non_residential_lease_above_5m,625000,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0 +WALES,non_residential_lease_above_5m,750000,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0 +WALES,non_residential_lease_above_5m,925000,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0 +WALES,non_residential_lease_above_5m,1000000,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0 +WALES,non_residential_lease_above_5m,1500000,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0 +WALES,non_residential_lease_above_5m,2000000,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0 +WALES,non_residential_lease_above_5m,6000000,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0 +NORTHERN_IRELAND,main,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,main,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,main,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,main,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,main,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,main,125001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +NORTHERN_IRELAND,main,150000,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0 +NORTHERN_IRELAND,main,150001,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0 +NORTHERN_IRELAND,main,180000,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0 +NORTHERN_IRELAND,main,225000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,main,250000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,main,250001,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0 +NORTHERN_IRELAND,main,300000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,main,400000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,main,425000,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0 +NORTHERN_IRELAND,main,450000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +NORTHERN_IRELAND,main,500000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,main,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +NORTHERN_IRELAND,main,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +NORTHERN_IRELAND,main,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +NORTHERN_IRELAND,main,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +NORTHERN_IRELAND,main,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +NORTHERN_IRELAND,main,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,main,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +NORTHERN_IRELAND,main,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +NORTHERN_IRELAND,first,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,150001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,180000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,225000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,250000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,250001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,300000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,first,400000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,first,425000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,first,450000,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0 +NORTHERN_IRELAND,first,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,first,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +NORTHERN_IRELAND,first,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +NORTHERN_IRELAND,first,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +NORTHERN_IRELAND,first,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +NORTHERN_IRELAND,first,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +NORTHERN_IRELAND,first,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,first,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +NORTHERN_IRELAND,first,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +NORTHERN_IRELAND,additional,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,additional,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,additional,40000,1200.0,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,additional,100000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,additional,125000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,additional,125001,3750.05,0.0,3750.05,0.0,3750.05,0.0,3750.05,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0 +NORTHERN_IRELAND,additional,150000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,additional,150001,5000.05,0.0,5000.05,0.0,5000.05,0.0,5000.05,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0 +NORTHERN_IRELAND,additional,180000,6500.0,0.0,6500.0,0.0,6500.0,0.0,6500.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0 +NORTHERN_IRELAND,additional,225000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0 +NORTHERN_IRELAND,additional,250000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,additional,250001,10000.08,0.0,10000.08,0.0,10000.08,0.0,10000.08,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0 +NORTHERN_IRELAND,additional,300000,14000.0,0.0,14000.0,0.0,14000.0,0.0,14000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +NORTHERN_IRELAND,additional,400000,22000.0,0.0,22000.0,0.0,22000.0,0.0,22000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +NORTHERN_IRELAND,additional,425000,24000.0,0.0,24000.0,0.0,24000.0,0.0,24000.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0 +NORTHERN_IRELAND,additional,450000,26000.0,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0 +NORTHERN_IRELAND,additional,500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +NORTHERN_IRELAND,additional,500001,30000.08,0.0,30000.08,0.0,30000.08,0.0,30000.08,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0 +NORTHERN_IRELAND,additional,625000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0 +NORTHERN_IRELAND,additional,750000,50000.0,0.0,50000.0,0.0,50000.0,0.0,50000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0 +NORTHERN_IRELAND,additional,925000,64000.0,0.0,64000.0,0.0,64000.0,0.0,64000.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0 +NORTHERN_IRELAND,additional,1000000,73750.0,0.0,73750.0,0.0,73750.0,0.0,73750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,additional,1500000,138750.0,0.0,138750.0,0.0,138750.0,0.0,138750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0 +NORTHERN_IRELAND,additional,2000000,213750.0,0.0,213750.0,0.0,213750.0,0.0,213750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0 +NORTHERN_IRELAND,additional,6000000,813750.0,0.0,813750.0,0.0,813750.0,0.0,813750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0 +NORTHERN_IRELAND,non_residential,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential,150001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +NORTHERN_IRELAND,non_residential,180000,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0 +NORTHERN_IRELAND,non_residential,225000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +NORTHERN_IRELAND,non_residential,250000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,non_residential,250001,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0 +NORTHERN_IRELAND,non_residential,300000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +NORTHERN_IRELAND,non_residential,400000,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0 +NORTHERN_IRELAND,non_residential,425000,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0 +NORTHERN_IRELAND,non_residential,450000,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0 +NORTHERN_IRELAND,non_residential,500000,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0 +NORTHERN_IRELAND,non_residential,500001,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0 +NORTHERN_IRELAND,non_residential,625000,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0 +NORTHERN_IRELAND,non_residential,750000,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0 +NORTHERN_IRELAND,non_residential,925000,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0 +NORTHERN_IRELAND,non_residential,1000000,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0 +NORTHERN_IRELAND,non_residential,1500000,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0 +NORTHERN_IRELAND,non_residential,2000000,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0 +NORTHERN_IRELAND,non_residential,6000000,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0 +NORTHERN_IRELAND,residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,residential_lease,125001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +NORTHERN_IRELAND,residential_lease,150000,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0 +NORTHERN_IRELAND,residential_lease,150001,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0 +NORTHERN_IRELAND,residential_lease,180000,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0 +NORTHERN_IRELAND,residential_lease,225000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +NORTHERN_IRELAND,residential_lease,250000,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0 +NORTHERN_IRELAND,residential_lease,250001,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0 +NORTHERN_IRELAND,residential_lease,300000,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0 +NORTHERN_IRELAND,residential_lease,400000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +NORTHERN_IRELAND,residential_lease,425000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,residential_lease,450000,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0 +NORTHERN_IRELAND,residential_lease,500000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0 +NORTHERN_IRELAND,residential_lease,500001,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0 +NORTHERN_IRELAND,residential_lease,625000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,residential_lease,750000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,residential_lease,925000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,residential_lease,1000000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0 +NORTHERN_IRELAND,residential_lease,1500000,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0 +NORTHERN_IRELAND,residential_lease,2000000,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0 +NORTHERN_IRELAND,residential_lease,6000000,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,39999,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,40000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,100000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,125000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,125001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,150000,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease,150001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +NORTHERN_IRELAND,non_residential_lease,180000,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0 +NORTHERN_IRELAND,non_residential_lease,225000,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,250000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,250001,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0 +NORTHERN_IRELAND,non_residential_lease,300000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,400000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,425000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,450000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,500000,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,500001,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0 +NORTHERN_IRELAND,non_residential_lease,625000,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,750000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,925000,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,1000000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,1500000,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,2000000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,6000000,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,39999,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,40000,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,100000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,125000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,125001,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,150000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,150001,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,180000,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,225000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,250000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,250001,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,300000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,400000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,425000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,450000,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,500001,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,625000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,750000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,925000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,1000000,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,1500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,2000000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,6000000,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml index 57d4093cf4..63f281fbb7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml @@ -329,3 +329,26 @@ output: stamp_duty_land_tax: 0 land_transaction_tax: 13_950 + +- name: A reform can charge a 2015 non-residential purchase on slices + # 1% x 100,000 + 3% x 250,000 + 4% x 500,000. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price: false + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 28_500 + +- name: A reform can keep SDLT in Wales after 2018 + period: 2019 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.applies_in_wales: true + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 diff --git a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py new file mode 100644 index 0000000000..49a9b3f9d2 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py @@ -0,0 +1,251 @@ +"""Property-based tests for the dates SDLT's 2016 and 2017 changes start on +(issue #2801). + +Parliament set these starts: + +- Finance Act 2018 s41(8): first-time buyers' relief (Sch 6ZA) from 22 + November 2017. +- Finance Act 2016 s128(5): the higher rates for additional dwellings (Sch + 4ZA) from 1 April 2016. Before then a buyer of an additional dwelling paid + the main rates in Finance Act 2003 s55(1B) Table A. +- Finance Act 2016 s127(12): the sliced non-residential rates and the 2% lease + rent band above £5,000,000 from 17 March 2016. Before then HMRC charged a + non-residential purchase "the percentage of the chargeable consideration for + the transaction determined in accordance with Table B" (s55(2) as at 30 + April 2015): 0% up to £150,000, 1% above £150,000 up to £250,000, 3% above + £250,000 up to £500,000 and 4% above £500,000. + +HMRC charges SDLT on land in Wales until 1 April 2018 (s48(1)(a) as at 30 +April 2017). From then the Welsh Revenue Authority charges Land Transaction +Tax (WSI 2018/128). Each model year reads the parameters in force on 30 April. + +Invariants, for any generated households: + +1. In every model year from 2015 to 2030, a purchase in Wales is liable to + exactly one of SDLT and LTT, and the other tax is zero: SDLT up to model + year 2017, LTT from 2018. +2. In model year 2015, a non-residential purchase priced at or around a Table + B threshold pays the percentage for its band times the whole price. +3. No transaction tax is ever negative. +4. In model years 2018 to 2030, apart from 2021, 2023 and 2024, SDLT and LTT + on a grid of purchases equal the values `main` (2da212abc) gave, pinned in + tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = list(range(2015, 2031)) +LTT_FIRST_YEAR = 2018 +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +TAXES = [ + "stamp_duty_land_tax", + "sdlt_on_residential_property_transactions", + "sdlt_on_non_residential_property_transactions", + "sdlt_on_residential_property_rent", + "sdlt_on_non_residential_property_rent", + "land_transaction_tax", + "land_and_buildings_transaction_tax", +] +# Table B in s55(2) as at 30 April 2015: each band covers prices that exceed +# its threshold and do not exceed the next one. +TABLE_B = ((0, 0.0), (150_000, 0.01), (250_000, 0.03), (500_000, 0.04)) +TABLE_B_YEAR = 2015 +GRID_PATH = Path(__file__).parent / "fixtures" / "sdlt_ltt_main_grid_2018_2030.csv" + +purchase_strategy = st.fixed_dictionaries( + { + "main_residential_property_purchased": st.integers(0, 3_000_000), + "main_residential_property_purchased_is_first_home": st.booleans(), + "additional_residential_property_purchased": st.integers(0, 3_000_000), + "non_residential_property_purchased": st.integers(0, 3_000_000), + "rent": st.integers(0, 2_000_000), + "cumulative_residential_rent": st.integers(0, 2_000_000), + "non_residential_rent": st.integers(0, 8_000_000), + "cumulative_non_residential_rent": st.integers(0, 8_000_000), + } +) + + +def _situation(households, years): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, household in enumerate(households): + person = f"p{i}" + situation["people"][person] = {"age": {year: 40 for year in years}} + situation["benunits"][f"b{i}"] = {"members": [person]} + situation["households"][f"h{i}"] = { + "members": [person], + **{ + key: {year: value for year in years} for key, value in household.items() + }, + } + return situation + + +def _calculate(simulation, variable, year): + return np.array(simulation.calculate(variable, year), dtype=float) + + +@PROPERTY_SETTINGS +@given(st.lists(purchase_strategy, min_size=1, max_size=6)) +@example( + [ + { + "main_residential_property_purchased": 300_000, + "main_residential_property_purchased_is_first_home": False, + "additional_residential_property_purchased": 300_000, + "non_residential_property_purchased": 1_000_000, + "rent": 0, + "cumulative_residential_rent": 0, + "non_residential_rent": 0, + "cumulative_non_residential_rent": 0, + } + ] +) +def test_wales_pays_exactly_one_of_sdlt_and_ltt(purchases): + households = [{"region": "WALES", **purchase} for purchase in purchases] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + sdlt_liable = np.array(simulation.calculate("sdlt_liable", year), dtype=bool) + ltt_liable = np.array(simulation.calculate("ltt_liable", year), dtype=bool) + assert np.all(sdlt_liable != ltt_liable), year + assert np.all(ltt_liable == (year >= LTT_FIRST_YEAR)), year + sdlt = _calculate(simulation, "stamp_duty_land_tax", year) + ltt = _calculate(simulation, "land_transaction_tax", year) + np.testing.assert_array_equal(sdlt[~sdlt_liable], 0, err_msg=str(year)) + np.testing.assert_array_equal(ltt[~ltt_liable], 0, err_msg=str(year)) + # A £1,000,000 purchase in any of these forms pays the liable tax. + large = np.array( + [ + max( + p["main_residential_property_purchased"], + p["additional_residential_property_purchased"], + p["non_residential_property_purchased"], + ) + >= 1_000_000 + for p in purchases + ] + ) + assert np.all(np.where(sdlt_liable, sdlt, ltt)[large] > 0), year + + +def _table_b_tax(price): + rate = [rate for threshold, rate in TABLE_B if price > threshold or threshold == 0] + return rate[-1] * price + + +threshold_prices = st.sampled_from( + [ + threshold + offset + for threshold, _ in TABLE_B + for offset in (-100, -1, -0.5, 0, 0.5, 1, 100) + if threshold + offset >= 0 + ] +) +table_b_prices = st.one_of(threshold_prices, st.integers(0, 3_000_000)) + + +@PROPERTY_SETTINGS +@given(st.lists(table_b_prices, min_size=1, max_size=40)) +@example([150_000, 150_000.5, 250_000, 250_000.5, 500_000, 500_000.5, 1_000_000]) +def test_table_b_charges_the_whole_price_in_2015(prices): + # Prices are whole pounds or halves, which float32 stores exactly. + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + simulation = Simulation(situation=_situation(households, [TABLE_B_YEAR])) + tax = _calculate(simulation, "stamp_duty_land_tax", TABLE_B_YEAR) + expected = [_table_b_tax(price) for price in prices] + np.testing.assert_allclose(tax, expected, rtol=1e-6, atol=0.01) + + +@settings(PROPERTY_SETTINGS, max_examples=6) +@given( + st.lists( + st.tuples(st.sampled_from(REGIONS), purchase_strategy), + min_size=4, + max_size=12, + ) +) +def test_transaction_taxes_are_never_negative(households): + households = [{"region": region, **purchase} for region, purchase in households] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + for tax in TAXES: + assert np.all(_calculate(simulation, tax, year) >= 0), (tax, year) + + +GRID_INPUTS = { + "main": ("main_residential_property_purchased",), + "first": ("main_residential_property_purchased",), + "additional": ("additional_residential_property_purchased",), + "non_residential": ("non_residential_property_purchased",), + "residential_lease": ("rent",), + "non_residential_lease": ("non_residential_rent",), + "non_residential_lease_above_5m": ("non_residential_rent",), +} +ZERO_INPUTS = ( + "main_residential_property_purchased", + "additional_residential_property_purchased", + "non_residential_property_purchased", + "rent", + "cumulative_residential_rent", + "non_residential_rent", + "cumulative_non_residential_rent", +) + + +def _grid_household(row): + household = {variable: 0 for variable in ZERO_INPUTS} + household["region"] = row.region + household["main_residential_property_purchased_is_first_home"] = row.kind == "first" + for variable in GRID_INPUTS[row.kind]: + household[variable] = row.price + if row.kind == "non_residential_lease_above_5m": + household["cumulative_non_residential_rent"] = 5_000_000 + return household + + +@pytest.fixture(scope="module") +def pinned_grid(): + grid = pd.read_csv(GRID_PATH) + years = sorted({int(column.split("_")[-1]) for column in grid.columns[3:]}) + households = [_grid_household(row) for row in grid.itertuples()] + return grid, years, Simulation(situation=_situation(households, years)) + + +def test_pinned_grid_covers_the_years_2787_leaves_alone(pinned_grid): + _, years, _ = pinned_grid + assert years == [y for y in range(2018, 2031) if y not in (2021, 2023, 2024)] + + +@pytest.mark.parametrize( + "year", [y for y in range(2018, 2031) if y not in (2021, 2023, 2024)] +) +def test_model_years_from_2018_match_main(pinned_grid, year): + grid, _, simulation = pinned_grid + for tax, column in ( + ("stamp_duty_land_tax", "sdlt"), + ("land_transaction_tax", "ltt"), + ): + np.testing.assert_allclose( + _calculate(simulation, tax, year), + grid[f"{column}_{year}"], + rtol=0, + atol=0.01, + err_msg=f"{tax} {year}", + ) From ea880ff7631cbb486a349ec197d8d624dc6e9169 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:37:30 -0500 Subject: [PATCH 072/168] Name Parliament as the actor in the Marriage Allowance notes Comments only: the module docstring, the IGNORED_EXCLUSIONS note and case 13's working. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 19 ++++++++++--------- .../utils/income_tax_allocation.py | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 65688c2112..57986c9c29 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -261,15 +261,16 @@ members: [recipient, partner] is_married: true output: - # The partner's unused allowance gives the recipient a Marriage Allowance - # of £1,260: 10% of £12,570, rounded up to a multiple of £10 (s. 55B(4) - # and (5)). Section 55B(1) gives it as a tax reduction, which Step 6 of - # s. 23 deducts, not as a deduction at Step 3, so the model leaves it out - # of the share. Pay, property income and the savings above the £1,000 - # savings allowance all bear 20%, and the starting rate band has gone - # (s. 12), so no share lowers the Step 5 tax, and the allowance comes off - # the pay and then the property income. Property 7,430 x 20% = 1,486; - # savings of £15,000 from £7,430: £1,000 at the savings nil rate and + # The model gives the recipient a Marriage Allowance of £1,260 from the + # partner's unused Personal Allowance: 10% of £12,570, rounded up to a + # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax + # reduction (s. 55B(1)), which Step 6 of s. 23 deducts, not as a deduction + # at Step 3, so the model leaves it out of the share. Pay, property income + # and the savings above the £1,000 savings allowance all bear 20%, and the + # pay and property income leave no starting rate band (s. 12), so no share + # lowers the Step 5 tax, and the model takes the Personal Allowance off the + # pay and then the property income. Property 7,430 x 20% = 1,486; savings + # of £15,000 from £7,430: £1,000 at the savings nil rate and # 14,000 x 20% = 2,800. marriage_allowance: [1_260, 0] received_allowances_earned_income: [12_570, 0] diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 3087cc61f9..2f88edae93 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -57,12 +57,12 @@ order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. -The search ignores the Marriage Allowance. Section 55B(1) gives it as a tax -reduction, which Step 6 deducts, not as a deduction at Step 3, and its rate -test (s. 55B(2)(b)) reads the share. The model still subtracts the allowance -from earned income (#1947), which moves a recipient's savings and dividends -down by up to that amount, so for a recipient the search's tax can differ from -the model's. +The search ignores the Marriage Allowance. Parliament gives it as a tax +reduction (s. 55B(1)), which Step 6 deducts, not as a deduction at Step 3, and +whether a person qualifies for it (s. 55B(2)(b)) depends on the share. The +model still subtracts the allowance from earned income (#1947), which moves a +recipient's savings and dividends down by up to that amount, so for a +recipient the search's tax can differ from the model's. Who cannot gain --------------- @@ -123,9 +123,10 @@ "taxable_property_income", "received_allowances_earned_income", ) -# The exclusions the search leaves out. s. 55B(1) gives the Marriage Allowance -# as a tax reduction, not a deduction at Step 3, and its rate test reads the -# share, so the search shares a recipient's deductions as if they had none. +# The search leaves these exclusions out. Parliament gives the Marriage +# Allowance as a tax reduction (s. 55B(1)), not a deduction at Step 3, and +# whether a person qualifies (s. 55B(2)(b)) depends on the share, so the search +# shares a recipient's deductions as if they had none. IGNORED_EXCLUSIONS = ("marriage_allowance",) From 5d9ce134348c1ebf7ce183c7239d5ebdba8582fa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 19:41:54 -0500 Subject: [PATCH 073/168] Name who acts in the allowance-order comments Replace passive constructions in the branch's comments, two YAML case names, a test module docstring and the documentation of non_savings_income_after_allowances ("is taxed at 20%" becomes "bears 20%", "is left" becomes "remains"). No test input, expected value or formula changes. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/reliefs/other_income_first.yaml | 2 +- .../least_tax_allocation.yaml | 16 ++++++++-------- .../received_allowances_dividend_income.yaml | 12 ++++++------ .../received_allowances_earned_income.yaml | 2 +- .../received_allowances_savings_income.yaml | 8 ++++---- .../baseline/gov/hmrc/income_tax/gift_aid.yaml | 2 +- ...trade_loss_relief_against_general_income.yaml | 4 ++-- .../tests/policy/integration/income.yaml | 2 +- policyengine_uk/tests/test_gift_aid.py | 6 +++--- .../test_property_income_band_base_properties.py | 2 +- policyengine_uk/utils/income_tax_allocation.py | 4 ++-- .../non_savings_income_after_allowances.py | 10 +++++----- 12 files changed, 35 insertions(+), 35 deletions(-) diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml index aad1838fca..91ffd405cf 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml @@ -1,6 +1,6 @@ description: Income Tax deducts reliefs and allowances from income other than property, savings and dividend income before it deducts them from property, savings or dividend income, and then in the way that gives the least tax. values: - # Before 2027-28, s. 25(2) alone decides how they are deducted. + # Before 2027-28, the taxpayer deducts them under s. 25(2) alone. 0000-01-01: false 2027-04-06: true metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 57986c9c29..5065fd29fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -32,7 +32,7 @@ dividend_income: 20_000 output: # The £4,000 of savings is the lowest part of Step 3 income, within the - # £5,000 starting rate band, so it is taxed at 0% (s. 12). Set against + # £5,000 starting rate band, so it bears 0% (s. 12). Set against # it, the allowance would save nothing; set against dividends it saves # 10.75%. So all £12,570 comes off dividends, and the tax is as in case 1: # (20,000 - 12,570 - 500) x 10.75%. @@ -41,7 +41,7 @@ savings_income_tax: 0 income_tax: 744.975 -- name: Case 3, a trading loss is set against dividends rather than savings at the starting rate +- name: Case 3, the taxpayer sets a trading loss against dividends rather than savings at the starting rate period: 2026 absolute_error_margin: 0.01 input: @@ -67,7 +67,7 @@ savings_income_tax: 0 income_tax: 65_466.71 -- name: Case 4, in 2027-28 a trading loss is set against property income rather than dividends +- name: Case 4, in 2027-28 the taxpayer sets a trading loss against property income rather than dividends period: 2027 absolute_error_margin: 0.01 input: @@ -126,11 +126,11 @@ savings_interest_income: 10_000 region: SCOTLAND output: - # Pay of £37,430 after the allowance is taxed at the Scottish rates: + # Pay of £37,430 after the allowance bears the Scottish rates: # 3,967 x 19% + 12,989 x 20% + 14,136 x 21% + 6,338 x 42% = 8,982.05. # Savings run from £37,430 to £47,430. Step 3 income would be higher # rate income outside Scotland, so the savings allowance is £500 - # (s. 12B(8)(b)(iv)), and the other £9,500 is above the UK basic rate + # (s. 12B(8)(b)(iv)), and the other £9,500 lies above the UK basic rate # limit, at 40% (s. 11D(6)): 3,800. Moving £270 of the allowance to # savings, as in case 7, would tax £270 more pay at 42% to take £270 of # savings out of the 40% band: £5.40 more. @@ -148,10 +148,10 @@ employment_income: 50_000 savings_interest_income: 10_000 output: - # With all £12,570 against pay, pay of £37,430 is taxed at 20% and the + # With all £12,570 against pay, pay of £37,430 bears 20% and the # £500 savings allowance covers savings from £37,430 to £37,930, all but - # £230 of it below the basic rate limit, where savings would be taxed - # at 20% anyway. Setting £270 against savings instead puts the pay at + # £230 of it below the basic rate limit, where savings would bear + # 20% anyway. Setting £270 against savings instead puts the pay at # £37,700 and the savings allowance above the limit, where it saves 40%: # pay 37,700 x 20% = 7,540; savings (47,430 - 38,200) x 40% = 3,692. # The order pay, savings, dividends charges 7,486 + 3,800 = 11,286. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml index ef45d15f0a..eba9397fb6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml @@ -46,10 +46,10 @@ taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - # After the pay, £1,000 of income is left. Left as £1,000 of dividends, - # £500 would be taxed at 8.75% (s. 13A nil rate on the first £500). Left + # After the pay, £1,000 of income remains. Left as £1,000 of dividends, + # £500 would bear 8.75% (s. 13A nil rate on the first £500). Left # as £500 of savings, in the starting rate band, and £500 of dividends, - # within the nil rate, it is all taxed at 0%. + # within the nil rate, it all bears 0%. received_allowances_savings_income: 1_500 received_allowances_dividend_income: 500 dividend_income_tax: 0 @@ -72,9 +72,9 @@ taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - # £1,430 is left after the pay. Up to £500 of dividends are taxed at 0% - # (s. 13A) and savings in the starting rate band at 0% (s. 12), so £930 - # of savings and £500 of dividends are left: £1,070 of the allowances + # £1,430 remains after the pay. Up to £500 of dividends bear 0% + # (s. 13A) and savings in the starting rate band 0% (s. 12), so £930 + # of savings and £500 of dividends remain: £1,070 of the allowances # go to savings and £500 to dividends. received_allowances_savings_income: 1_070 received_allowances_dividend_income: 500 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 5abae01d67..55def4eade 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -36,7 +36,7 @@ taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: - # The pay would be taxed at 20%, so it takes £5,000 of the allowances + # The pay would bear 20%, so it takes £5,000 of the allowances # (ITA 2007 s. 25(2)). received_allowances_earned_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml index 708a4858f5..92c707d367 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml @@ -29,9 +29,9 @@ taxable_employment_income: 11_570 taxable_savings_interest_income: 3_000 output: - # The pay takes £11,570, which would otherwise be taxed at 20%. The - # £2,000 of savings left is within the £5,000 starting rate band, so - # it is taxed at 0% whichever £1,000 of the allowances it takes. + # The pay takes £11,570, which would otherwise bear 20%. The + # £2,000 of savings left falls within the £5,000 starting rate band, so + # it bears 0% whichever £1,000 of the allowances it takes. received_allowances_savings_income: 1_000 - name: No allowances go to savings that the starting rate leaves untaxed @@ -61,6 +61,6 @@ taxable_employment_income: 13_000 taxable_savings_interest_income: 1_000 output: - # £430 of pay is taxed at 20% and the savings above it at the starting + # £430 of pay bears 20% and the savings above it the starting # rate of 0%, so the allowances are worth most against the pay. received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index b9052b16d6..05e7557487 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -95,7 +95,7 @@ # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic # rate limit, £37,700, or £38,700 with the £1,000 grossed-up gift, and the # rest of it comes off dividends, so the £500 dividend allowance covers - # dividends that would otherwise be taxed at 35.75%. Dividends left: + # dividends that would otherwise bear 35.75%. Dividends left: # 9,730 and 8,730; less £500 at the nil rate, at 35.75%. The extension # keeps £1,000 more pay at 20% in place of upper-rate dividends: £157.50. earned_taxable_income: [37_700, 38_700] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index da87919870..90776b41bc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -123,13 +123,13 @@ trade_loss_relief_against_savings_and_dividends: 15_000 # ITA 2007 s. 25(2) sets the £32,570 of relief and Personal Allowance # against income in the way that gives the least tax. The pay takes - # £5,000. Of Step 3 income of £12,430, savings up to £6,000 are taxed at + # £5,000. Of Step 3 income of £12,430, savings up to £6,000 bear # 0%: £5,000 at the starting rate (s. 12) and £1,000 within the savings # allowance (s. 12A). So savings take £4,000 and dividends £23,570. received_allowances_earned_income: 0 received_allowances_savings_income: 4_000 received_allowances_dividend_income: 23_570 - # £6,430 of dividends is left: (6,430 - 500) x 10.75%. + # £6,430 of dividends remains: (6,430 - 500) x 10.75%. income_tax: 637.475 - name: Adjusted total income is total income after employment expenses diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index e86069230b..55afb8a955 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -23,7 +23,7 @@ # ITA 2007 s. 25(2): of the £12,500 Personal Allowance, £2,500 comes off # the pay and £10,000 off the savings. The pay left is £37,500, the basic # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings - # that would otherwise be taxed at 40% rather than 20%: £100 less tax + # that would otherwise bear 40% rather than 20%: £100 less tax # than the allowance all against the pay. Savings of £5,000 run from # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting # more against the pay or the savings would not change the tax. diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 1b74ef4d08..8a4d9995a1 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -302,9 +302,9 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): # The fourth row outside Scotland sets £1,300 of the Personal # Allowance against pay and £11,270 against savings (ITA 2007 # s. 25(2), #2106): pay and property reach the £37,700 basic rate - # limit, and the £500 savings allowance covers savings that would be - # taxed at 40%, so its tax falls by £100. In Scotland the pay above - # £31,092 would be taxed at 42%, so the order is unchanged. + # limit, and the £500 savings allowance covers savings that would + # bear 40%, so its tax falls by £100. In Scotland the pay above + # £31,092 would bear 42%, so the model keeps the old order. "earned_taxable_income": [17_430, 47_430, 150_000, 28_700, 0, 88_930] + [17_430, 47_430, 150_000, 17_430, 0, 88_930], "received_allowances_earned_income": [12_570, 12_570, 0, 1_300, 10_000, 12_070] diff --git a/policyengine_uk/tests/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index 5e4b716092..f3ebf5d301 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -15,7 +15,7 @@ after the allowances set against them, floored at nil (earned_taxable_income also subtracts Marriage Allowance; #1947). Under ITA 2007 s. 25(2) the model can set allowances against savings or - dividends while non-savings income is left, so the savings and dividends + dividends while non-savings income remains, so the savings and dividends left, not their full amounts, sit above it. 3. Monotonic: more property income never lowers the income tax. Savings or dividend tax alone can fall, because under s. 25(2) the model can move diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 2f88edae93..d699e7d4f3 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -189,8 +189,8 @@ def _build(inputs, income_tax): savings = np.maximum(0, columns["savings"]) dividends = np.maximum(0, columns["dividends"]) relief = columns["relief"] - # net_income, not its components, so a supplied adjusted net income or a - # reform that sets net income is followed, as the default order does. + # net_income, not its components, so the search follows a supplied adjusted + # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) deductions = relief + columns["allowances"] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py index 232a6ff67e..e1bb768e06 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -10,11 +10,11 @@ class non_savings_income_after_allowances(Variable): "does not reduce the income charged to tax) less savings and dividend " "income and the reliefs and allowances set against non-savings " "income, floored at nil: the income below savings and dividends " - "(ITA 2007 s. 16) once Steps 2 and 3 are done. ITA 2007 s. 25(2) " - "decides how much of the reliefs and allowances comes off it, and " - "from 2027-28 s. 25(3A) takes them off the rest of it before property " - "income. property_income_tax and taxable_property_income_after_allowances " - "both read it." + "(ITA 2007 s. 16) after Steps 2 and 3. The taxpayer sets the reliefs " + "and allowances against it in the way that gives the least tax (ITA " + "2007 s. 25(2)), and from 2027-28 against the rest of it before " + "property income (s. 25(3A)). property_income_tax and " + "taxable_property_income_after_allowances both read it." ) definition_period = YEAR reference = [ From 74fdacf1d4933f759f94d15999a28e64b4cc73e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 01:13:46 -0500 Subject: [PATCH 074/168] Filter rate identities on the allowance order A Scottish taxpayer's property income stays on the Scottish rates while the Budget 2026 savings rates rise, so the least-tax order can move an allowance onto savings. Property tax and savings tax then both move, so the identities in test_budget_2026_savings_property_rates_properties.py hold only for people whose allowance order the rates leave unchanged. Pin that person with a worked case, and add them to the fast-path test's fixed people. Co-Authored-By: Claude Opus 5.5 --- ..._2026_savings_property_rates_properties.py | 69 ++++++++++++++++--- .../tests/test_income_tax_allocation.py | 2 + 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 09c62063dd..1de32e9688 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -4,7 +4,8 @@ 6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% and 47%. Each test compares current law with the Budget 2025 rates for the same generated people, and checks identities that hold for every input -whose property allowance election is the same under both sets of rates: +whose property allowance election and allowance order stay the same under +both sets of rates: - rUK property tax rises by exactly 2% of taxable property income, since every pound of it sits in one of the three rate bands; @@ -23,6 +24,11 @@ property tax and the relief both move (test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile). The model makes that choice only where it lowers the person's income tax. + +The order of the allowances can change too. A Scottish taxpayer's property +income stays on the Scottish rates while the savings rates rise, so the +allowance can save more against savings, and then both taxes move +(test_higher_savings_rates_can_move_the_allowance_onto_savings). """ import numpy as np @@ -60,6 +66,10 @@ # property allowance at the higher rates. ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) +# The first property run's counterexample: the fourth person (Scotland) sets +# the allowance against savings at the higher rates. +ORDER_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES +ORDER_CHANGE[3] = (0.0, 58_000.0, 0.0, 24_000.0) def situation(): @@ -88,7 +98,12 @@ def sims(): ) -VARIABLES = [ +ALLOWANCE_ORDER = [ + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", +] +VARIABLES = ALLOWANCE_ORDER + [ "pays_scottish_income_tax", "uses_property_allowance", "taxable_property_income_after_allowances", @@ -116,9 +131,13 @@ def run(sims, drawn): return results -def same_election(law, before): - """People whose property allowance election the rates leave unchanged.""" - return law["uses_property_allowance"] == before["uses_property_allowance"] +def same_choices(law, before): + """People whose property allowance election and allowance order the rates + leave unchanged.""" + same = law["uses_property_allowance"] == before["uses_property_allowance"] + for name in ALLOWANCE_ORDER: + same &= np.abs(law[name] - before[name]) <= tolerance(law[name]) + return same def tolerance(*values): @@ -138,7 +157,7 @@ def tolerance(*values): @example(ELECTION_CHANGE) def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): law, before = run(sims, drawn) - ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] expected = RISE * law["taxable_property_income_after_allowances"] tol = tolerance(law["property_income_tax"], expected) @@ -148,9 +167,10 @@ def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): law, before = run(sims, drawn) - scottish = law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + scottish = law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] tol = tolerance(law["property_income_tax"]) assert (np.abs(rise)[scottish] <= tol[scottish]).all() @@ -159,6 +179,7 @@ def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): law, before = run(sims, drawn) banded = ( @@ -168,13 +189,14 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): ) rise = law["savings_income_tax"] - before["savings_income_tax"] tol = tolerance(law["savings_income_tax"], banded) - same = same_election(law, before) + same = same_choices(law, before) assert (np.abs(rise - RISE * banded) <= tol)[same].all() @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): law, before = run(sims, drawn) rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] @@ -183,7 +205,36 @@ def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): assert (rise >= -tol).all() full_before = 0.22 * before["property_finance_costs_relieved"] uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol - assert (rise <= cap + tol)[uncapped & same_election(law, before)].all() + assert (rise <= cap + tol)[uncapped & same_choices(law, before)].all() + + +def test_higher_savings_rates_can_move_the_allowance_onto_savings(sims): + """Property income of £58,000 and savings of £24,000 for a Scottish + taxpayer. Under the Budget 2025 rates the allowance saves 42% against + either income, and the old order sets it against property income. Under + the Budget 2026 rates savings pay 44% and this property income still pays + the Scottish 42%, so ITA 2007 s.25(2) sets the £12,570 against savings. + + Savings tax goes from 42% of £23,500 to 44% of £10,930, after the £500 + savings allowance. Property tax rises by 42% of £12,570. Income tax rises + by £218.60, against £470 had the allowance stayed on property income: the + move saves 2% of £12,570, £251.40.""" + law, before = run(sims, ORDER_CHANGE) + person = ORDER_CHANGE.index((0.0, 58_000.0, 0.0, 24_000.0)) + assert law["pays_scottish_income_tax"][person] + assert before["received_allowances_earned_income"][person] == 12_570 + assert before["received_allowances_savings_income"][person] == 0 + assert law["received_allowances_earned_income"][person] == 0 + assert law["received_allowances_savings_income"][person] == 12_570 + assert before["savings_income_tax"][person] == pytest.approx(9_870, abs=0.01) + assert law["savings_income_tax"][person] == pytest.approx(4_809.2, abs=0.01) + assert law["property_income_tax"][person] - before["property_income_tax"][ + person + ] == pytest.approx(5_279.4, abs=0.01) + assert law["income_tax"][person] - before["income_tax"][person] == pytest.approx( + 218.6, abs=0.01 + ) + assert not same_choices(law, before)[person] def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 49ddc55e62..15221cf83c 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -371,6 +371,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # Property with other income, savings and dividends. (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, False, 500), (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, True, 500), + # Scottish property income on 42% beside savings on 44% from 2027-28. + (0, 58_000, 24_000, 0, 0, 0, 0, 0, True, 500), (0, 20_000, 0, 200_000, 1_000, 0, 0, 0, False, 0), (45_000, 20_000, 3_000, 0, 0, 0, 1_260, 0, False, 500), # Gift Aid raises the limits. From d10b985d2136b2207396a14e4e7a46d0ee4c4860 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 05:10:12 -0500 Subject: [PATCH 075/168] Add a private renter above the LHA rate to the endpoint comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bound's housing costs read uc_LHA_cap, and the comparison's only private renter paid less than the cap, so a copy that used the full rent passed. A lone parent renting privately for £30,000 a year, capped at £18,600, now adds 18 rows (126 in all), and the test asserts that the rows include a housing costs element held to the cap. The Scottish carer-premium YAML cites paras 5(2) and 6(1) for a single applicant with two qualifying limbs. Documentation and comments name their actors; no formula changes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2120.fixed.md | 2 +- .../council_tax_reduction_carer_premium.yaml | 21 +++++---- .../test_uc_carer_element_award_properties.py | 27 +++++++++-- ...e_disability_minimum_guarantee_addition.py | 11 ++--- .../universal_credit/uc_award_upper_bound.py | 45 ++++++++++--------- .../council_tax_reduction_carer_premium.py | 5 ++- .../council_tax_reduction_premiums.py | 2 +- .../household/demographic/carer_premium.py | 2 +- .../pension_age_severe_disability_premium.py | 11 ++--- .../demographic/severe_disability_premium.py | 3 +- 10 files changed, 78 insertions(+), 51 deletions(-) diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md index e67509c102..b3214856e0 100644 --- a/changelog.d/2120.fixed.md +++ b/changelog.d/2120.fixed.md @@ -1 +1 @@ -The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022. Until 31 March 2025 it also counts a nil award that earnings bring about, reading Scotland's definition of an award "with a value of zero" to include one (#2120, #2124). +The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022. Until 31 March 2025 it also counts an award that DWP tapers to nil because of the carer's earnings, under its reading of Scotland's definition of an award "with a value of zero" (#2120, #2124). diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 2546b3a853..07e321aa83 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -183,13 +183,14 @@ council_tax_reduction_carer_premium: 0 - name: In 2024 the model counts a Scottish carer's nil award as an award with the carer element - # The same carer in 2024-25. The maximum amount is (393.45 + 198.31) x 12 - # = 7,101.12, and the taper on £20,000 of earnings less income tax and - # National Insurance takes more, so the award comes to nil. Until 31 March 2025 - # an "award of universal credit" included one "with a value of zero" (reg - # 4). The model reads that as reaching this nil award, a reading the - # sources leave open (see the header), so para 6(1)(c) applies: carer - # premium 45.60 x 52 = 2,371.20. + # The same carer in 2024-25. The maximum amount comes to (393.45 + 198.31) + # x 12 = 7,101.12, and the taper on £20,000 of earnings less income tax and + # National Insurance takes more, so the award comes to nil. Until 31 March + # 2025 an "award of universal credit" included one "with a value of zero" + # (reg 4), and the model reads those words as reaching this nil award. + # Under that chosen reading, the model includes the carer premium (para + # 6(1)(c)): 45.60 x 52 = 2,371.20. The sources leave that reading open (see + # the header). period: 2024 absolute_error_margin: 0.01 input: @@ -248,8 +249,10 @@ council_tax_reduction_carer_premium: 0 - name: A Scottish carer with both Carer Support Payment and the carer element gets one premium - # Councils pay the premium in respect of each person who qualifies (para - # 5(3)), not for each award: 48.15 x 52 = 2,503.80, not twice that. + # The applicant has regular and substantial caring responsibilities under + # both para 6(1)(a) and (c), and councils include the premium once for an + # applicant with those responsibilities (para 5(2)): 48.15 x 52 = + # 2,503.80, not twice that. period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 57034831b5..629146f9fc 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -763,9 +763,10 @@ def visit(node): # Families for the endpoint comparison, each in its own English household: -# every element of the maximum amount, both work allowances, and owners, -# private, council and housing association renters. The award reads the -# earned and unearned income each row supplies. +# every element of the maximum amount, both work allowances, owners, council +# and housing association renters, and private renters below and above the +# Local Housing Allowance rate. The award reads the earned and unearned +# income each row supplies. PARITY_FAMILIES = [ # A single owner, with no work allowance. dict(adults=[dict(age=30)], children=[], tenure="OWNED_OUTRIGHT", rent=0), @@ -810,6 +811,16 @@ def visit(node): tenure="RENT_FROM_HA", rent=7_200, ), + # A lone parent earning £6,000, with a child (3) in childcare, who rents + # privately for £30,000 a year. The model places a household with no + # region in Inner South East London, where DWP caps the housing costs + # element at the two-bedroom Local Housing Allowance rate, £1,550 a month. + dict( + adults=[dict(age=30, employment_income=6_000)], + children=[dict(age=3, childcare_expenses=6_000)], + tenure="RENT_PRIVATELY", + rent=30_000, + ), ] # Earned income before the work allowance and unearned income, a year. PARITY_INCOMES = [(0, 0), (6_000, 0), (18_000, 0), (12_000, 1_500)] @@ -907,6 +918,8 @@ def test_upper_bound_matches_real_award_endpoints(): "uc_childcare_element", ) variables = components + ( + "uc_LHA_cap", + "benunit_rent", "uc_work_allowance", "universal_credit_pre_benefit_cap", "universal_credit", @@ -915,10 +928,16 @@ def test_upper_bound_matches_real_award_endpoints(): base = Simulation(situation=parity_situation(rows)) bound = np.asarray(base.calculate("uc_award_upper_bound", YEAR)) - # The rows cover every element, both work allowances, both endpoints + # The rows cover every element, a housing costs element that DWP caps at + # the Local Housing Allowance rate, both work allowances, both endpoints # deciding the bound, and awards that cancel to nil or under a penny. for component in components: assert (with_housing[component] > 0).any(), component + lha_cap = with_housing["uc_LHA_cap"] + assert ( + (lha_cap < with_housing["benunit_rent"]) + & (with_housing["uc_housing_costs_element"] == lha_cap) + ).any() assert (with_housing["uc_work_allowance"] == 427 * 12).any() assert (without_housing["uc_work_allowance"] == 710 * 12).any() pre_cap = [ diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 7b69991bd7..3ca17774c6 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -27,11 +27,12 @@ class severe_disability_minimum_guarantee_addition(Variable): "listed on is_counted_resident_for_severe_disability_addition. Whom " "a carer benefit is paid for caring for is attributed by " "is_cared_for_by_carer_benefit_recipient, which the legacy severe " - "disability premium shares; a carer benefit there includes an award " - "of Universal Credit which includes the carer element (para 1(4)), " - "which has_uc_award_with_carer_element reads from the award's " - "conditions and the upper bound on the award (uc_award_upper_bound), " - "with that variable's limits. Blindness " + "disability premium shares. The attribution counts an award of " + "Universal Credit which includes the carer element (para 1(4)), which " + "has_uc_award_with_carer_element reads from the award's conditions " + "and the upper bound on the award (uc_award_upper_bound). The bound " + "can stay positive when the actual award comes to nil (#2795). " + "Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index 6907e11f96..b02c2341a6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -6,39 +6,40 @@ class uc_award_upper_bound(Variable): entity = BenUnit label = "Upper bound on the Universal Credit award" documentation = ( - "The family's award before the benefit cap " - "(universal_credit_pre_benefit_cap), as the model calculates it, " - "never exceeds this amount, which the model works out without reading " - "the non-dependant deduction. DWP awards the maximum amount less the income reduction " - "(UC Regs 2013 reg 22). The maximum amount includes the housing costs " - "element (uc_housing_costs_element): the rent a renter may claim less " - "DWP's non-dependant deductions. Where that element is positive, DWP " + "The model calculates this upper bound without reading the " + "non-dependant deduction. Its formula for " + "universal_credit_pre_benefit_cap never returns a larger award. DWP " + "awards the maximum amount less the income reduction (UC Regs 2013 " + "reg 22). The maximum amount includes the housing costs element " + "(uc_housing_costs_element): the rent a renter may claim less DWP's " + "non-dependant deductions. Where that element is positive, DWP " "applies the lower work allowance (reg 22(2)(b)). Where DWP deducts " "less than the rent, the award stays at or below the award with no " "deduction. Where it deducts at least the rent, DWP calculates the " "award with no housing costs element and the higher work allowance " - "(reg 22(2)(a); Sch 4 para 13). This amount takes the larger of those " + "(reg 22(2)(a); Sch 4 para 13). The formula takes the larger of those " "two awards. It repeats the award's steps in their order, rounding " - "where they store a value, so it never falls below the award the " - "model's formulas calculate. An input or a scenario can store a " + "where they store a value, so the bound never falls below the award " + "the model's formulas calculate. An input or a scenario can store a " "larger award; the carer test then reads the stored award " "(has_uc_award_with_carer_element). A family whose bound comes to nil " "has a nil award, and so no award: Parliament makes an amount " "payable of at least the prescribed minimum a financial condition " "(Welfare Reform Act 2012 s.5(1)(b) and (2)(b)), and DWP sets that " - "minimum at one penny (reg 17). The deduction spares a non-dependant " - "on Pension Credit, so it reads Pension Credit, whose severe " - "disability addition reads who has an award with the carer element. " - "This amount reads neither, so has_uc_award_with_carer_element can " - "read it. It can exceed " - "a nil award (#2795). A non-dependant deduction can take the award " - "to nil. A family that takes Tax-Free Childcare instead gets no award " - "(forgoes_universal_credit_for_tax_free_childcare). For a family " + "minimum at one penny (reg 17). DWP makes no deduction for a " + "non-dependant who receives Pension Credit (Sch 4 para 16(2)(b)), so " + "the model's deduction reads Pension Credit, whose severe disability " + "addition reads who has an award with the carer element. The bound " + "reads neither, so has_uc_award_with_carer_element can read it. The " + "bound can exceed a nil award in three ways (#2795). A non-dependant " + "deduction can take the award to nil. A family that takes Tax-Free " + "Childcare instead gets no award " + "(forgoes_universal_credit_for_tax_free_childcare). And for a family " "with a work allowance and a small housing costs element, the " - "calculation without housing costs applies the higher work " - "allowance. Its extra earnings disregard can outweigh the housing " - "costs it drops, so it can stay positive when the award comes to nil, " - "even with no non-dependant deduction." + "calculation without housing costs applies the higher work allowance, " + "whose extra earnings disregard can outweigh the housing costs it " + "drops; that calculation can stay positive when the award comes to " + "nil, even with no non-dependant deduction." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index 9ee13545f3..283670b440 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -47,8 +47,9 @@ class council_tax_reduction_carer_premium(Variable): "Claims and Payments Regs 2013 reg 32A). The model has neither " "sanctions nor reclaims, so it pays no premium for those. A person " "with both an allowance and a carer element qualifies once: councils " - "pay the premium for each partner who qualifies (para 5(3)), not for " - "each award. Partners caring for the same severely disabled person " + "include the premium for each applicant or partner who qualifies " + "(paras 5(2) and (3)), not for each award. Partners caring for the " + "same severely disabled person " "get one premium (para 5(3) and (4); " "partners_care_for_same_severely_disabled_person), as in " "carer_premium. Councils give it, under para 5(4), in respect of the " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index 912651908a..4369488705 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -97,7 +97,7 @@ def head_has(condition): & (others_counted == 0) ) * disability.severe_single # A head who applies alone holds no Universal Credit award of her own - # (the benefit unit's award is its claimant's and partner's), so + # (the benefit unit's claimant and partner hold its award), so # Scotland's carer element limb (council_tax_reduction_carer_premium) # never reaches her. carer_premium = ( diff --git a/policyengine_uk/variables/household/demographic/carer_premium.py b/policyengine_uk/variables/household/demographic/carer_premium.py index 5a0a9aa9bf..6ba97fc802 100644 --- a/policyengine_uk/variables/household/demographic/carer_premium.py +++ b/policyengine_uk/variables/household/demographic/carer_premium.py @@ -60,7 +60,7 @@ def formula(benunit, period, parameters): # tests caring responsibilities and pays each partner who qualifies. # Para 6(1) defines those as (a) receiving either allowance, (b) being # entitled to one reduced to nil by the overlapping benefits rules, or - # (c) a Universal Credit award with the carer element. This premium + # (c) a Universal Credit award with the carer element. This variable # applies (a) and (b), which every scheme shares; Scottish working-age # council tax reduction reads council_tax_reduction_carer_premium, # which adds (c). diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 54767f5f1d..5dc57d82aa 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -19,11 +19,12 @@ class pension_age_severe_disability_premium(Variable): "its amounts and its documented assumptions: where only one partner " "qualifies and the other is blind, the qualifying partner is taken to " "claim, as a couple may agree under HB(SPC) reg 63(1) and the CTR " - "schemes (England SI 2012/2885 Sch 8 para 4(1)); carers are attributed " - "by is_cared_for_by_carer_benefit_recipient, which counts a Universal " - "Credit award with the carer element from the award's conditions and " - "the upper bound on the award (uc_award_upper_bound), with that " - "variable's limits. The Pension Credit severe " + "schemes (England SI 2012/2885 Sch 8 para 4(1)). " + "is_cared_for_by_carer_benefit_recipient attributes carers, and " + "counts a Universal Credit award with the carer element from the " + "award's conditions and the upper bound on the award " + "(uc_award_upper_bound). The bound can stay positive when the actual " + "award comes to nil (#2795). The Pension Credit severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index e4f8560906..90a2c3948d 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -21,7 +21,8 @@ class severe_disability_premium(Variable): "when one is paid for only one of them, and nothing when carers are " "paid for both. Whom a carer benefit is paid for caring for is " "attributed by is_cared_for_by_carer_benefit_recipient, shared with " - "the Pension Credit severe disability addition. It counts an award " + "the Pension Credit severe disability addition. The attribution " + "counts an award " "of Universal Credit which includes the carer element from the " "award's conditions and an upper bound on the award " "(has_uc_award_with_carer_element). A carer whose bound comes to nil " From 260f6a77e90bc290348bde6f3699b48b7a964b29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 05:18:40 -0500 Subject: [PATCH 076/168] Name the actors in the carer-award comments and documentation Comments, docstrings, YAML notes and the zero-value parameter's description give each step to DWP, councils, the Scottish Ministers or the model, and drop sentences about earlier code. No formula or expected value changes. Co-Authored-By: Claude Opus 5.5 --- .../uc_award_includes_zero_value_award.yaml | 2 +- .../council_tax_reduction_carer_premium.yaml | 73 ++++++++++--------- .../uc_carer_element_carer_test.yaml | 45 ++++++------ .../test_uc_carer_element_award_properties.py | 47 ++++++------ .../has_uc_award_with_carer_element.py | 11 +-- .../universal_credit/uc_award_upper_bound.py | 2 +- ...is_cared_for_by_carer_benefit_recipient.py | 11 +-- 7 files changed, 99 insertions(+), 92 deletions(-) diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml index 06ff155820..3c25bfbf63 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml @@ -1,4 +1,4 @@ -description: Whether Scottish councils count a Universal Credit award with a value of zero when deciding the working-age Council Tax Reduction carer premium. The model reads that to include a nil award that earnings bring about, a reading the sources leave open. +description: Whether Scottish councils count a Universal Credit award with a value of zero when deciding the working-age Council Tax Reduction carer premium. The model reads that definition as reaching an award that DWP tapers to nil because of the carer's earnings; the sources leave that reading open. values: 2013-04-01: false 2022-04-01: true diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 07e321aa83..eac36da9df 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -1,13 +1,14 @@ -# Scotland's working-age council tax reduction includes the carer premium -# where the applicant or partner has regular and substantial caring +# Scottish councils include the carer premium in the working-age council tax +# reduction where the applicant or partner has regular and substantial caring # responsibilities for a severely disabled person (SSI 2021/249 Sch 1 para -# 5(2)), which para 6(1)(c) extends to "an award of universal credit which -# includes the carer element described in regulation 29 of the 2013 -# Regulations". No other scheme's carer premium has that limb: the Housing -# Benefit, English and Welsh schedules test entitlement to Carer's Allowance -# or Carer Support Payment (HB Regs 2006 Sch 3 para 17; WSI 2013/3029 Sch 7 -# para 14), as did Scotland's scheme for persons under pension age before -# 1 April 2022 (SSI 2012/303 Sch 1 para 14). From 1 April 2022 to 31 March +# 5(2)), and the Scottish Ministers count among those responsibilities "an +# award of universal credit which includes the carer element described in +# regulation 29 of the 2013 Regulations" (para 6(1)(c)). No other scheme's +# carer premium has that limb: under the Housing Benefit, English and Welsh +# schedules, DWP and councils test entitlement to Carer's Allowance or Carer +# Support Payment (HB Regs 2006 Sch 3 para 17; WSI 2013/3029 Sch 7 para 14), +# as Scottish councils did for persons under pension age before 1 April 2022 +# (SSI 2012/303 Sch 1 para 14). From 1 April 2022 to 31 March # 2025 an "award of universal credit" included "a universal credit award with # a value of zero" (reg 4, as amended by SSI 2022/52 reg 3); from 1 April 2025 # it "means an award of universal credit made under Part 1 of the 2012 Act" @@ -29,9 +30,10 @@ # (2022-23), standard allowance £424.90, £400.14, £393.45 and £334.91. - name: The issue's Scottish carer aged 40 with 35 hours and no Carer's Allowance claim gets the carer premium - # Para 6(1)(c): the award includes the carer element, 209.34 x 12 = - # 2,512.08, so the applicable amount comes to (95.55 + 48.15) x 52 = - # 7,472.40, not 95.55 x 52 = 4,968.60. + # The award includes the carer element, 209.34 x 12 = 2,512.08, so the + # council includes the carer premium (para 6(1)(c)) and the applicable + # amount comes to (95.55 + 48.15) x 52 = 7,472.40, not 95.55 x 52 = + # 4,968.60. period: 2026 absolute_error_margin: 0.01 input: @@ -83,9 +85,10 @@ council_tax_reduction_applicable_amount: (77.00 + 38.85) * 52 - name: Before 1 April 2022 the Scottish scheme for persons under pension age tested Carer's Allowance only - # SSI 2012/303 Sch 1 para 14: "entitled to a carer's allowance". The model - # reads model year 2021 (2021-22) at 30 April 2021, so the carer element, - # 163.73 x 12 = 1,964.76, gives no premium. + # Under SSI 2012/303 Sch 1 para 14, councils included the premium for an + # applicant or partner "entitled to a carer's allowance". The model reads + # model year 2021 (2021-22) at 30 April 2021, so the carer element, 163.73 + # x 12 = 1,964.76, brings no premium. period: 2021 absolute_error_margin: 0.01 input: @@ -107,9 +110,9 @@ council_tax_reduction_carer_premium: 0 - name: The same carer in England gets no carer premium for the carer element - # England: the working-age premiums follow the Housing Benefit schedule, - # whose carer premium needs entitlement to Carer's Allowance (HB Regs 2006 - # Sch 3 para 17(1)). + # In England the model takes the working-age premiums from the Housing + # Benefit schedule, whose carer premium needs entitlement to Carer's + # Allowance (HB Regs 2006 Sch 3 para 17(1)). period: 2026 absolute_error_margin: 0.01 input: @@ -132,7 +135,8 @@ council_tax_reduction_applicable_amount: 95.55 * 52 - name: The same carer in Wales gets no carer premium for the carer element - # WSI 2013/3029 Sch 7 para 14(1): "entitled to a carer's allowance". + # Welsh councils include the premium for an applicant or partner "entitled + # to a carer's allowance" (WSI 2013/3029 Sch 7 para 14(1)). period: 2026 absolute_error_margin: 0.01 input: @@ -217,11 +221,12 @@ council_tax_reduction_carer_premium: 45.60 * 52 - name: From 1 April 2025 the same Scottish carer's nil award leaves no award - # The same carer in 2025-26. The maximum amount is (400.14 + 201.68) x 12 = - # 7,221.84, and the taper again takes more, so the award comes to nil. From - # 1 April 2025 reg 4 defines an award of universal credit as one "made under - # Part 1 of the 2012 Act" (SSI 2025/24 reg 8), and an income that tapers it to nil leaves - # no award, so para 6(1)(c) does not apply: no carer premium. Neither of + # The same carer in 2025-26. The maximum amount comes to (400.14 + 201.68) + # x 12 = 7,221.84, and the taper again takes more, so the award comes to + # nil. From 1 April 2025 reg 4 defines an award of universal credit as one + # "made under Part 1 of the 2012 Act" (SSI 2025/24 reg 8), and an income + # that tapers it to nil leaves no award, so para 6(1)(c) does not apply: no + # carer premium. Neither of # reg 10A's nil awards (a sanction, or a reclaim under the UC Claims and # Payments Regs 2013 reg 32A) arises. period: 2025 @@ -278,8 +283,9 @@ - name: Scottish partners caring for the same person get one premium, whichever holds the carer element # Both care 35 hours. The younger receives Carer Support Payment and the # joint award includes the carer element for the elder (UC Regs 2013 reg - # 29(3)). Para 5(3) and (4) pay one premium, in respect of the one whose - # award includes the carer element: 48.15 x 52 = 2,503.80. + # 29(3)). Councils include one premium under para 5(3) and (4), in respect + # of the one whose award includes the carer element: 48.15 x 52 = + # 2,503.80. period: 2026 absolute_error_margin: 0.01 input: @@ -307,8 +313,9 @@ council_tax_reduction_carer_premium: 48.15 * 52 - name: A Scottish pensioner's schedule has no carer element limb - # SSI 2012/319 Sch 1 para 10(1): entitlement to Carer's Allowance or Carer - # Support Payment. The working-age variable keeps the shared premium. + # Under Scotland's pension-age scheme, councils test entitlement to Carer's + # Allowance or Carer Support Payment (SSI 2012/319 Sch 1 para 10(1)), and + # the working-age variable keeps the shared premium. period: 2026 absolute_error_margin: 0.01 input: @@ -331,11 +338,11 @@ council_tax_reduction_premiums: 0 - name: A dependant's caring does not raise the Scottish carer premium - # Para 5(2) includes the premium where "an applicant or the applicant's - # partner" has caring responsibilities. The son receives Carer Support - # Payment for caring for his grandmother, but he is neither the applicant - # nor a partner, so his parent's family gets no carer premium (48.15 x 52 - # = 2,503.80 if his caring counted). + # Councils include the premium where "an applicant or the applicant's + # partner" has caring responsibilities (para 5(2)). The son receives Carer + # Support Payment for caring for his grandmother, but councils count only + # the applicant and partner, so his parent's family gets no carer premium + # (48.15 x 52 = 2,503.80 if his caring counted). period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml index 1cae9f34b9..fd1065b142 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -1,11 +1,11 @@ -# An award of Universal Credit which includes the carer element counts as a -# carer benefit in the carer condition of the legacy severe disability premium -# (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b); HB(SPC) Regs 2006 Sch 3 -# para 6(2)) and of the Pension Credit severe disability addition (SPC Regs -# 2002 Sch I para 1(1)(a)(iii) and (b), with para 1(4)): DWP and councils -# withhold the premium or addition when a carer "has an award of universal -# credit which includes the carer element in respect of caring for" the -# claimant. DWP includes the carer element for regular and substantial caring +# DWP and councils count an award of Universal Credit which includes the +# carer element as a carer benefit in the carer condition of the legacy +# severe disability premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b); +# HB(SPC) Regs 2006 Sch 3 para 6(2)) and of the Pension Credit severe +# disability addition (SPC Regs 2002 Sch I para 1(1)(a)(iii) and (b), with +# para 1(4)): they withhold the premium or addition when a carer "has an +# award of universal credit which includes the carer element in respect of +# caring for" the claimant. DWP includes the carer element for regular and substantial caring # for a severely disabled person, whether or not the carer claims Carer's # Allowance (UC Regs 2013 regs 29 and 30(2)). # @@ -16,11 +16,11 @@ # over) and £666.97 (joint, either 25 or over), carer element £209.34 a month. - name: A blind carer's Universal Credit carer element bars the premium of the person they care for - # The carer is a non-dependant who is blind, so the non-dependant condition - # ignores them (para 14(4)(b)); their award bars the premium instead. - # Carer element 209.34 x 12 = 2,512.08; award (424.90 + 209.34) x 12 = - # 7,610.88. Without the limb the model paid the premium, 86.05 x 52 = - # 4,474.60. + # The council ignores the carer, a blind non-dependant, under the + # non-dependant condition (para 14(4)(b)), and the carer's award bars the + # premium instead. Carer element 209.34 x 12 = 2,512.08; award (424.90 + + # 209.34) x 12 = 7,610.88. Without the award, the council would include the + # premium, 86.05 x 52 = 4,474.60. period: 2026 input: people: @@ -50,10 +50,11 @@ severe_disability_premium: [0, 0] - name: A partner whose joint award includes the carer element leaves a couple who both qualify the single rate - # Para 14(2)(b)(iii): a carer element in respect of caring for only one of - # the couple gives one single rate, 86.05 x 52 = 4,474.60, where no carer - # gave the double rate, 172.10 x 52 = 8,949.20. Joint award (666.97 + - # 209.34) x 12 = 10,515.72. + # The joint award includes a carer element in respect of caring for only + # one of the couple, so the council includes one single rate, 86.05 x 52 = + # 4,474.60, and not the double rate, 172.10 x 52 = 8,949.20, that it + # includes where no one cares for either (para 14(2)(b)(iii)). Joint award + # (666.97 + 209.34) x 12 = 10,515.72. period: 2026 input: people: @@ -80,9 +81,10 @@ severe_disability_premium: 4_474.60 - name: A son's Universal Credit carer element in another benefit unit bars a pensioner's severe disability addition - # SPC Regs 2002 Sch I para 1(1)(a)(iii) and (4). The son is blind, so the - # residence condition ignores him (para 2). Without the limb the model paid - # the addition, 86.05 x 52 = 4,474.60, and the pension-age Housing Benefit + # DWP ignores the blind son as a resident (SPC Regs 2002 Sch I para + # 2(2)(b)), and his award with the carer element bars the addition (para + # 1(1)(a)(iii) and (4)). Without the award, DWP would include the addition, + # 86.05 x 52 = 4,474.60, and the council the pension-age Housing Benefit # premium at the same amount (HB(SPC) Regs 2006 Sch 3 para 6(2)(a)(iii)). period: 2026 input: @@ -189,7 +191,8 @@ is_cared_for_by_carer_benefit_recipient: [false, false, true, false] - name: A carer whose income tapers the award to nil has no award and does not bar the premium - # The carer's maximum amount is (424.90 + 209.34) x 12 = 7,610.88. Earnings + # The carer's maximum amount comes to (424.90 + 209.34) x 12 = 7,610.88. + # Earnings # of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 and # National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, and # with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. That diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 629146f9fc..6d76b8513d 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -16,10 +16,9 @@ (receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when the model counts a nil award as one with a value of zero, the award's conditions. Each example simulates the households as drawn, and again with -has_uc_award_with_carer_element supplied as false for everyone, the -attribution as it ran before the limb (its carers then come from -receives_carer_benefit alone). carer_premium gives the Scottish premium's -counterpart without the limb. +has_uc_award_with_carer_element supplied as false for everyone, which leaves +receives_carer_benefit as the attribution's only source of carers. +carer_premium gives the Scottish premium's counterpart without the limb. Invariants, on generated households under baseline law: @@ -61,10 +60,6 @@ A reform that makes is_uc_eligible read Pension Credit would cycle through the attribution, which reads eligibility; these tests do not cover reforms. - -The no-change invariant compares the two code paths in this tree; the -differential against origin/main itself runs on hand-built datasets outside -the test suite (see the pull request). """ import numpy as np @@ -132,9 +127,10 @@ def adult(draw, age): age=draw(age), disability=draw(st.sampled_from(["none", "none", "pip_standard", "aa_higher"])), blind=draw(st.sampled_from([False, False, True])), - # The qualifying hours, with or without a Carer's Allowance claim: the - # Universal Credit carer element needs no claim (UC Regs 2013 reg - # 30(2)), and its test ignores the allowance's earnings limit. + # Draw the qualifying hours with or without a Carer's Allowance claim: + # DWP includes the Universal Credit carer element without a claim and + # above the allowance's earnings limit (UC Regs 2013 reg 30(1)(a)(i) + # and (2)). care_hours=draw(st.sampled_from([0, 35, 35])), would_claim_carers_allowance=draw(st.booleans()), earnings=draw(st.sampled_from([0, 0, 8_000, 25_000])), @@ -268,8 +264,8 @@ def single(age=40, **inputs): EXAMPLES = [ - # The blind carer: a Universal Credit carer element in another benefit - # unit bars the disabled person's premium. + # A blind carer's Universal Credit carer element, in another benefit + # unit, bars the disabled person's premium. dict( country="ENGLAND", units=[ @@ -303,7 +299,7 @@ def single(age=40, **inputs): ) ], ), - # A pensioner cared for by a son on a qualifying benefit. + # A pensioner whose son, on a qualifying benefit, cares for her. dict( country="WALES", units=[ @@ -318,11 +314,10 @@ def single(age=40, **inputs): def test_issue_households_read_the_carer_element_award(): - # #2120: the blind carer's award includes the carer element, 209.34 x 12, - # so the disabled person has no severe disability premium (86.05 x 52 - # without it). #2124: the Scottish carer's applicable amount includes the - # carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. The test reads - # only variables that predate the fix, so it also runs on the old code. + # In #2120 the blind carer's award includes the carer element, 209.34 x + # 12, so the disabled person has no severe disability premium (86.05 x 52 + # without it). In #2124 the Scottish carer's applicable amount includes + # the carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. sim = Simulation(situation=situation(EXAMPLES[:1] + EXAMPLES[2:3])) severe = sim.calculate("severe_disability_premium", YEAR) carer_element = sim.calculate("uc_carer_element", YEAR) @@ -502,8 +497,8 @@ def test_a_stored_award_is_the_award_and_reads_no_cycle(order): def test_a_reform_that_abolishes_universal_credit_leaves_no_carer_element_award(): - # A neutralized award reads no other variable and comes to nil, so no one has - # an award and the blind carer of the issue no longer bars the premium. + # A neutralized award reads no other variable and comes to nil, so no one + # has an award, and the issue's blind carer does not bar the premium. from policyengine_core.reforms import Reform class AbolishUniversalCredit(Reform): @@ -676,16 +671,16 @@ def small_housing_situation(): strict=True, reason=( "#2795: the bound's calculation without housing costs takes the " - "higher work allowance and stays positive when this award is nil." + "higher work allowance and stays positive when this award comes to nil." ), ) def test_small_housing_costs_with_a_work_allowance_leave_no_award(): # DWP has determined that the carer has limited capability for work, so # her award has a work allowance (UC Regs 2013 reg 22(1)(b)), and she - # gets the carer element with it: reg 29(4) gives the LCWRA element in - # its place only for limited capability for work and work-related - # activity, and reg 30(1)(a)(i) and (2) ignore the allowance's earnings - # limit and the missing claim. Her award has a housing costs element of + # gets the carer element with it: DWP gives the LCWRA element in its + # place only for limited capability for work and work-related activity + # (reg 29(4)), and ignores the allowance's earnings limit and the missing + # claim (reg 30(1)(a)(i) and (2)). Her award has a housing costs element of # £600, so DWP applies the lower work allowance, £427 a month (reg # 22(2)(b)). The taper then exceeds the maximum amount, and the award # comes to nil (Welfare Reform Act 2012 s.5(1)(b); reg 17). The bound diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index 1b26f254e4..64394d7f8d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -27,11 +27,12 @@ class has_uc_award_with_carer_element(Variable): "deduction reads Pension Credit, whose addition reads the " "attribution. has_uc_award_with_carer_element therefore reads the " "award's conditions and bound. " - "The family claims Universal Credit (claims_universal_credit), meets " - "the age, pension-route and capital conditions (is_uc_eligible) and " - "has a positive upper bound on its award (uc_award_upper_bound), and " - "the award includes the carer element in respect of " - "this claimant (uc_carer_element_included_for_claimant). A nil bound " + "It counts an award where the family claims Universal Credit " + "(claims_universal_credit), meets the age, pension-route and capital " + "conditions (is_uc_eligible) and has a positive upper bound on its " + "award (uc_award_upper_bound), and where the award includes the carer " + "element in respect of this claimant " + "(uc_carer_element_included_for_claimant). A nil bound " "leaves a nil award, and so no award (Welfare Reform Act 2012 s.5(1)(b) " "and (2)(b); DWP sets the minimum at one penny, reg 17). A carer " "whose income reduces both bound calculations to nil does not count. " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index b02c2341a6..c92135e432 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -12,7 +12,7 @@ class uc_award_upper_bound(Variable): "awards the maximum amount less the income reduction (UC Regs 2013 " "reg 22). The maximum amount includes the housing costs element " "(uc_housing_costs_element): the rent a renter may claim less DWP's " - "non-dependant deductions. Where that element is positive, DWP " + "non-dependant deductions. Where that element exceeds nil, DWP " "applies the lower work allowance (reg 22(2)(b)). Where DWP deducts " "less than the rent, the award stays at or below the award with no " "deduction. Where it deducts at least the rent, DWP calculates the " diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index a3a0fda087..dddddd6928 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -19,9 +19,9 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "(is_severely_disabled_for_carers_allowance; UC Regs 2013 reg 30), a " "carer has one award (SSCBA 1992 s.70(7)), and only " "one person has an allowance or a carer element for caring for the " - "same severely disabled person (s.70(7ZA) to (7ZC) and (7A); both " - "rules were in s.70(7) before 16 November 2023). No one is their own " - "carer. A " + "same severely disabled person (s.70(7ZA) to (7ZC) and (7A); before " + "16 November 2023, s.70(7) contained both rules). The model makes no " + "one their own carer. A " "carer cares for someone in their own benefit unit if they can: a " "claimant or partner first, then another member such as a disabled " "child. Every other carer in the household, including one left over " @@ -41,8 +41,9 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "first, then person order; the number cared for in a benefit unit " "does not depend on that choice. Carers outside the household are not " "observed, and nor is a carer whose allowance runs on for up to eight " - "weeks after the person they cared for has died (s.70(1A)). Receipt " - "is receives_carer_benefit: a positive Carer's Allowance or Carer " + "weeks after the person they cared for has died (s.70(1A)). The " + "model reads receipt from receives_carer_benefit: a positive Carer's " + "Allowance or Carer " "Support Payment after the overlapping-benefit adjustment. A carer " "whose allowance an overlapping benefit, such as a higher State " "Pension, reduces to nil keeps the entitlement but receives nothing, " From 1e5771c12eb63098ff0679c81254c2f5824732a9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 07:30:40 -0500 Subject: [PATCH 077/168] Document the State Pension's 52 weeks and test survey data to Income Tax The State Pension components read state_pension_reported back at 52 weeks a year and split it at the full weekly rate, so a dataset must annualise the State Pension at 52 weeks. policyengine-uk-data used 365.25 / 7, so every full-rate pensioner carried an add-on they do not get, which put people whose only income is the full new State Pension on the Pension Tax Allowance's taper, paying tax (policyengine-uk-data#644 fixes the data). state_pension_reported now says so, and new tests follow a 2024-25 survey record to Income Tax: a full-rate reporter has no add-on in any year; Steph pays no Income Tax in 2027-28 to 2029-30, the same as when her State Pension is given directly; and near the full new rate only the add-on is taxed, at most 8/15 of it. Co-Authored-By: Claude Opus 5.5 --- .../state-pension-reported-weeks.changed.md | 1 + .../test_state_pension_reported_weeks.py | 193 ++++++++++++++++++ .../gov/dwp/state_pension_reported.py | 9 + 3 files changed, 203 insertions(+) create mode 100644 changelog.d/state-pension-reported-weeks.changed.md create mode 100644 policyengine_uk/tests/test_state_pension_reported_weeks.py diff --git a/changelog.d/state-pension-reported-weeks.changed.md b/changelog.d/state-pension-reported-weeks.changed.md new file mode 100644 index 0000000000..615785998d --- /dev/null +++ b/changelog.d/state-pension-reported-weeks.changed.md @@ -0,0 +1 @@ +Document that state_pension_reported is the weekly State Pension times 52, the weeks the State Pension components read it back at, and test the route from a survey's weekly amount to Income Tax under the Pension Tax Allowance. diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py new file mode 100644 index 0000000000..7d74b0eade --- /dev/null +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -0,0 +1,193 @@ +"""The State Pension a survey reports, read back at 52 weeks a year. + +basic_state_pension, new_state_pension and additional_state_pension divide +state_pension_reported by policyengine-core's 52 weeks a year and split the +weekly amount at the full rate of the person's type in the data year. A dataset +must therefore annualise the State Pension at 52 weeks. At 365.25 / 7, which +policyengine-uk-data used until policyengine-uk-data#644, every full-rate +pensioner read 0.34% above the full rate and carried an add-on they do not +get, which put people whose only income is the full new State Pension on the +Pension Tax Allowance's taper, paying tax. + +These cases build simulations from 2024-25 data projected forward, as survey +data is (ages held fixed), and check the route from a reported weekly amount to +Income Tax: + +- a pensioner reporting exactly the data year's full rate has no add-on in any + year; +- Steph, whose only income is the full new State Pension, pays no Income Tax + in 2027-28 to 2029-30, as HM Treasury's explainer says, and the same as when + her State Pension is given directly; +- near the full new rate, no one reporting at most the full rate pays Income + Tax, and above it the tax never exceeds 8/15 of the add-on (20% on it plus a + third of it off the allowance). + +policyengine-core holds amounts as float32, so £11,502.40 reads back about a +ten-thousandth of a penny above £221.20 a week; comparisons allow a penny. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.system import system + +DATA_YEAR = 2024 +PTA_YEARS = [2027, 2028, 2029] +WEEKS_IN_YEAR = 52 +# Ages held through the projection: reached State Pension age in 2020 (new +# State Pension) and in 2005 (basic State Pension). +AGE = {"NEW": 70, "BASIC": 85} +PENNY = 0.01 + + +def survey_simulation(weekly, pension_types) -> Microsimulation: + """2024-25 data with one pensioner per household, reporting `weekly` a + week of State Pension annualised at 52 weeks, and nothing else.""" + ids = np.arange(len(weekly)) + person = pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [AGE[t] for t in pension_types], + "gender": "FEMALE", + "state_pension_reported": np.asarray(weekly) * WEEKS_IN_YEAR, + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.ones(len(ids)), + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return Microsimulation( + dataset=UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": ids}), + household=household, + fiscal_year=DATA_YEAR, + ) + ) + + +def calculate(sim, variable, year) -> np.ndarray: + # A Microsimulation returns a weighted series, a Simulation an array. + values = sim.calculate(variable, year) + return np.asarray(getattr(values, "values", values)) + + +def full_rates(year) -> dict: + p = system.parameters.gov.dwp.state_pension + return { + "NEW": p.new_state_pension.amount(year), + "BASIC": p.basic_state_pension.amount(year), + } + + +@pytest.fixture(scope="module") +def full_rate_reporters(): + types = ["NEW", "BASIC"] + rates = full_rates(DATA_YEAR) + return survey_simulation([rates[t] for t in types], types), types + + +@pytest.mark.parametrize("year", range(DATA_YEAR, 2031)) +def test_full_rate_reporters_have_no_add_on_in_any_year(full_rate_reporters, year): + sim, types = full_rate_reporters + rates = full_rates(year) + + assert calculate(sim, "state_pension_type", year).astype(str).tolist() == types + assert np.all(calculate(sim, "additional_state_pension", year) < PENNY) + assert calculate(sim, "new_state_pension", year)[0] == pytest.approx( + WEEKS_IN_YEAR * rates["NEW"], abs=PENNY + ) + assert calculate(sim, "basic_state_pension", year)[1] == pytest.approx( + WEEKS_IN_YEAR * rates["BASIC"], abs=PENNY + ) + + +@pytest.mark.parametrize("year", PTA_YEARS) +def test_steph_pays_no_income_tax_from_survey_data_or_given_directly( + full_rate_reporters, year +): + """Steph's only income is the full new State Pension: £13,036.40 in + 2027-28, 52 weeks at £250.70. The Pension Tax Allowance removes all her + tax, whether her State Pension comes from a survey's weekly amount or is + given directly.""" + sim, _ = full_rate_reporters + threshold = WEEKS_IN_YEAR * full_rates(year)["NEW"] + personal_allowance = system.parameters( + f"{year}-04-30" + ).gov.hmrc.income_tax.allowances.personal_allowance.amount + tax_before_allowance = 0.2 * (threshold - personal_allowance) + + direct = Simulation( + situation={ + "people": { + "steph": { + "age": {year: AGE["NEW"]}, + "state_pension": {year: threshold}, + } + }, + "benunits": {"benunit": {"members": ["steph"]}}, + "households": { + "household": {"members": ["steph"], "region": {year: "LONDON"}} + }, + } + ) + + from_survey = { + v: calculate(sim, v, year)[0] + for v in ("state_pension", "income_tax", "pension_tax_allowance") + } + given_directly = {v: calculate(direct, v, year)[0] for v in from_survey} + + assert from_survey["state_pension"] == pytest.approx(threshold, abs=PENNY) + assert from_survey["income_tax"] == pytest.approx(0, abs=PENNY) + # Not vacuous: without the allowance she would pay basic-rate tax. + assert tax_before_allowance > 90 + assert from_survey["pension_tax_allowance"] == pytest.approx( + tax_before_allowance, abs=PENNY + ) + for v in from_survey: + assert from_survey[v] == pytest.approx(given_directly[v], abs=PENNY), v + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + offsets=st.lists( + st.one_of( + st.floats(min_value=-5, max_value=5, allow_nan=False), + st.sampled_from([0.0, -0.01, 0.01]), + ), + min_size=1, + max_size=40, + ) +) +def test_near_the_full_rate_only_the_add_on_is_taxed(offsets): + """New State Pension reporters within £5 a week of the full rate, with no + other income, in each year of the allowance.""" + offsets = np.asarray(offsets) + rates = full_rates(DATA_YEAR) + sim = survey_simulation(rates["NEW"] + offsets, ["NEW"] * len(offsets)) + at_or_below = offsets <= 0 + for year in PTA_YEARS: + add_on = calculate(sim, "additional_state_pension", year) + tax = calculate(sim, "income_tax", year) + assert np.all(add_on[at_or_below] < PENNY), year + assert np.all(tax[at_or_below] < PENNY), year + assert np.all(tax <= 8 / 15 * add_on + PENNY), year diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported.py b/policyengine_uk/variables/gov/dwp/state_pension_reported.py index 3fdf232e62..851ee34f00 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported.py @@ -5,6 +5,15 @@ class state_pension_reported(Variable): value_type = float entity = Person label = "Reported income from the State Pension" + documentation = ( + "The State Pension a survey reports, as an annual amount: the weekly " + "amount times 52. basic_state_pension, new_state_pension and " + "additional_state_pension divide it by 52 and split the weekly amount " + "at the full rate of the person's type in the data year, so a dataset " + "must annualise weekly amounts at 52 weeks. At 365.25 / 7, a full-rate " + "pensioner reads 0.34% above the full rate and gets the excess as " + "additional State Pension or a protected payment." + ) definition_period = YEAR unit = GBP From f10a566621ef4798d983787ead84c5382c71043d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 08:07:28 -0500 Subject: [PATCH 078/168] Say which type and which year's rate split the reported State Pension The components split state_pension_reported at the data year's full rate for the type the person has in the year being calculated, not their type in the data year (Sol review of #2818). The tests hold ages whose type never changes. Correct the float32 note to about a thousandth of a penny a week. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_reported_weeks.py | 12 +++++++----- .../variables/gov/dwp/state_pension_reported.py | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py index 7d74b0eade..2f22044d97 100644 --- a/policyengine_uk/tests/test_state_pension_reported_weeks.py +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -2,16 +2,17 @@ basic_state_pension, new_state_pension and additional_state_pension divide state_pension_reported by policyengine-core's 52 weeks a year and split the -weekly amount at the full rate of the person's type in the data year. A dataset -must therefore annualise the State Pension at 52 weeks. At 365.25 / 7, which +weekly amount at the data year's full rate for the type the person has in the +year being calculated. A dataset must therefore annualise the State Pension at +52 weeks. At 365.25 / 7, which policyengine-uk-data used until policyengine-uk-data#644, every full-rate pensioner read 0.34% above the full rate and carried an add-on they do not get, which put people whose only income is the full new State Pension on the Pension Tax Allowance's taper, paying tax. These cases build simulations from 2024-25 data projected forward, as survey -data is (ages held fixed), and check the route from a reported weekly amount to -Income Tax: +data is (ages held fixed, at ages whose type stays the same in every year), and +check the route from a reported weekly amount to Income Tax: - a pensioner reporting exactly the data year's full rate has no add-on in any year; @@ -23,7 +24,8 @@ third of it off the allowance). policyengine-core holds amounts as float32, so £11,502.40 reads back about a -ten-thousandth of a penny above £221.20 a week; comparisons allow a penny. +thousandth of a penny above £221.20 a week (0.0012p, or 0.063p a year); +comparisons allow a penny. """ import numpy as np diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported.py b/policyengine_uk/variables/gov/dwp/state_pension_reported.py index 851ee34f00..2eb40271d8 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported.py @@ -9,10 +9,11 @@ class state_pension_reported(Variable): "The State Pension a survey reports, as an annual amount: the weekly " "amount times 52. basic_state_pension, new_state_pension and " "additional_state_pension divide it by 52 and split the weekly amount " - "at the full rate of the person's type in the data year, so a dataset " - "must annualise weekly amounts at 52 weeks. At 365.25 / 7, a full-rate " - "pensioner reads 0.34% above the full rate and gets the excess as " - "additional State Pension or a protected payment." + "at the data year's full rate for the type the person has in the year " + "being calculated, so a dataset must annualise weekly amounts at 52 " + "weeks. At 365.25 / 7, a full-rate pensioner reads 0.34% above the " + "full rate and gets the excess as additional State Pension or a " + "protected payment." ) definition_period = YEAR unit = GBP From 54cffcec8e37a2efdc3f99af01a026c8a2a52f5e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 08:08:48 -0500 Subject: [PATCH 079/168] Name the simulated year in the State Pension split docs Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_state_pension_reported_weeks.py | 4 ++-- policyengine_uk/variables/gov/dwp/state_pension_reported.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py index 2f22044d97..1537fec5a7 100644 --- a/policyengine_uk/tests/test_state_pension_reported_weeks.py +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -3,8 +3,8 @@ basic_state_pension, new_state_pension and additional_state_pension divide state_pension_reported by policyengine-core's 52 weeks a year and split the weekly amount at the data year's full rate for the type the person has in the -year being calculated. A dataset must therefore annualise the State Pension at -52 weeks. At 365.25 / 7, which +simulated year. A dataset must therefore annualise the State Pension at 52 +weeks. At 365.25 / 7, which policyengine-uk-data used until policyengine-uk-data#644, every full-rate pensioner read 0.34% above the full rate and carried an add-on they do not get, which put people whose only income is the full new State Pension on the diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported.py b/policyengine_uk/variables/gov/dwp/state_pension_reported.py index 2eb40271d8..1acf348c62 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported.py @@ -9,8 +9,8 @@ class state_pension_reported(Variable): "The State Pension a survey reports, as an annual amount: the weekly " "amount times 52. basic_state_pension, new_state_pension and " "additional_state_pension divide it by 52 and split the weekly amount " - "at the data year's full rate for the type the person has in the year " - "being calculated, so a dataset must annualise weekly amounts at 52 " + "at the data year's full rate for the type the person has in the " + "simulated year, so a dataset must annualise weekly amounts at 52 " "weeks. At 365.25 / 7, a full-rate pensioner reads 0.34% above the " "full rate and gets the excess as additional State Pension or a " "protected payment." From af118de43bd68aad6b567f3537da5baa6797f11f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:59:40 +0000 Subject: [PATCH 080/168] Package the SDLT grid fixture, cite the Wales Act for Wales, pin the dates Add a .gitignore exception so hatchling puts the 2018-2030 grid fixture in the wheel and sdist, and test that every tracked package file reaches both. Cite Wales Act 2014 s16 and SI 2018/214 for the end of SDLT in Wales, and LTTADT(W)A 2017 s2 with WSI 2018/34 for the LTT charge. Route a purchase of an additional dwelling to the main rates before 1 April 2016 through a new higher_rates_apply switch, so a reform to Table A reaches it. Give a non-residential purchase below every threshold no band on the whole-price path. Extend the raw-date tests back to 2015 and add raw-date checks for each switch, so each statutory date is pinned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HNJwzBk9KYeaqqKXc --- .gitignore | 1 + changelog.d/2801.fixed.md | 2 +- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 10 +- .../gov/hmrc/stamp_duty/applies_in_wales.yaml | 15 +- .../stamp_duty/non_residential/purchase.yaml | 2 +- .../purchase_charged_on_whole_price.yaml | 2 +- .../additional/higher_rates_apply.yaml | 25 +++ .../residential/purchase/additional/min.yaml | 11 +- .../residential/purchase/additional/rate.yaml | 88 +++----- .../residential/purchase/main/first/max.yaml | 6 +- .../code_health/test_release_artifacts.py | 26 +++ .../stamp_duty_land_tax_commencement.yaml | 66 +++++- .../test_sdlt_dated_schedule_properties.py | 82 +++++-- ..._sdlt_statutory_commencement_properties.py | 200 +++++++++++++++++- .../variables/gov/hmrc/sdlt_liable.py | 2 +- ...n_non_residential_property_transactions.py | 14 +- ...lt_on_residential_property_transactions.py | 23 +- .../variables/gov/wra/ltt_liable.py | 7 +- 18 files changed, 460 insertions(+), 122 deletions(-) create mode 100644 policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml diff --git a/.gitignore b/.gitignore index d0f289aae2..5df73da1f9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ policyengine_uk/calibration/*.h5 !uprating_growth_factors.csv !policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv !policyengine_uk/tests/fixtures/energy_prices/*.csv +!policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv # Virtual environments .venv/ diff --git a/changelog.d/2801.fixed.md b/changelog.d/2801.fixed.md index afeae0051b..698db21861 100644 --- a/changelog.d/2801.fixed.md +++ b/changelog.d/2801.fixed.md @@ -1 +1 @@ -Stamp Duty Land Tax now starts first-time buyers' relief on 22 November 2017, the higher rates for additional dwellings on 1 April 2016 and the 2016 non-residential rates on 17 March 2016, charges a non-residential purchase before then at one percentage of the whole price, and charges Welsh buyers SDLT until 31 March 2018 and Land Transaction Tax from 1 April 2018. +The model now dates first-time buyers' relief from 22 November 2017, the higher rates for additional dwellings from 1 April 2016 and the 2016 non-residential rates from 17 March 2016. Before 17 March 2016 it computes SDLT on a non-residential purchase as one percentage of the whole price. It computes SDLT on purchases in Wales until 31 March 2018 and Land Transaction Tax from 1 April 2018. diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index a6e28e0cdc..dd527960d0 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -23,12 +23,12 @@ "source": [ "## Start dates in 2016 to 2018\n", "\n", - "The parameters date each change to the effective date Parliament set, and each model year reads the parameters in force on 30 April:\n", + "This page lists the effective date Parliament set for each change. Each model year reads the parameters in force on 30 April:\n", "\n", - "- First-time buyers' relief starts on 22 November 2017 ([Finance Act 2018 s41(8)](https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted)), so the model first grants it in model year 2018.\n", - "- The higher rates for additional dwellings start on 1 April 2016 ([Finance Act 2016 s128(5)](https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted)). In model year 2015 a buyer of an additional dwelling pays the main rates at any price.\n", - "- The sliced non-residential rates and the 2% band on lease rent above £5 million start on 17 March 2016 ([Finance Act 2016 s127(12)](https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted)). In model year 2015 the model charges a non-residential purchase the percentage for the band its price falls in on the whole price, under [s55(2) Table B as at 30 April 2015](https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30): 0% up to £150,000, 1% up to £250,000, 3% up to £500,000 and 4% above. A £1,000,000 purchase pays £40,000 in model year 2015 and £39,500 from model year 2016.\n", - "- HMRC charges SDLT on land in Wales until 31 March 2018 ([Finance Act 2003 s48(1)(a) as at 30 April 2017](https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30)), and the Welsh Revenue Authority charges Land Transaction Tax from 1 April 2018. The model charges SDLT in Wales up to model year 2017 and Land Transaction Tax from model year 2018. The parameter `gov.hmrc.stamp_duty.applies_in_wales` sets the switch." + "- Parliament started first-time buyers' relief on 22 November 2017 ([Finance Act 2018 s41(8)](https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted)), so the model grants it from model year 2018.\n", + "- Parliament started the higher rates for additional dwellings on 1 April 2016 ([Finance Act 2016 s128(5)](https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted)). In model year 2015 a buyer of an additional dwelling pays the main rates at any price, and a reform to the main rates reaches that buyer too. A reform can move the switch through `gov.hmrc.stamp_duty.residential.purchase.additional.higher_rates_apply`.\n", + "- Parliament moved non-residential purchases to sliced rates, and added a 2% band on lease rent above £5 million, from 17 March 2016 ([Finance Act 2016 s127(12)](https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted)). Before then HMRC charged the whole price of a non-residential purchase at the percentage for its band under [s55(2) Table B as at 30 April 2015](https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30): 0% up to £150,000, 1% up to £250,000, 3% up to £500,000 and 4% above. The model applies that table in model year 2015, so a £1,000,000 purchase pays £40,000 in model year 2015 and £39,500 from model year 2016.\n", + "- HMRC charged SDLT on land in Wales until 31 March 2018 ([Finance Act 2003 s48(1)(a) as at 30 April 2017](https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30)). Parliament ended that charge from 1 April 2018 ([Wales Act 2014 s16](https://www.legislation.gov.uk/ukpga/2014/29/section/16), on the date the Treasury appointed in [SI 2018/214](https://www.legislation.gov.uk/uksi/2018/214/article/2/made)), and the Welsh Revenue Authority charges Land Transaction Tax from then ([Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017 s2](https://www.legislation.gov.uk/anaw/2017/1/section/2)). The model computes SDLT on purchases in Wales up to model year 2017 and Land Transaction Tax from model year 2018. A reform can move the switch through `gov.hmrc.stamp_duty.applies_in_wales`." ] }, { diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml index d87fa92458..0023c852ef 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml @@ -9,14 +9,21 @@ values: 2018-04-01: value: false metadata: + note: Wales Act 2014 s. 16(5) and (6) keep SDLT for transactions under some contracts entered into on or before 17 December 2014. The model has no contract date, so it applies the switch to every purchase. reference: - - title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) Regulations 2018, schedule - href: https://www.legislation.gov.uk/wsi/2018/128/schedule + - title: Wales Act 2014 s. 16(2), (4) + href: https://www.legislation.gov.uk/ukpga/2014/29/section/16 + - title: The Wales Act 2014, Sections 16 and 19 (Disapplication of UK Stamp Duty Land Tax and UK Landfill Tax) (Appointed Date) Order 2018 (SI 2018/214) art. 2(a) + href: https://www.legislation.gov.uk/uksi/2018/214/article/2/made + - title: Finance Act 2003 s. 48(1)(a), as at 1 April 2018 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2018-04-01 metadata: unit: bool label: Stamp Duty applies in Wales reference: - title: Finance Act 2003 s. 48(1)(a), as at 30 April 2017 href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30 - - title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) Regulations 2018, schedule - href: https://www.legislation.gov.uk/wsi/2018/128/schedule + - title: Wales Act 2014 s. 16(2), (4) + href: https://www.legislation.gov.uk/ukpga/2014/29/section/16 + - title: The Wales Act 2014, Sections 16 and 19 (Disapplication of UK Stamp Duty Land Tax and UK Landfill Tax) (Appointed Date) Order 2018 (SI 2018/214) art. 2(a) + href: https://www.legislation.gov.uk/uksi/2018/214/article/2/made diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml index d7ab62889a..30b6829647 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml @@ -39,7 +39,7 @@ brackets: reference: - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted title: Finance Act 2016 s. 127(12) -description: Stamp Duty Land Tax rates for non-residential property transfers, by band of the price. The purchase_charged_on_whole_price parameter beside this one sets whether HMRC charges the rate for the band the price falls in on the whole price, or each band's rate on the slice of the price in that band. +description: Stamp Duty Land Tax rates for non-residential property transfers, by band of the price. gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price sets whether HMRC charges the rate for the band the price falls in on the whole price, or each band's rate on the slice of the price in that band. metadata: economy: false label: Stamp Duty on non-residential property diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml index bc6ffb1e66..e7574ef994 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml @@ -1,4 +1,4 @@ -description: Whether HMRC charges a non-residential purchase at the single percentage of the whole price that the non-residential purchase rates set for the band the price falls in, rather than at each band's rate on the slice of the price in that band. +description: Whether HMRC charges a non-residential purchase one percentage of the whole price, the rate for the band the price falls in. When false, HMRC charges each band's rate on the slice of the price in that band. values: 2003-07-10: value: true diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml new file mode 100644 index 0000000000..8b70598ce7 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml @@ -0,0 +1,25 @@ +description: Whether HMRC charges a purchase of an additional dwelling at the higher rates for additional dwellings. When false, HMRC charges that purchase at the main residential rates, gov.hmrc.stamp_duty.residential.purchase.main.subsequent. +values: + 2003-07-10: + value: false + metadata: + note: Before 1 April 2016 HMRC charged a buyer of an additional dwelling the main rates in s. 55(1B) Table A at any price. + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-04-01: + value: true + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA +metadata: + unit: bool + label: Stamp Duty higher rates for additional dwellings apply + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml index 8c13b17247..1ef8124821 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml @@ -1,23 +1,16 @@ description: Minimum value for taxability of additional residential property. values: - 2014-12-04: - value: 0 - metadata: - note: Before the higher rates for additional dwellings started, a buyer of an additional dwelling paid the main rates in s. 55(1B) Table A at any price. - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 40_000 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(3)(4)(b) + - title: Finance Act 2003 schedule 4ZA paragraph 3(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA metadata: unit: currency-GBP label: Stamp Duty secondary residence minimum value reference: - - title: Finance Act 2003 schedule 4ZA(3)(4)(b) + - title: Finance Act 2003 schedule 4ZA paragraph 3(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml index 3a2d8bbc69..2880c94bbe 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml @@ -1,42 +1,32 @@ description: Stamp Duty Land Tax progressive rates for residential property transfers that are not replacements of an individual's primary residence. brackets: - rate: - 2014-12-04: - value: 0 - metadata: - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 0.03 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.05 threshold: - 2014-12-04: + 2016-04-01: value: 0 metadata: reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - rate: - 2014-12-04: - value: 0.02 - metadata: - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 0.05 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: null @@ -50,17 +40,19 @@ brackets: reference: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1)(b), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2022-09-23: null 2025-04-01: 0.07 threshold: - 2014-12-04: + 2016-04-01: value: 125_000 metadata: reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: null metadata: @@ -73,33 +65,29 @@ brackets: reference: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1)(b), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2022-09-23: null 2025-04-01: 125_000 - rate: - 2014-12-04: - value: 0.05 - metadata: - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 0.08 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.10 threshold: - 2014-12-04: + 2016-04-01: value: 250_000 metadata: reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: 500_000 metadata: @@ -115,59 +103,49 @@ brackets: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1), (3), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - rate: - 2014-12-04: - value: 0.10 - metadata: - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 0.13 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.15 threshold: - 2014-12-04: + 2016-04-01: value: 925_000 metadata: reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - rate: - 2014-12-04: - value: 0.12 - metadata: - reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 2016-04-01: value: 0.15 metadata: reference: - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.17 threshold: - 2014-12-04: + 2016-04-01: value: 1_500_000 metadata: reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA metadata: threshold_unit: currency-GBP rate_unit: /1 label: Stamp Duty on secondary residences reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - title: Finance Act 2016 s. 128(5) href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml index 3641a1a79c..c134d6a669 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml @@ -1,12 +1,10 @@ description: Maximum value for a first-property purchase to receive the Stamp Duty relief for first-time buyers. Increasing this value will underestimate the number of claims for FTBR, as the model will not add new claims. values: - 2014-12-04: + 2003-07-10: value: 0 metadata: - note: Before Parliament inserted Schedule 6ZA, a first-time buyer paid the main rates in s. 55(1B) Table A, so no purchase qualifies for the relief. + note: Parliament gave Schedule 6ZA effect from 22 November 2017 (Finance Act 2018 s. 41(8)). Before then a first-time buyer paid the main rates, so no purchase qualifies for the relief. The model applies no first-time buyers' relief other than Schedule 6ZA. reference: - - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 - title: Finance Act 2018 s. 41(8) href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted 2017-11-22: diff --git a/policyengine_uk/tests/code_health/test_release_artifacts.py b/policyengine_uk/tests/code_health/test_release_artifacts.py index cce67f0d58..cfbdfd3061 100644 --- a/policyengine_uk/tests/code_health/test_release_artifacts.py +++ b/policyengine_uk/tests/code_health/test_release_artifacts.py @@ -26,6 +26,9 @@ index serves another file or other bytes. 7. The release workflow's PyPI checks read the manifest written before the publish step, which adds attestation files to ``dist`` (pe-uk#2461). +8. Hatchling's wheel and sdist builders select every file git tracks under + ``policyengine_uk/``, so ``check-dist`` finds none missing at release. + ``.gitignore`` decides what hatchling leaves out, whatever git tracks. """ import hashlib @@ -771,3 +774,26 @@ def run(name): assert "--dist" not in run(name), name # Written before the publish step adds anything to dist. assert names.index(written) < names.index("Publish to PyPI") + + +# Invariant 8: hatchling packages every tracked file. + + +def test_hatchling_packages_every_tracked_package_file(): + from hatchling.builders.sdist import SdistBuilder + from hatchling.builders.wheel import WheelBuilder + + tracked = set( + git(REPO_ROOT, "ls-files", "-z", "--", "policyengine_uk").split("\0") + ) - {""} + assert tracked + wheel = { + file.distribution_path + for file in WheelBuilder(str(REPO_ROOT)).recurse_included_files() + } + sdist = { + file.relative_path + for file in SdistBuilder(str(REPO_ROOT)).recurse_included_files() + } + assert sorted(tracked - wheel) == [] + assert sorted(tracked - sdist) == [] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml index 63f281fbb7..404d0e633f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml @@ -6,8 +6,11 @@ # rates and the 2% lease rent band, from 17 March 2016). Before those dates: # Finance Act 2003 s. 55(1B) Table A and s. 55(2) Table B, and Sch 5 para 2(3) # Table B, each as at 30 April 2015. HMRC charges SDLT on land in Wales until -# 1 April 2018 (s. 48(1)(a) as at 30 April 2017); from then the Welsh Revenue -# Authority charges Land Transaction Tax (WSI 2018/128). +# 31 March 2018 (s. 48(1)(a) as at 30 April 2017). Parliament ended SDLT in +# Wales from 1 April 2018 (Wales Act 2014 s. 16(2), (4); SI 2018/214 art. +# 2(a)), and the Welsh Revenue Authority charges Land Transaction Tax from then +# (LTTADT(W)A 2017 s. 2; WSI 2018/34 art. 3); the Welsh Ministers set LTT's +# rates in WSI 2018/128. - name: First-time buyer, £300,000, model year 2015, pays the main rates # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). @@ -69,6 +72,16 @@ output: stamp_duty_land_tax: 12_500 +- name: First-time buyer, £200,000, model year 2016, pays the main rates + # 2% x 75,000 under s. 55(1B) Table A. + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 200_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 1_500 + - name: First-time buyer, £300,000, model year 2018, gets the relief # Sch 6ZA para 4: 0% to £300,000. period: 2018 @@ -107,6 +120,24 @@ output: stamp_duty_land_tax: 0 +- name: Additional dwelling, £1,000,000, model year 2015, pays the main rates + # 2,500 + 33,750 + 10% x 75,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 43_750 + +- name: Additional dwelling, £1,600,000, model year 2015, pays the main rates + # 2,500 + 33,750 + 57,500 + 12% x 100,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 1_600_000 + output: + stamp_duty_land_tax: 105_750 + - name: Additional dwelling, £300,000, model year 2016, pays the higher rates # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). period: 2016 @@ -352,3 +383,34 @@ ltt_liable: false stamp_duty_land_tax: 5_000 land_transaction_tax: 0 + +- name: A 2015 reform to the main rates reaches a buyer of an additional dwelling + # 3% x 125,000 + 5% x 50,000, the reformed main rates. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.residential.purchase.main.subsequent[1].rate: 0.03 + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 6_250 + +- name: A 2015 reform with a positive first non-residential threshold charges nothing below it + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase[0].threshold: 10_000 + gov.hmrc.stamp_duty.non_residential.purchase[0].rate: 0.005 + non_residential_property_purchased: 5_000 + output: + stamp_duty_land_tax: 0 + +- name: A 2015 reform with a positive first non-residential threshold charges its rate above it + # 0.5% x 20,000 on the whole price. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase[0].threshold: 10_000 + gov.hmrc.stamp_duty.non_residential.purchase[0].rate: 0.005 + non_residential_property_purchased: 20_000 + output: + stamp_duty_land_tax: 100 diff --git a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py index e91d6d3be2..afc22b5af2 100644 --- a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py +++ b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py @@ -1,7 +1,8 @@ """Property-based tests for the dated residential SDLT schedules (issue #2245). The reference below restates the legislation's own tables, for effective -dates from 15 March 2018: +dates from 12 February 2015, the date from which the model's main rates take +their s55(1B) Table A values: - Finance Act 2003 s55(1B) Table A (main rates); Sch4ZA para 1(2) (higher rates for additional dwellings, the main rates plus 3 points, or 5 from @@ -9,6 +10,10 @@ from £40,000; Sch6ZA paras 1(3) and 4 (first-time buyers' relief: 0% to £300,000 and 5% above, on prices up to £500,000); Sch5 para 2(3) Table A (lease rent: 1% of net present value above £125,000). +- Finance Act 2016 s128(5): Sch4ZA's higher rates from 1 April 2016. Before + then a buyer of an additional dwelling paid the main rates at any price. +- Finance Act 2018 s41(8): Sch6ZA's relief from 22 November 2017. Before then + a first-time buyer paid the main rates. - SDLT (Temporary Relief) Act 2020 s1, from 8 July 2020 to 30 June 2021 (the end as amended by Finance Act 2021 s87(2)(a)): 0% to £500,000 on the main and lease rent tables, and no first-time buyers' relief (s1(5)). @@ -22,12 +27,12 @@ 1. Differential: the raw dated parameters give the reference tax for a non-first purchase, a first-time buyer, an additional dwelling and a - lease. -2. Annual sampling: each model year from 2018 to 2040 reads every residential - SDLT parameter at 30 April of that year. -3. Through stamp_duty_land_tax, in every model year from 2018 to 2040, each - buyer's tax equals the reference at 30 April, and in every model year - from 2015 to 2040 it never falls as the price or the lease rent rises. + lease, on any date and on each day a schedule changes and the day before. +2. Annual sampling: each model year from 2015 to 2040 reads every residential + SDLT parameter in force at 30 April of that year. +3. Through stamp_duty_land_tax, in every model year from 2015 to 2040, each + buyer's tax equals the reference at 30 April and never falls as the price + or the lease rent rises. 4. At any effective date, no buyer's tax falls as the price or the lease rent rises. 5. Omitted relief: from 8 July 2020 to 30 June 2021, whatever the main and @@ -67,8 +72,11 @@ FIRST_TIME_BUYER = (300_000, 500_000) FIRST_TIME_BUYER_2022_25 = (425_000, 625_000) # First effective date, main rates, first-time buyers' relief (nil band, -# maximum price) or None, higher-rates surcharge, lease rent nil band. +# maximum price) or None, higher-rates surcharge or None, lease rent nil band. TIMELINE = ( + (date(2015, 2, 12), STANDARD, None, None, 125_000), + (date(2016, 4, 1), STANDARD, None, 0.03, 125_000), + (date(2017, 11, 22), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), (date(2018, 3, 15), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), (date(2020, 7, 8), NIL_500K, None, 0.03, 500_000), (date(2021, 7, 1), NIL_250K, FIRST_TIME_BUYER, 0.03, 250_000), @@ -103,7 +111,8 @@ def reference_tax(effective_date, price, buyer): nil_band, maximum = first_time_buyer if price <= maximum: return _marginal(((0, 0), (nil_band, 0.05)), price) - if buyer == "additional" and price >= HIGHER_RATES_MINIMUM: + higher_rates_apply = surcharge is not None + if buyer == "additional" and higher_rates_apply and price >= HIGHER_RATES_MINIMUM: higher = tuple((start, rate + surcharge) for start, rate in main) return _marginal(higher, price) return _marginal(main, price) @@ -116,17 +125,20 @@ def reference_lease_tax(effective_date, net_present_value): def _model_tax(purchase, price, buyer): # The schedule sdlt_on_residential_property_transactions applies to each - # kind of purchase. - if buyer == "first": + # kind of purchase. Before a relief or surcharge starts, its schedule + # holds no brackets on the raw dated parameters, and the formula reads + # the main rates instead. + main_rates_tax = purchase.main.subsequent.calc(price) + if buyer == "first" and purchase.main.first.max > 0: return np.where( price <= purchase.main.first.max, purchase.main.first.rate.calc(price), - purchase.main.subsequent.calc(price), + main_rates_tax, ) - if buyer == "additional": + if buyer == "additional" and purchase.additional.higher_rates_apply: chargeable = np.where(price < purchase.additional.min, 0, price) return purchase.additional.rate.calc(chargeable) - return purchase.main.subsequent.calc(price) + return main_rates_tax RAW = ParameterNode( @@ -166,6 +178,34 @@ def test_dated_parameters_match_the_statutory_tables(effective_date, price_list) ) +CHANGE_DAYS = sorted( + {d + timedelta(days=offset) for d in CHANGE_DATES for offset in (-1, 0)} +) +BOUNDARY_PRICES = sorted( + {0.0, 0.01, 2_000_000.0} + | {float(b + offset) for b in PRICE_BOUNDARIES for offset in (-1, 0, 1)} +) + + +@pytest.mark.parametrize("effective_date", CHANGE_DAYS, ids=str) +def test_each_change_day_and_its_eve_match_the_statutory_tables(effective_date): + residential = RAW(effective_date.isoformat()) + price = np.array(BOUNDARY_PRICES) + for buyer in BUYERS: + expected = [reference_tax(effective_date, p, buyer) for p in BOUNDARY_PRICES] + np.testing.assert_allclose( + _model_tax(residential.purchase, price, buyer), + expected, + rtol=1e-12, + atol=1e-6, + err_msg=f"{buyer} on {effective_date}", + ) + expected = [reference_lease_tax(effective_date, v) for v in BOUNDARY_PRICES] + np.testing.assert_allclose( + residential.rent.calc(price), expected, rtol=1e-12, atol=1e-6 + ) + + @PROPERTY_SETTINGS @given(effective_date=dates, value_list=st.lists(prices, min_size=1, max_size=8)) def test_dated_lease_rent_matches_the_statutory_table(effective_date, value_list): @@ -240,13 +280,19 @@ def _scale(scale): def _read(residential): + # Before a relief or surcharge starts, the model year reads backdated + # values that the formula never uses, so only schedules in force count. purchase = residential.purchase + first, additional = purchase.main.first, purchase.additional + relief = (_scale(first.rate),) if first.max > 0 else () + higher_rates = bool(additional.higher_rates_apply) + surcharge = (_scale(additional.rate), additional.min) if higher_rates else () return ( _scale(purchase.main.subsequent), - _scale(purchase.main.first.rate), - purchase.main.first.max, - _scale(purchase.additional.rate), - purchase.additional.min, + first.max, + relief, + higher_rates, + surcharge, _scale(residential.rent), ) diff --git a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py index 49a9b3f9d2..7a6e7c9da4 100644 --- a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py +++ b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py @@ -15,11 +15,14 @@ April 2015): 0% up to £150,000, 1% above £150,000 up to £250,000, 3% above £250,000 up to £500,000 and 4% above £500,000. -HMRC charges SDLT on land in Wales until 1 April 2018 (s48(1)(a) as at 30 -April 2017). From then the Welsh Revenue Authority charges Land Transaction -Tax (WSI 2018/128). Each model year reads the parameters in force on 30 April. +HMRC charged SDLT on land in Wales until 31 March 2018 (s48(1)(a) as at 30 +April 2017). Parliament ended SDLT in Wales from 1 April 2018 (Wales Act 2014 +s16(2), (4); SI 2018/214 art 2(a)), and the Welsh Revenue Authority charges +Land Transaction Tax from then (LTTADT(W)A 2017 s2; WSI 2018/34 art 3); the +Welsh Ministers set LTT's rates in WSI 2018/128. Each model year reads the +parameters in force on 30 April. -Invariants, for any generated households: +Invariants, for any generated households or dates: 1. In every model year from 2015 to 2030, a purchase in Wales is liable to exactly one of SDLT and LTT, and the other tax is zero: SDLT up to model @@ -30,8 +33,24 @@ 4. In model years 2018 to 2030, apart from 2021, 2023 and 2024, SDLT and LTT on a grid of purchases equal the values `main` (2da212abc) gave, pinned in tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv. +5. On any date from 1 January 2015, the raw dated parameters hold the + statutory switches and tables: SDLT applies in Wales before 1 April 2018; + HMRC charges a non-residential purchase on the whole price at Table B's + rates before 17 March 2016 and on slices at 0%, 2% and 5% from then; the + non-residential lease rent table adds a 2% band above £5,000,000 from 17 + March 2016. +6. Before 1 April 2016 a buyer of an additional dwelling pays what a buyer of + a main home pays, at every price, on the raw dated parameters and in model + year 2015. +7. Before 22 November 2017 a first-time buyer pays what any other buyer pays, + at every price, on the raw dated parameters and in model years 2015 to + 2017. +8. In every model year from 2015 to 2030, SDLT on a non-residential purchase + never falls as the price rises, on the whole-price path and the sliced + path. """ +from datetime import date, timedelta from pathlib import Path import numpy as np @@ -40,6 +59,9 @@ from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode + +import policyengine_uk from policyengine_uk import Simulation PROPERTY_SETTINGS = settings( @@ -228,7 +250,7 @@ def pinned_grid(): return grid, years, Simulation(situation=_situation(households, years)) -def test_pinned_grid_covers_the_years_2787_leaves_alone(pinned_grid): +def test_pinned_grid_leaves_out_2021_2023_and_2024(pinned_grid): _, years, _ = pinned_grid assert years == [y for y in range(2018, 2031) if y not in (2021, 2023, 2024)] @@ -249,3 +271,171 @@ def test_model_years_from_2018_match_main(pinned_grid, year): atol=0.01, err_msg=f"{tax} {year}", ) + + +# Invariant 5: the raw dated switches and tables. + +RAW = ParameterNode( + "stamp_duty", + directory_path=str( + Path(policyengine_uk.__file__).parent / "parameters/gov/hmrc/stamp_duty" + ), +) +WALES_SDLT_ENDS = date(2018, 4, 1) # SI 2018/214 art 2(a) +SLICED_RATES_START = date(2016, 3, 17) # FA 2016 s127(12) +HIGHER_RATES_START = date(2016, 4, 1) # FA 2016 s128(5) +RELIEF_START = date(2017, 11, 22) # FA 2018 s41(8) +# FA 2003 s55(2) Table B as amended by FA 2016 s127, and Sch 5 para 2(3) +# Table B before and after it. +SLICED_RATES = ((0, 0.0), (150_000, 0.02), (250_000, 0.05)) +RENT_BEFORE = ((0, 0.0), (150_000, 0.01)) +RENT_FROM = ((0, 0.0), (150_000, 0.01), (5_000_000, 0.02)) +STATUTORY_DATES = (WALES_SDLT_ENDS, SLICED_RATES_START, HIGHER_RATES_START) +STATUTORY_DATES += (RELIEF_START,) +RAW_DAYS = sorted({d + timedelta(days=k) for d in STATUTORY_DATES for k in (-1, 0)}) +raw_dates = st.one_of( + st.dates(min_value=date(2015, 1, 1), max_value=date(2035, 12, 31)), + st.sampled_from(RAW_DAYS), +) + + +def _bands(scale): + return tuple(zip(map(float, scale.thresholds), map(float, scale.rates))) + + +def _check_raw_switches(effective_date): + stamp_duty = RAW(effective_date.isoformat()) + non_residential = stamp_duty.non_residential + before_sliced_rates = effective_date < SLICED_RATES_START + assert bool(stamp_duty.applies_in_wales) == (effective_date < WALES_SDLT_ENDS) + assert bool(non_residential.purchase_charged_on_whole_price) == before_sliced_rates + assert _bands(non_residential.purchase) == ( + TABLE_B if before_sliced_rates else SLICED_RATES + ) + assert _bands(non_residential.rent) == ( + RENT_BEFORE if before_sliced_rates else RENT_FROM + ) + purchase = stamp_duty.residential.purchase + assert bool(purchase.additional.higher_rates_apply) == ( + effective_date >= HIGHER_RATES_START + ) + assert (purchase.main.first.max > 0) == (effective_date >= RELIEF_START) + + +@pytest.mark.parametrize("effective_date", RAW_DAYS, ids=str) +def test_raw_switches_change_on_their_statutory_dates(effective_date): + _check_raw_switches(effective_date) + + +@settings(PROPERTY_SETTINGS, max_examples=60) +@given(raw_dates) +def test_raw_switches_hold_on_any_date(effective_date): + _check_raw_switches(effective_date) + + +# Invariants 6 and 7: before a surcharge or relief starts, the buyer pays the +# main rates. + +residential_prices = st.one_of( + st.integers(0, 300_000_000).map(lambda pence: pence / 100), + st.sampled_from( + [ + b + k + for b in (40_000, 125_000, 250_000, 300_000, 500_000, 925_000, 1_500_000) + for k in (-1, 0, 1) + ] + ), +) + + +def _residential_tax(purchase, price, buyer): + # The schedule sdlt_on_residential_property_transactions applies. + main_rates_tax = purchase.main.subsequent.calc(price) + if buyer == "first" and purchase.main.first.max > 0: + return np.where( + price <= purchase.main.first.max, + purchase.main.first.rate.calc(price), + main_rates_tax, + ) + if buyer == "additional" and purchase.additional.higher_rates_apply: + chargeable = np.where(price < purchase.additional.min, 0, price) + return purchase.additional.rate.calc(chargeable) + return main_rates_tax + + +@settings(PROPERTY_SETTINGS, max_examples=60) +@example(HIGHER_RATES_START - timedelta(days=1), [40_000, 300_000, 1_000_000]) +@example(RELIEF_START - timedelta(days=1), [200_000, 300_000, 500_000]) +@given( + st.dates(min_value=date(2015, 2, 12), max_value=RELIEF_START - timedelta(days=1)), + st.lists(residential_prices, min_size=1, max_size=8), +) +def test_raw_buyers_pay_the_main_rates_before_their_schedules_start( + effective_date, price_list +): + purchase = RAW(effective_date.isoformat()).residential.purchase + price = np.array(price_list, dtype=float) + main = _residential_tax(purchase, price, "main") + np.testing.assert_array_equal(_residential_tax(purchase, price, "first"), main) + if effective_date < HIGHER_RATES_START: + np.testing.assert_array_equal( + _residential_tax(purchase, price, "additional"), main + ) + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example([0, 40_000, 125_000, 200_000, 300_000, 500_000, 1_000_000, 1_600_000]) +@given(st.lists(residential_prices, min_size=1, max_size=10)) +def test_model_years_before_the_schedules_charge_the_main_rates(price_list): + years = [2015, 2016, 2017] + households = [] + for price in price_list: + for buyer in ("main", "first", "additional"): + households.append( + { + "region": "LONDON", + "main_residential_property_purchased": ( + 0 if buyer == "additional" else price + ), + "main_residential_property_purchased_is_first_home": ( + buyer == "first" + ), + "additional_residential_property_purchased": ( + price if buyer == "additional" else 0 + ), + } + ) + simulation = Simulation(situation=_situation(households, years)) + for year in years: + tax = _calculate(simulation, "stamp_duty_land_tax", year).reshape(-1, 3) + main, first, additional = tax.T + np.testing.assert_array_equal(first, main, err_msg=str(year)) + if year == 2015: + np.testing.assert_array_equal(additional, main, err_msg=str(year)) + + +# Invariant 8: non-residential SDLT never falls as the price rises. + +non_residential_prices = st.one_of( + st.integers(0, 300_000_000).map(lambda pence: pence / 100), + st.sampled_from( + [b + k for b, _ in TABLE_B for k in (-1, -0.5, 0, 0.5, 1) if b + k >= 0] + ), +) + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example([0, 150_000, 150_000.5, 250_000, 250_000.5, 500_000, 500_000.5]) +@given(st.lists(non_residential_prices, min_size=2, max_size=20)) +def test_non_residential_tax_never_falls_as_the_price_rises(price_list): + prices = sorted(price_list) + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + tax = _calculate( + simulation, "sdlt_on_non_residential_property_transactions", year + ) + assert np.all(np.diff(tax) >= -1e-6), (year, prices, tax) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index 0501fe143f..eb345f2fb5 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -6,7 +6,7 @@ class sdlt_liable(Variable): documentation = ( "Whether the household is liable for Stamp Duty Land Tax: it lives in " "England or Northern Ireland, or in Wales while HMRC charges the tax " - "there, and the tax has not been abolished." + "there, and no reform sets gov.hmrc.stamp_duty.abolish." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py index f549d45a97..97dd720bf9 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py @@ -6,9 +6,10 @@ class sdlt_on_non_residential_property_transactions(Variable): documentation = ( "Tax charge from purchase of non-residential property. When " "gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price " - "is true, the formula charges the rate of the band the price falls in " - "on the whole price, where a price falls in the highest band whose " - "threshold it exceeds. Otherwise it charges each band's rate on the " + "holds true, HMRC charges the whole price at the rate for the band " + "the price falls in: the highest band whose threshold the price " + "exceeds, or no band, and so no tax, when the price exceeds no " + "threshold. When it holds false, HMRC charges each band's rate on the " "slice of the price in that band." ) entity = Household @@ -24,10 +25,11 @@ def formula(household, period, parameters): thresholds = np.array(scale.thresholds, dtype=float) rates = np.array(scale.rates, dtype=float) # Each band covers prices that exceed its threshold and do not exceed - # the next one; a price of zero falls in the first band. + # the next one. A price that exceeds no threshold falls in no band. exceeds = np.asarray(price, dtype=float)[:, None] > thresholds[None, :] - band = max_(exceeds.sum(axis=1) - 1, 0) - whole_price_tax = rates[band] * price + band = exceeds.sum(axis=1) - 1 + whole_price_rate = where(band >= 0, rates[max_(band, 0)], 0) + whole_price_tax = whole_price_rate * price slice_tax = scale.calc(price) return where( non_residential.purchase_charged_on_whole_price, diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py index c9f4403b45..daf7a3a831 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py @@ -3,7 +3,12 @@ class sdlt_on_residential_property_transactions(Variable): label = "Stamp Duty on residential property" - documentation = "Tax charge from purchase of residential property" + documentation = ( + "Tax charge from purchase of residential property. HMRC charges a " + "purchase of an additional dwelling at the higher rates when " + "gov.hmrc.stamp_duty.residential.purchase.additional.higher_rates_apply " + "holds true, and at the main rates when it holds false." + ) entity = Household definition_period = YEAR value_type = float @@ -23,16 +28,16 @@ def formula(household, period, parameters): stamp_duty.residential.purchase.main.first.rate.calc(price), stamp_duty.residential.purchase.main.subsequent.calc(price), ) - # Tax on second-home purchases + # Tax on second-home purchases: the higher rates for additional + # dwellings once they apply, and the main rates before then. + additional = stamp_duty.residential.purchase.additional second_home_price = household( "additional_residential_property_purchased", period ) - price = where( - second_home_price < stamp_duty.residential.purchase.additional.min, - 0, - second_home_price, - ) - additional_residential_purchase_tax = ( - stamp_duty.residential.purchase.additional.rate.calc(price) + chargeable = where(second_home_price < additional.min, 0, second_home_price) + additional_residential_purchase_tax = where( + additional.higher_rates_apply, + additional.rate.calc(chargeable), + stamp_duty.residential.purchase.main.subsequent.calc(second_home_price), ) return main_residential_purchase_tax + additional_residential_purchase_tax diff --git a/policyengine_uk/variables/gov/wra/ltt_liable.py b/policyengine_uk/variables/gov/wra/ltt_liable.py index c6c9c3f267..25aaf4167a 100644 --- a/policyengine_uk/variables/gov/wra/ltt_liable.py +++ b/policyengine_uk/variables/gov/wra/ltt_liable.py @@ -11,7 +11,12 @@ class ltt_liable(Variable): entity = Household definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/wsi/2018/128/schedule" + reference = [ + # Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) + # Act 2017 s. 2, in force from 1 April 2018 under WSI 2018/34 art. 3. + "https://www.legislation.gov.uk/anaw/2017/1/section/2", + "https://www.legislation.gov.uk/wsi/2018/34/article/3/made", + ] def formula(household, period, parameters): country = household("country", period) From 6429896f9a2c0c45375c2a8c8f17c3d94c4dcc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:25:42 +0000 Subject: [PATCH 081/168] Hold pension relief on pay and keep the UC earnings slice in the old order Pension relief under net pay (FA 2004 s. 193(2)) and relief at source (s. 192(4)) is no Step 2 deduction the taxpayer can set against savings or dividends, so the least-tax search now holds min(pension relief, non-savings income) on non-savings income and shares only the rest. The income tax that Universal Credit deducts from earnings reads the old order's deductions, so more savings or dividends never raise it. Before 2027-28 the search adds the lines that an exclusion from earned income creates beside property income. non_savings_income_after_allowances comes from the allocation's own arithmetic, and earned_taxable_income never exceeds it, so float32 rounding in the relief added back leaves no residue. New tests: YAML cases for pension relief, the earned income add-back, a pension-exempting reform and the float32 residue; property tests for the UC slice, the search with exclusions and the Budget 2026 rate bound; tests that separate the fast path's supplied-net-income and Scottish starter rate checks. Docstrings name the actor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g --- changelog.d/2106.fixed.md | 2 +- .../least_tax_allocation.yaml | 145 ++++++++++- .../tests/policy/integration/income.yaml | 6 +- ..._2026_savings_property_rates_properties.py | 38 ++- .../tests/test_income_tax_allocation.py | 192 +++++++++++++- .../tests/test_uc_income_tax_properties.py | 141 +++++++++++ .../utils/income_tax_allocation.py | 235 +++++++++++------- policyengine_uk/utils/uc_income_tax.py | 60 +++-- .../received_allowances_earned_income.py | 2 +- .../received_allowances_savings_income.py | 9 +- .../earned_taxable_income.py | 16 +- .../non_savings_income_after_allowances.py | 28 +-- ...ss_relief_against_savings_and_dividends.py | 17 +- 13 files changed, 735 insertions(+), 156 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_income_tax_properties.py diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md index fb0a881aa8..92b771fb40 100644 --- a/changelog.d/2106.fixed.md +++ b/changelog.d/2106.fixed.md @@ -1 +1 @@ -The model now deducts the Personal Allowance, other allowances and trade loss relief from non-savings, savings and dividend income so that the taxpayer pays the least tax (ITA 2007 s. 25(2)). From 2027-28 it takes them off other income before property, savings and dividend income (s. 25(3A)). It previously always deducted them from non-savings income first, then savings, then dividends. +The model now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), where it previously always took them from non-savings income first. Pension relief stays on non-savings income, and the income tax that Universal Credit deducts from earnings still follows the order non-savings, savings, dividends. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 5065fd29fd..ba8b340e63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -1,8 +1,8 @@ # ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in # the way which will result in the greatest reduction in the taxpayer's -# liability to income tax." From 2027-28, s. 25(3A) (Finance Act 2026 s. 6) -# deducts them from income other than property, savings and dividend income -# first. Savings sit above non-savings income and dividends above savings +# liability to income tax." From 2027-28, the taxpayer deducts them from +# income other than property, savings and dividend income first (s. 25(3A), +# inserted by Finance Act 2026 s. 6). Savings sit above non-savings income and dividends above savings # (s. 16), and from 2027-28 property income sits just below savings (s. 16A). # 2026-27 and 2027-28: Personal Allowance £12,570, tapered away by £1 for each # £2 of adjusted net income above £100,000 (s. 35); basic rate limit £37,700 @@ -76,8 +76,8 @@ dividend_income: 200_000 trading_loss: 1_000 output: - # No other income, so s. 25(3A) leaves the choice between property and - # dividends to s. 25(2). Property income sits below dividends (s. 16A). + # No other income, so the taxpayer chooses between property and dividends + # (s. 25(2)); s. 25(3A) changes nothing. Property income sits below dividends (s. 16A). # Against property, the £1,000 of relief saves 24% on property income in # the basic rate band, and moves £1,000 of dividends down into the # ordinary rate band at 10.75%: a net saving of £132.50 over setting it @@ -210,9 +210,9 @@ employment_income: 50_000 dividend_income: 20_000 output: - # s. 25(3A) deducts the allowance from income other than property, - # savings and dividend income first, so all £12,570 comes off the pay, - # unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 + # The taxpayer deducts the allowance from income other than property, + # savings and dividend income first (s. 25(3A)), so all £12,570 comes off + # the pay, unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 # at the nil rate and (57,430 - 37,930) x 35.75% = 6,971.25. received_allowances_earned_income: 12_570 received_allowances_dividend_income: 0 @@ -264,8 +264,8 @@ # The model gives the recipient a Marriage Allowance of £1,260 from the # partner's unused Personal Allowance: 10% of £12,570, rounded up to a # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax - # reduction (s. 55B(1)), which Step 6 of s. 23 deducts, not as a deduction - # at Step 3, so the model leaves it out of the share. Pay, property income + # reduction (s. 55B(1)), which the taxpayer deducts at Step 6 of s. 23, + # not as a deduction at Step 3, so the model leaves it out of the share. Pay, property income # and the savings above the £1,000 savings allowance all bear 20%, and the # pay and property income leave no starting rate band (s. 12), so no share # lowers the Step 5 tax, and the model takes the Personal Allowance off the @@ -276,3 +276,128 @@ received_allowances_earned_income: [12_570, 0] received_allowances_savings_income: [0, 0] income_tax_pre_charges: [4_286, 0] + +- name: Case 14, net pay pension relief stays on pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + employee_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes employee contributions as a net pay arrangement, which + # the employer deducts from employment income (FA 2004 s. 193(2)). The + # taxpayer can't set that relief against savings, so it stays on the + # pay, and the pay left for the share is £30,000, as if the person had + # pay of £30,000 and no pension. Adjusted net income of £60,000 leaves the + # full £12,570 allowance and a £500 savings allowance (s. 12B). Against + # pay: pay 17,430 x 20% = 3,486; savings from £17,430 to £47,430: £500 at + # the nil rate, (37,700 - 17,930) x 20% = 3,954 and 9,730 x 40% = 3,892. + # Every share keeps the savings allowance below the £37,700 limit, where + # it saves 20%, so no share lowers the tax of 11,332. Sharing the pension + # relief too would put the pay at the limit and charge £100 less. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + earned_taxable_income: 17_430 + earned_income_tax: 3_486 + savings_income_tax: 7_846 + income_tax: 11_332 + +- name: Case 15, relief at source on personal pension contributions stays on non-savings income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + personal_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes personal contributions as relief at source, which + # extends the rate bands (FA 2004 s. 192(4)) rather than giving a Step 2 + # deduction the taxpayer can set against savings. The model deducts the + # relief from non-savings income, so the tax matches case 14. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + income_tax: 11_332 + +- name: Case 16, trading loss relief set against savings leaves the pay in earned income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + savings_interest_income: 30_000 + trading_loss: 10_000 + output: + # Net income of £60,000 leaves the full allowance and a £500 savings + # allowance. The £10,000 of relief (s. 64) and the £12,570 allowance + # share £22,570. The taxpayer sets £2,300 of the relief against pay, which + # leaves pay at the £37,700 limit, and the other £7,700 and the whole + # allowance against savings (s. 25(2)): savings of £9,730 from £37,700, + # £500 at the nil rate above the limit, where it saves 40%. Pay + # 37,700 x 20% = 7,540; savings 9,230 x 40% = 3,692. The relief against + # pay first charges 11,332. net_income deducts all the relief, so earned + # taxable income adds back the £7,700 set against savings. + trade_loss_relief_against_general_income: 10_000 + trade_loss_relief_against_savings_and_dividends: 7_700 + received_allowances_earned_income: 0 + received_allowances_savings_income: 20_270 + earned_taxable_income: 37_700 + non_savings_income_after_allowances: 37_700 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 17, under a reform that exempts pensions the search stops pay at the exemption + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.earned_taxable_income_exclusions: + - taxable_savings_interest_income + - taxable_dividend_income + - taxable_property_income + - received_allowances_earned_income + - marriage_allowance + - taxable_pension_income + age: 40 + employment_income: 5_000 + private_pension_income: 10_000 + savings_interest_income: 30_000 + output: + # The reform takes the £10,000 pension out of earned income, so the + # model taxes non-savings income only above it. Net income of £45,000 + # leaves the full allowance and a £1,000 savings allowance. With £5,000 of + # the allowance against pay and pension, the £10,000 left is all pension, + # earned income is nil, and the savings start at nil: £22,430 of savings, + # £5,000 at the starting rate (s. 12), £1,000 at the savings nil rate + # (s. 12A) and 16,430 x 20% = 3,286. Setting more against savings makes + # each pound of pay taxable at 20% and shrinks the starting rate band by + # a pound; setting less leaves a pound more of savings at 20%. All the + # allowance against non-savings income first charges + # (30,000 - 6,000) x 20% = 4,800. + received_allowances_earned_income: 5_000 + received_allowances_savings_income: 7_570 + earned_taxable_income: 0 + savings_income_tax: 3_286 + income_tax: 3_286 + +- name: Case 18, trading loss relief past the non-savings income leaves none of it + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + self_employment_income: 30_016.63 + savings_interest_income: 27_285.95 + dividend_income: 74_747.51 + trading_loss: 33_973.11 + output: + # The £33,973.11 of relief exceeds the £30,016.63 of trading income, so + # no non-savings income is left, and the rest of the relief and the + # allowances come off savings and dividends. The model computes the non-savings income left from the + # share's own arithmetic, so float32 rounding in net income plus the + # relief added back leaves nothing above nil. + non_savings_income_after_allowances: 0 + earned_taxable_income: 0 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 55afb8a955..eb2c734fde 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -25,8 +25,10 @@ # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings # that would otherwise bear 40% rather than 20%: £100 less tax # than the allowance all against the pay. Savings of £5,000 run from - # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting - # more against the pay or the savings would not change the tax. + # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%, so the tax + # is 7,500 + 1,800 = 9,300. Setting more against the savings leaves it at + # 9,300; setting £500 more against the pay would bring the savings + # allowance below the limit and charge 9,400. received_allowances_savings_income: 10_000 basic_rate_savings_income_pre_starter: 0 savings_starter_rate_income: 0 diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 1de32e9688..4833c90e2f 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -28,7 +28,12 @@ The order of the allowances can change too. A Scottish taxpayer's property income stays on the Scottish rates while the savings rates rise, so the allowance can save more against savings, and then both taxes move -(test_higher_savings_rates_can_move_the_allowance_onto_savings). +(test_higher_savings_rates_can_move_the_allowance_onto_savings). For everyone, +whatever the share, income tax rises by at most 2% of the banded savings and +taxable property income under the Budget 2025 rates: the Budget 2025 share +costs at most that much more at the higher rates, and the model takes a share +that costs no more than it +(test_income_tax_rises_by_at_most_two_points_of_banded_income). """ import numpy as np @@ -62,12 +67,12 @@ st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings ) cases = st.lists(case, min_size=CASES, max_size=CASES) -# Review round 2's counterexample: the fourth person (Scotland) gives up the -# property allowance at the higher rates. +# The fourth person, a Scottish taxpayer, gives up the property allowance at +# the higher rates. ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) -# The first property run's counterexample: the fourth person (Scotland) sets -# the allowance against savings at the higher rates. +# The fourth person, a Scottish taxpayer, sets the allowance against savings at +# the higher rates. ORDER_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ORDER_CHANGE[3] = (0.0, 58_000.0, 0.0, 24_000.0) @@ -116,6 +121,12 @@ def sims(): "property_finance_cost_relief", "income_tax", ] +BANDED = [ + "basic_rate_savings_income", + "higher_rate_savings_income", + "add_rate_savings_income", + "taxable_property_income_after_allowances", +] def run(sims, drawn): @@ -193,6 +204,23 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): assert (np.abs(rise - RISE * banded) <= tol)[same].all() +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +@example(ORDER_CHANGE) +def test_income_tax_rises_by_at_most_two_points_of_banded_income(sims, drawn): + """Under the Budget 2025 share and property allowance election, the Budget + 2026 rates add 2% of the savings in the rate bands and, outside Scotland, + of the taxable property income, and the finance cost relief never falls. + The model's share and election give no more tax than that one (ITA 2007 + s. 25(2)), so the bound holds for everyone, including people whose share + the rates move.""" + law, before = run(sims, drawn) + bound = before["income_tax"] + RISE * sum(before[name] for name in BANDED) + tol = tolerance(law["income_tax"], bound) + assert (law["income_tax"] <= bound + tol).all() + + @SETTINGS @given(cases) @example(ELECTION_CHANGE) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 15221cf83c..5502554d4c 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -3,7 +3,7 @@ s. 25(3A); #2106). The invariants, for every person without Marriage Allowance, on the Step 5 -tax (income_tax_pre_charges), which is all the share changes: +tax (income_tax_pre_charges), the only tax the share changes: 1. Least tax: no share of the deductions between the components s. 25 lets them come off gives less Step 5 tax than the model's. A brute-force oracle @@ -288,12 +288,12 @@ def test_tax_equals_brute_force_grid_minimum(sims, year, region, incomes): model_tax = law["income_tax_pre_charges"][0] least = grid_tax[feasible].min() step = min(savings, used) / (GRID - 1) + min(dividends, used) / (GRID - 1) - # Invariant 1: the model's share is the least; the grid comes within its + # Invariant 1: the model's share gives the least tax; the grid comes within its # step of it, since a pound moved between components changes the tax by # less than a pound. assert model_tax <= least + tolerance(model_tax) assert model_tax >= least - step - tolerance(model_tax) - # Invariant 5: the search's tax function is the model's Step 5 tax. + # Invariant 5: the search's tax function equals the model's Step 5 tax. forced_c = _context(forced.populations["person"], year, parameters_of(forced)) x = (forced_c.step_3_income - forced_c.savings - forced_c.dividends)[:, 0] x = x + to_savings + to_dividends @@ -394,11 +394,13 @@ def system(): return uk_simulation.CountryTaxBenefitSystem() -def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free"): +def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free", exclusions=False): """Inputs for ``least_tax_deductions`` and the old order's deductions. "free" draws every amount from a wide mixture with values at the limits; - "realistic" gives most people pay alone and some savings or dividends.""" + "realistic" gives most people pay alone and some savings or dividends. + With ``exclusions``, half the people get an exclusion from earned income + of up to £20,000 in place of the Marriage Allowance's £1,260.""" rng = np.random.default_rng(seed) def amount(zero, scale): @@ -433,6 +435,8 @@ def amount(zero, scale): gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) scottish = rng.random(count) < 0.3 savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) + pension = np.where(rng.random(count) < 0.2, rng.uniform(0, 20_000, count), 0) + exclusion_draw = np.where(rng.random(count) < 0.5, rng.uniform(0, 20_000, count), 0) rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) other, property_income, savings, dividends, relief, extra, marriage, gift = ( np.concatenate([column, rows[:, i]]) @@ -451,12 +455,16 @@ def amount(zero, scale): ) scottish = np.concatenate([scottish, rows[:, 8] > 0]) savings_allowance = np.concatenate([savings_allowance, rows[:, 9]]) + pension = np.concatenate([pension, np.zeros(len(rows))]) + if exclusions: + # A reform's exclusion from earned income, such as an exempt pension. + marriage = np.concatenate([exclusion_draw, rows[:, 6]]) f32 = np.float32 before_relief = (other + property_income + savings + dividends).astype(f32) relief = relief.astype(f32) net_income = np.maximum(0, before_relief - relief) personal_allowance = np.maximum(0, 12_570 - np.maximum(0, net_income - 100_000) / 2) - allowances = (personal_allowance + extra).astype(f32) + allowances = (personal_allowance + extra + pension).astype(f32) savings, dividends = savings.astype(f32), dividends.astype(f32) default = old_order( allowances, savings, dividends, net_income, relief, before_relief @@ -468,6 +476,7 @@ def amount(zero, scale): net_income=net_income, property=property_income.astype(f32), allowances=allowances, + pension=pension.astype(f32), earned_exclusions=marriage.astype(f32), gift=gift.astype(f32), scottish=scottish, @@ -552,3 +561,174 @@ def test_fast_path_equals_full_search_when_rates_fall(system, year): for path, value in old.items(): parameters.get_child(path).update(period=str(year), value=value) system.reset_parameter_caches() + + +def vertex_search_and_grid(income_tax, inputs, points=101): + """The search's least Step 5 tax and a dense grid's least, per person, + with the grid's step.""" + c = _build(inputs, income_tax) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, points) + grid_x = (low + (high - low) * fractions[None, :]).repeat(points, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], points) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / (points - 1) + c.savings / (points - 1))[:, 0] + return found, grid, step + + +@pytest.mark.parametrize("year", [2018, 2026, 2027]) +def test_vertex_search_equals_dense_grid_with_exclusions(system, year): + """Invariant 6 on synthetic people with exclusions from earned income of + up to £20,000 beside property income, as a reform that exempts pensions + gives. Before 2027-28 the exclusion splits the first component into three + pieces, each with its own lines.""" + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + for seed in range(2): + inputs, _ = synthetic_people(seed, count=300, exclusions=True) + found, grid, step = vertex_search_and_grid(income_tax, inputs) + worst = np.argmax(found - grid) + assert np.all(found <= grid + 1e-5), { + name: values[worst] for name, values in inputs.items() + } | {"found": found[worst], "grid": grid[worst]} + assert np.all(found >= grid - 2 * step - 1e-5) + + +def one_person(**amounts): + """``least_tax_deductions`` inputs for one person, and the old order's + deductions, computed as the model computes them from net income and net + income before trade loss relief.""" + values = dict( + savings=0.0, + dividends=0.0, + relief=0.0, + net_income=0.0, + property=0.0, + allowances=12_570.0, + pension=0.0, + earned_exclusions=0.0, + gift=0.0, + savings_allowance=1_000.0, + ) + before_relief = amounts.pop("before_relief", None) + scottish = amounts.pop("scottish", False) + values.update(amounts) + if before_relief is None: + before_relief = values["net_income"] + values["relief"] + inputs = { + name: np.array([value], dtype=np.float64) for name, value in values.items() + } + inputs["scottish"] = np.array([scottish]) + f32 = np.float32 + default = old_order( + *( + np.array([value], dtype=f32) + for value in ( + values["allowances"], + values["savings"], + values["dividends"], + values["net_income"], + values["relief"], + before_relief, + ) + ) + ) + return inputs, default + + +def tax_saved(income_tax, inputs, default): + """The Step 5 tax under the old order less the tax under the full search's + share, in the search's own tax function.""" + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + c = _build(inputs, income_tax) + + def tax(to_savings, to_dividends): + s = c.savings - np.asarray(to_savings, dtype=float)[:, None] + v = c.dividends - np.asarray(to_dividends, dtype=float)[:, None] + return step_5_tax(c, c.step_3_income - s - v, s, v)[0, 0] + + return tax(default.savings, default.dividends) - tax(*full) + + +@pytest.mark.parametrize( + "amounts, saving", + [ + # Savings of £1,000 the only income in net income before the relief, + # with £1,000 of trade loss relief, and a supplied adjusted net income + # that puts net income at £30,000. The old order sets the relief + # against the savings and leaves £17,430 of non-savings income at + # 20%; setting it against non-savings income instead leaves £16,430, + # and the £1,000 savings allowance covers the savings: £200 less. + ( + dict(savings=1_000, relief=1_000, net_income=30_000, before_relief=1_000), + 200, + ), + # Savings of £300 and £300 of relief, with net income supplied at + # £13,070: the old order leaves £500 of non-savings income at 20%, the + # search £200, with the savings in the savings allowance: £60 less. + (dict(savings=300, relief=300, net_income=13_070, before_relief=300), 60), + ], + ids=["relief-against-savings", "all-savings-used"], +) +def test_fast_path_follows_a_supplied_net_income(system, amounts, saving): + """Where a supplied adjusted net income sets net income apart from net + income before trade loss relief less the relief, the old order can set + relief against savings while non-savings income remains. The fast path + must send those people to the search.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(**amounts) + assert tax_saved(income_tax, inputs, default) == pytest.approx(saving, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + + +def test_fast_path_reads_the_scottish_starter_rate(system): + """A reform that lifts the top of the Scottish starter band to £9,000, + above the £5,000 starting rate limit for savings. A Scottish taxpayer with + £18,570 of pay and £20,000 of savings: the old order leaves £6,000 of pay + at the 19% starter rate, and the savings above it, past the starting rate + band, bear 20%. Moving £3,000 of the allowance to savings takes the pay to + £9,000 and saves 1% of £3,000, £30. The fast path must read the Scottish + rate on the pay, not the UK basic rate.""" + parameters = system.parameters + path = "gov.hmrc.income_tax.rates.scotland.rates[1].threshold" + parameter = parameters.get_child(path) + before = parameter("2026") + try: + parameter.update(period="2026", value=9_000) + system.reset_parameter_caches() + income_tax = parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(savings=20_000, net_income=38_570, scottish=True) + assert tax_saved(income_tax, inputs, default) == pytest.approx(30, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + parameter.update(period="2026", value=before) + system.reset_parameter_caches() + + +def test_pension_relief_stays_on_non_savings_income(system): + """Pay of £40,000 with £10,000 of pension relief and £30,000 of savings in + 2026-27. The relief stays on the pay (FA 2004 ss. 192(4) and 193(2)), so + only the £12,570 allowance moves, and every share keeps the savings + allowance below the basic rate limit: the old order stands. Sharing the + pension relief would put the pay at the limit and save £100.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person( + savings=30_000, + net_income=70_000, + allowances=22_570, + pension=10_000, + savings_allowance=500, + ) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + assert full[0][0] == 0 + assert tax_saved(income_tax, inputs, default) == 0 + shared = dict(inputs, pension=np.array([0.0])) + assert tax_saved(income_tax, shared, default) == pytest.approx(100, abs=1e-6) diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py new file mode 100644 index 0000000000..1600b44762 --- /dev/null +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -0,0 +1,141 @@ +"""Property tests for the income tax that Universal Credit deducts from +earnings (uc_income_tax_on_earnings and uc_income_tax_on_trade). + +The helper takes earnings as the lowest slice of non-savings income, after the +allowances set against non-savings income first, as PAYE codes set them +against the job. The least-tax share of the reliefs and allowances (ITA 2007 +s. 25(2)) can move the allowances onto savings or dividends to lower the +year's tax, but the slice follows the order non-savings, savings, dividends, +so tax on other income never comes off earnings: + +- adding savings or dividend income never raises either slice, for every + person whose allowances it leaves unchanged (more income can taper the + Personal Allowance, which raises the tax on earnings for a reason of its + own). +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +PEOPLE = 8 +YEARS = (2025, 2026, 2027, 2028) +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "self_employment_income", + "property_income", + "savings_interest_income", + "dividend_income", + "trading_loss", +) +SLICES = ("uc_income_tax_on_earnings", "uc_income_tax_on_trade") + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=80_000), + st.sampled_from([5_000.0, 12_570.0, 37_406.0, 37_700.0, 50_270.0]), +) +person = st.tuples(*([amount] * len(INCOMES))) +extra = st.tuples(amount, amount) # savings and dividends added +people = st.lists(st.tuples(person, extra), min_size=PEOPLE, max_size=PEOPLE) +# Self-employment income of £37,406: £14,407 of savings would move the whole +# Personal Allowance onto the savings under the least-tax share. +WITNESS = [((0.0, 37_406.0, 0.0, 0.0, 0.0, 0.0), (14_407.0, 0.0))] + [ + ((0.0,) * len(INCOMES), (0.0, 0.0)) +] * (PEOPLE - 1) + + +def situation(year): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(PEOPLE)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(PEOPLE)}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": {year: REGIONS[i % len(REGIONS)]}, + } + for i in range(PEOPLE) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + cache = {} + + def get(year): + if year not in cache: + cache[year] = Simulation(situation=situation(year)) + return cache[year] + + return get + + +def run(sim, year, incomes): + for name, values in incomes.items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return { + name: sim.calculate(name, year).astype(float) + for name in SLICES + ("allowances", "income_tax") + } + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.sampled_from(YEARS), people) +@example(2025, WITNESS) +@example(2026, WITNESS) +def test_more_savings_or_dividends_never_raise_tax_on_earnings(sims, year, drawn): + base_people, added = zip(*drawn) + incomes = { + name: np.array(values) for name, values in zip(INCOMES, zip(*base_people)) + } + savings, dividends = (np.array(values) for values in zip(*added)) + sim = sims(year) + before = run(sim, year, incomes) + more = dict(incomes) + more["savings_interest_income"] = incomes["savings_interest_income"] + savings + more["dividend_income"] = incomes["dividend_income"] + dividends + after = run(sim, year, more) + same_allowances = np.abs(after["allowances"] - before["allowances"]) <= 0.01 + for name in SLICES: + rise = after[name] - before[name] + tolerance = 0.01 + 1e-6 * np.abs(before[name]) + assert (rise <= tolerance)[same_allowances].all(), ( + name, + rise[same_allowances].max(), + ) + + +def test_savings_leave_the_tax_on_self_employment_unchanged(sims): + """Self-employment income of £37,406, alone and with £14,407 of savings, + in 2026-27. With the savings, the least-tax share sets the whole Personal + Allowance against them, and income tax falls from £8,057.20 under the + order non-savings, savings, dividends to £8,016. The tax on the trade + stays at (37,406 - 12,570) x 20% = £4,967.20, as without the savings.""" + year = 2026 + sim = sims(year) + zeros = np.zeros(PEOPLE) + incomes = {name: zeros.copy() for name in INCOMES} + incomes["self_employment_income"][0] = 37_406 + alone = run(sim, year, incomes) + incomes["savings_interest_income"][0] = 14_407 + with_savings = run(sim, year, incomes) + assert alone["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_earnings"][0] == pytest.approx( + 4_967.2, abs=0.01 + ) + assert with_savings["income_tax"][0] == pytest.approx(8_016, abs=0.01) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index d699e7d4f3..71cbd784d4 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -1,76 +1,98 @@ """Set Income Tax reliefs and allowances against the components of income. -At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), s. 25(2) +At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), the taxpayer deducts reliefs and allowances "in the way which will result in the greatest -reduction in the taxpayer's liability to income tax". From 2027-28, s. 25(3A) -(Finance Act 2026 s. 6(6) and (8)) deducts them from income other than -property, savings and dividend income first, and s. 25(2) then chooses between -property, savings and dividend income. Before 2027-28, s. 25(2) chooses between -non-savings, savings and dividend income. Property income is non-savings income -taxed at the same rates in those years, so the deductions come off the rest of -non-savings income first, as they always have in the model. +reduction in the taxpayer's liability to income tax" (s. 25(2)). From +2027-28, the taxpayer deducts them from income other than property, savings +and dividend income first (s. 25(3A), inserted by Finance Act 2026 s. 6(6) +with effect from 2027-28 under s. 6(8)), and then chooses between property, +savings and dividend income (s. 25(2)). Before 2027-28, the taxpayer chooses +between non-savings, savings and dividend income (s. 25(2)). In those years +Parliament taxes property income as non-savings income at the same rates, so +the model takes the deductions off the rest of non-savings income first, as +it always has. The model deducts trade loss relief against general income at Step 2 and the -amounts in ``allowances`` (the personal and blind person's allowances, gifts of -investments to charity, covenanted payments, other deductions and pension -relief). Together they are one sum to share between the components. Each part -of it can come off any component, and the tax depends only on how much comes -off each, so the search shares out the sum. +amounts in ``allowances`` (the personal and blind person's allowances, gifts +of investments to charity, covenanted payments, other deductions and pension +relief). The search shares their sum between the components. Each part of it +can come off any component, and the tax depends only on how much comes off +each, so the search shares out the sum. + +Pension relief is the exception. The model takes employee contributions as a +net pay arrangement, which the employer deducts from employment income (FA +2004 s. 193(2)), and personal contributions as relief at source, which +extends the rate bands (s. 192(4)). Neither is a Step 2 deduction the +taxpayer can set against savings or dividends, so the search holds +``min(pension relief, non-savings income)`` on non-savings income and shares +only the rest. How the search finds the least tax ---------------------------------- Three components share what s. 25(3A) leaves: the first (non-savings income -before 2027-28, property income after), savings and dividends. Write ``x`` for -the first component left after deductions, ``w`` for ``x`` plus the savings -left, and ``T`` for the three together after deductions, which the sharing -does not change. The dividends left are ``T - w``. Savings sit above -non-savings income and dividends above savings (ss. 16 and 16A), so with ``q`` -the non-savings income below the savings (``x``, or property plus the other -non-savings income left before it from 2027-28): +before 2027-28, property income after), savings and dividends. Write ``x`` +for the first component left after deductions, ``w`` for ``x`` plus the +savings left, and ``T - w`` for the dividends left, where ``T``, the three +together after deductions, stays the same in every share. Savings sit above +non-savings income and dividends above savings (ss. 16 and 16A), so with +``q`` the non-savings income below the savings (``x``, or property plus the +other non-savings income left before it from 2027-28): - tax on the first component changes slope where ``x`` crosses a rate limit; -- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) cover - the savings from ``q`` up to ``max(q, L) + A``, where ``L`` is the starting - rate limit and ``A`` the personal savings allowance. That changes slope where - ``q`` reaches ``L``, where ``max(q, L) + A`` crosses a rate limit, and where - ``w`` reaches the top of that band: ``w - x = A`` above ``L``, ``w`` constant - below it; +- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) + cover the savings from ``q`` up to ``max(q, L) + A``, where ``L`` stands + for the starting rate limit and ``A`` for the personal savings allowance. + That changes slope where ``q`` reaches ``L``, where ``max(q, L) + A`` + crosses a rate limit, and where ``w`` reaches the top of that band: + ``w - x = A`` above ``L``, ``w`` constant below it; - the rest of the savings change slope where their top, ``w``, crosses a rate limit; -- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` of - the dividends, so their tax changes slope where ``w + DA`` crosses a dividend - rate limit or reaches ``T``. +- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` + of the dividends, so their tax changes slope where ``w + DA`` crosses a + dividend rate limit or reaches ``T``. The limits on the components add ``x = 0`` and ``x`` equal to the whole first component, savings left from nil to all (``w - x = 0`` and ``w - x`` equal to -the savings), and dividends left from nil to all (``w = T`` and ``w = T`` less -the dividends). So the Step 5 tax is continuous and linear on each piece of the -feasible polygon cut out by vertical lines (``x`` constant), horizontal lines -(``w`` constant) and diagonal lines (``w - x`` constant). A linear function -takes its least value on a polygon at a corner, so the least tax is at a point -where two of these lines cross. ``_candidates`` lists every such crossing, and -the search evaluates the tax at all of them for all people at once. - -That argument needs the starting rate limit at or below the basic rate limit -and a phase-out of one pound of starting rate band for each pound of -non-savings income (s. 12), as in every year modelled. The search keeps the +the savings), and dividends left from nil to all (``w = T`` and ``w = T`` +less the dividends). So the Step 5 tax runs continuous and linear on each +piece of the feasible polygon that vertical lines (``x`` constant), +horizontal lines (``w`` constant) and diagonal lines (``w - x`` constant) +cut. A linear function takes its least value on a polygon at a corner, so the +least tax lies where two of these lines cross. ``_candidates`` lists every +such crossing, and the search evaluates the tax at all of them for all people +at once. + +Before 2027-28, a reform can add an exclusion ``E`` from earned income. With +property income ``P``, the savings then sit on ``q = x`` up to ``x = P``, on +``q = P`` up to ``x = P + E``, and on ``q = x - E`` beyond. ``_candidates`` +adds the lines of each of those pieces: the two boundaries, the limits moved +by ``P`` and ``E``, and the diagonal lines that a fixed ``q = P`` gives. + +That argument needs the starting rate limit at or below the basic rate limit, +a phase-out of one pound of starting rate band for each pound of non-savings +income (s. 12) and a first UK rate threshold at nil, as in every year +modelled. A reform that sets that threshold above nil leaves savings below it +untaxed and adds lines on which ``x + w`` stays constant, which the search +doesn't list, so the search can then miss the least tax; the fast path sends +everyone to the search under such a reform. The search keeps the order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. The search ignores the Marriage Allowance. Parliament gives it as a tax -reduction (s. 55B(1)), which Step 6 deducts, not as a deduction at Step 3, and -whether a person qualifies for it (s. 55B(2)(b)) depends on the share. The -model still subtracts the allowance from earned income (#1947), which moves a -recipient's savings and dividends down by up to that amount, so for a -recipient the search's tax can differ from the model's. +reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a +deduction at Step 3, and whether a person qualifies for it (s. 55B(2)(b)) +depends on the share. The model still subtracts the allowance from earned +income (#1947), which moves a recipient's savings and dividends down by up to +that amount, so for a recipient the search's tax can differ from the model's. Who cannot gain --------------- Most people cannot gain from another share, and ``_old_order_is_least`` finds them without a search, so the search runs only on the rest. -1. One share: with no deduction left for s. 25(2) to place, or no income left - after the deductions, only one share exists, and the old order takes it. +1. One share: with no deduction left for the taxpayer to place under + s. 25(2), or no income left after the deductions, only one share exists, + and the old order takes it. Tests 2 and 3 need the rates to rise with income in every scale, as Parliament has set them, and the old order to set every shared deduction against the @@ -88,18 +110,18 @@ moved pounds can reach above the nil rates. The tax then rises by at least ``(rho_n - sigma_s) dx + (rho_s - sigma_v) dw`` for any move with ``0 <= dw <= dx``, or ``rho_n dx - sigma_v dw`` where the old order charges - no savings above the nil rates. Where that is never negative, the old order - gives the least tax. The top of the nil rates moves by at most ``dx``, - because the starting rate band shrinks by at most a pound for each pound of - non-savings income (s. 12) and the savings and dividend nil rates cover - fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no - property income or no exclusion from earned income that the search reads: - with property income, such an exclusion breaks the rising rate on the - first component. + no savings above the nil rates. Where that bound never falls below zero, + the old order gives the least tax. The top of the nil rates moves by at + most ``dx``, because the starting rate band shrinks by at most a pound for + each pound of non-savings income (s. 12) and the savings and dividend nil + rates cover fixed amounts (ss. 12A and 13A). Before 2027-28 the test also + needs no property income or no exclusion from earned income that the + search reads: with property income, such an exclusion breaks the rising + rate on the first component. Where any test holds, the search would keep the old order too, since it -changes the share only for a saving above ``TOLERANCE``. So the result is the -same, and ``test_income_tax_allocation.py`` checks that it is. +changes the share only for a saving above ``TOLERANCE``. So both paths give +the same result, and ``test_income_tax_allocation.py`` checks it. """ from types import SimpleNamespace @@ -111,7 +133,8 @@ # A share replaces the order non-savings, savings, dividends only where it # saves more than this (in pounds), so rounding never moves a deduction. TOLERANCE = 1e-3 -# Shares whose tax differs by less than this (in pounds) are equally good. +# The search treats shares whose tax differs by less than this (in pounds) +# as equally good. TIE = 1e-6 # Bound on people times candidates evaluated at once, to bound memory. CHUNK = 2_000_000 @@ -167,6 +190,7 @@ def values(variable): net_income=values("net_income"), property=values("taxable_property_income"), allowances=values("allowances"), + pension=values("pension_contributions_relief"), earned_exclusions=sum( (values(variable) for variable in exclusions), np.zeros_like(savings) ), @@ -192,8 +216,18 @@ def _build(inputs, income_tax): # net_income, not its components, so the search follows a supplied adjusted # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) + # Net pay comes off employment income (FA 2004 s. 193(2)) and relief at + # source extends the bands (s. 192(4)), so pension relief stays on + # non-savings income as far as it reaches; the search shares the rest. + # allowances includes the relief, so it never holds more than that. + pension = np.clip( + columns.get("pension", np.zeros_like(relief)), + 0, + np.minimum(np.maximum(0, columns["allowances"]), non_savings), + ) + non_savings = non_savings - pension property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) - deductions = relief + columns["allowances"] + deductions = relief + columns["allowances"] - pension used = np.minimum(deductions, non_savings + savings + dividends) gift = columns["gift"] uk_thresholds = rates.uk.thresholds @@ -334,9 +368,10 @@ def step_5_tax(c, x, s, v): def _candidates(c): """The points where two of the lines the module docstring lists cross.""" zero = np.zeros_like(c.savings) - offset = c.offset low, basic_limit, higher_limit = c.bands + band_limits = (basic_limit, higher_limit) allowance = c.savings_allowance + dividend_allowance = c.dividend_allowance if c.other_first: first_limits = [ np.where(c.scottish, scottish, ruk) - c.other_left @@ -349,22 +384,40 @@ def _candidates(c): np.where(c.scottish, scottish, ruk) for scottish, ruk in _paired_limits(c.scotland[0], c.uk[0], zero) ] - vertical = [zero, c.first, c.starting_limit - offset + zero] - vertical += [limit - allowance - offset for limit in (basic_limit, higher_limit)] - vertical += first_limits total = c.step_3_income - horizontal = [ - total - c.dividends, - total, - total - c.dividend_allowance, - c.starting_limit + allowance - offset, - basic_limit - offset, - higher_limit - offset, - ] - horizontal += [ - limit - c.dividend_allowance - offset + zero for limit in c.dividend_scale[0] - ] + vertical = [zero, c.first] + first_limits + horizontal = [total - c.dividends, total, total - dividend_allowance] diagonal = [zero, c.savings, allowance] + # The non-savings income below the savings is x plus each offset. + offsets = [c.offset] + if not c.other_first and np.any(c.earned_exclusions > 0): + # Before 2027-28 an exclusion E from earned income splits x into three + # pieces: q = x up to the property income P, q = P up to P + E, and + # q = x - E beyond. Tax on the property income changes slope where x + # or x - P crosses a limit, and tax on earned income where x - P - E + # does. With q fixed at P, the savings band and dividend lines run + # diagonal, and the top of the dividends moves with x. + property_income, exclusion = c.property, c.earned_exclusions + offsets.append(-exclusion) + vertical += [property_income, property_income + exclusion] + vertical += [property_income + limit for limit in first_limits] + vertical += [property_income + exclusion + limit for limit in first_limits] + vertical += [property_income + total - limit for limit in c.dividend_scale[0]] + starting = np.maximum(0, c.starting_limit - property_income * c.phase_out) + diagonal += [limit - property_income for limit in band_limits] + diagonal += [starting + allowance] + diagonal += [ + limit - dividend_allowance - property_income + for limit in c.dividend_scale[0] + ] + for offset in offsets: + vertical += [c.starting_limit - offset + zero] + vertical += [limit - allowance - offset for limit in band_limits] + horizontal += [c.starting_limit + allowance - offset] + horizontal += [limit - offset + zero for limit in band_limits] + horizontal += [ + limit - dividend_allowance - offset + zero for limit in c.dividend_scale[0] + ] xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) @@ -557,8 +610,8 @@ def _old_order_is_least(c, to_savings, to_dividends): def allocate_deductions(person, period, parameters): """Deductions against savings and dividend income in the share that gives the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less - tax than the order non-savings, savings, dividends, these are that order's - deductions exactly.""" + tax than the order non-savings, savings, dividends, the function returns + that order's deductions exactly.""" default = default_order_deductions(person, period) income_tax = parameters(period).gov.hmrc.income_tax savings, dividends = least_tax_deductions( @@ -604,13 +657,17 @@ def least_tax_deductions( def non_savings_deductions(person, period): - """Allowances against non-savings income beyond trade loss relief, and the - relief set against savings and dividends, once received_allowances_savings_income - and received_allowances_dividend_income have taken their share. + """Allowances against non-savings income beyond trade loss relief, the + relief set against savings and dividends, and the non-savings income left + after the deductions, once received_allowances_savings_income and + received_allowances_dividend_income have taken their share. The relief comes off non-savings income first, as far as the share sets - anything against it, then the allowances. Where the share is the order - non-savings, savings, dividends, these are that order's amounts exactly. + anything against it, then the allowances. Where the share keeps the order + non-savings, savings, dividends, the function returns that order's + amounts exactly. Elsewhere it takes the non-savings income left from the + share's own arithmetic, in float64, so float32 rounding in a sum of + net income and the relief added back leaves no residue above nil. """ default = default_order_deductions(person, period) to_savings = person("received_allowances_savings_income", period) @@ -621,13 +678,14 @@ def non_savings_deductions(person, period): relief = person("trade_loss_relief_against_general_income", period).astype( np.float64 ) - non_savings = np.maximum( - 0, person("net_income", period) + relief - savings - dividends - ) - used = np.minimum( - relief + person("allowances", period), non_savings + savings + dividends - ) + net_income = person("net_income", period) + allowances = person("allowances", period) + non_savings = np.maximum(0, net_income + relief - savings - dividends) + used = np.minimum(relief + allowances, non_savings + savings + dividends) to_non_savings = used - to_savings - to_dividends + # The old order: net income less savings, dividends and allowances, as the + # model has always computed it. + default_left = np.maximum(0, net_income - savings - dividends - allowances) return SimpleNamespace( earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), relief_against_savings_and_dividends=np.where( @@ -635,4 +693,7 @@ def non_savings_deductions(person, period): np.maximum(0, relief - to_non_savings), default.relief_against_savings_and_dividends, ), + non_savings_left=np.where( + moved, np.maximum(0, non_savings - to_non_savings), default_left + ), ) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index 99aaf14990..70ea63aba6 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -10,6 +10,7 @@ calc_income_tax_with_gift_aid, first_extended_scottish_threshold, ) +from policyengine_uk.utils.income_tax_allocation import default_order_deductions def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=True): @@ -23,15 +24,16 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr # Reg. 55(5)(b) deducts income tax paid by the person "in respect of # the employment" and reg. 57(2) step 3 income tax paid "in respect # of any trade". Neither says how to split a person's tax when they - # also have other income. Earnings are taken as the lowest slice of - # their non-savings income, after the allowances they actually have: - # savings and dividends sit above earnings and other non-savings - # income (as in ITA 2007 s. 16), property income above the rest of - # it (as in s. 16A from 2027-28), and other non-savings income - # (private pensions, State Pension, taxable benefits) above - # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE - # actually taken on the job, which can include tax on a State - # Pension coded against it; that is not modelled. + # also have other income. The helper takes earnings as the lowest + # slice of their non-savings income, after the allowances set against + # non-savings income first: savings and dividends sit above earnings + # and other non-savings income (as in ITA 2007 s. 16), property + # income above the rest of it (as in s. 16A from 2027-28), and other + # non-savings income (private pensions, State Pension, taxable + # benefits) above earnings. So tax on other income never comes off + # earnings. Under RTI, DWP deducts the PAYE actually taken on the job, + # which can include tax on a State Pension coded against it; the + # model leaves that out. p = parameters(period) income_tax = p.gov.hmrc.income_tax # earned_taxable_income is net income less the incomes and @@ -56,18 +58,44 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr period, [variable for variable in earnings_components if variable not in exclusions], ) - # The income left in earned_taxable_income before allowances. The - # part of it above earnings is the person's other non-savings income. - # Trade loss relief comes off that other income first; net_income - # deducts all of it, so add back the part set against savings and - # dividends, as earned_taxable_income does. + # The slice follows the order non-savings, savings, dividends, not the + # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): + # PAYE codes set the allowances against the job, and moving them onto + # savings or dividends to lower the year's tax would otherwise raise + # the tax on earnings. So the earnings slice reads the old order's + # deductions, and more savings or dividends never raise it. + default = default_order_deductions(person, period) + # The income left in earned taxable income before allowances, under + # that order. The part of it above earnings is the person's other + # non-savings income. Trade loss relief comes off that other income + # first; net_income deducts all of it, so add back the part the order + # sets against savings and dividends. income_in_base = ( person("net_income", period) - + person("trade_loss_relief_against_savings_and_dividends", period) + + default.relief_against_savings_and_dividends - add(person, period, excluded_incomes) ) other_income = max_(0, income_in_base - earnings) - taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) + # Earned taxable income under that order: the exclusions with the old + # order's allowances against non-savings income in place of the share's. + excluded = add( + person, + period, + [ + variable + for variable in exclusions + if variable != "received_allowances_earned_income" + ], + ) + if "received_allowances_earned_income" in exclusions: + excluded = excluded + default.earned + earned_taxable = max_( + 0, + person("net_income", period) + + default.relief_against_savings_and_dividends + - excluded, + ) + taxable_earnings = max_(0, earned_taxable - other_income) rates = income_tax.rates gross_gift = person("gift_aid_grossed_up", period) tax = where( diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index 62bf03a92d..00fd878f87 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -10,7 +10,7 @@ class received_allowances_earned_income(Variable): "The part of the allowances that Income Tax deducts from non-savings, " "non-dividend income, property income included. Trade loss relief " "against general income comes off that income first, inside " - "net_income, so this is whatever the share in " + "net_income, so it holds what the share in " "received_allowances_savings_income sets against that income beyond " "the relief." ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index 8d0326ef64..4f306ce65a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -8,10 +8,11 @@ class received_allowances_savings_income(Variable): label = "Allowances and reliefs deducted from savings income" documentation = ( "The part of the allowances and trade loss relief against general " - "income that Income Tax deducts from savings income. ITA 2007 s. 25(2) " - "deducts reliefs and allowances in the way that gives the least tax, " - "and from 2027-28 s. 25(3A) deducts them from income other than " - "property, savings and dividend income first. " + "income that Income Tax deducts from savings income. The taxpayer " + "deducts reliefs and allowances in the way that gives the least tax " + "(ITA 2007 s. 25(2)), and from 2027-28 deducts them from income other " + "than property, savings and dividend income first (s. 25(3A)). Pension " + "relief stays on non-savings income as far as it reaches. " "policyengine_uk.utils.income_tax_allocation finds that share." ) unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 40900b49b1..3da7a2609d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -1,5 +1,13 @@ from policyengine_uk.model_api import * +# The exclusions that take earned income down to the non-savings income left +# after the allowances. +NON_SAVINGS_BASE = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "received_allowances_earned_income", +) + class earned_taxable_income(Variable): value_type = float @@ -23,4 +31,10 @@ def formula(person, period, parameters): "trade_loss_relief_against_savings_and_dividends", period ) exclusions = add(person, period, exclusion_list) - return max_(0, income - exclusions) + earned = max_(0, income - exclusions) + if all(variable in exclusion_list for variable in NON_SAVINGS_BASE): + # Earned income is part of the non-savings income left after + # the allowances, which the allocation computes without the + # float32 rounding of the sum above. + return min_(earned, person("non_savings_income_after_allowances", period)) + return earned diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py index e1bb768e06..f5c5467c75 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class non_savings_income_after_allowances(Variable): @@ -13,8 +14,10 @@ class non_savings_income_after_allowances(Variable): "(ITA 2007 s. 16) after Steps 2 and 3. The taxpayer sets the reliefs " "and allowances against it in the way that gives the least tax (ITA " "2007 s. 25(2)), and from 2027-28 against the rest of it before " - "property income (s. 25(3A)). property_income_tax and " - "taxable_property_income_after_allowances both read it." + "property income (s. 25(3A)). The allocation computes it from its own " + "share, without the float32 rounding of a sum that adds the relief " + "back. property_income_tax, taxable_property_income_after_allowances " + "and earned_taxable_income read it." ) definition_period = YEAR reference = [ @@ -30,16 +33,11 @@ class non_savings_income_after_allowances(Variable): unit = GBP def formula(person, period, parameters): - return max_( - 0, - # Not adjusted_net_income: it deducts the grossed-up Gift Aid - # gift (ITA 2007 s. 58), which raises the rate limits instead - # (s. 414(2)(b)) and is no deduction from taxable income. - # net_income deducts all trade loss relief, so add back the part - # set against savings and dividends (s. 25). - person("net_income", period) - + person("trade_loss_relief_against_savings_and_dividends", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - - person("received_allowances_earned_income", period), - ) + # Not adjusted_net_income: it deducts the grossed-up Gift Aid gift + # (ITA 2007 s. 58), which raises the rate limits instead + # (s. 414(2)(b)) and is no deduction from taxable income. The + # allocation takes net income, adds back the trade loss relief that + # the share sets against savings and dividends (s. 25), and deducts + # savings, dividends and the reliefs and allowances the share sets + # against non-savings income. + return non_savings_deductions(person, period).non_savings_left diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index b9e147f958..57183792c2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -8,14 +8,15 @@ class trade_loss_relief_against_savings_and_dividends(Variable): label = "Trade loss relief deducted from savings and dividend income" documentation = ( "The part of trade loss relief against general income that Income Tax " - "deducts from savings and dividend income. ITA 2007 s. 25(2) deducts " - "reliefs and allowances in the way that gives the least tax, and from " - "2027-28 s. 25(3A) deducts them from income other than property, " - "savings and dividend income first; received_allowances_savings_income " - "and received_allowances_dividend_income hold that share. The relief " - "comes off non-savings income first, as far as the share sets anything " - "against that income, and this is the rest. net_income deducts the " - "whole relief, so the non-savings bases add this back." + "deducts from savings and dividend income. The taxpayer deducts " + "reliefs and allowances in the way that gives the least tax (ITA 2007 " + "s. 25(2)), and from 2027-28 deducts them from income other than " + "property, savings and dividend income first (s. 25(3A)); " + "received_allowances_savings_income and " + "received_allowances_dividend_income hold that share. The relief comes " + "off non-savings income first, as far as the share sets anything " + "against that income, and this variable holds the rest. net_income " + "deducts the whole relief, so the non-savings bases add this back." ) definition_period = YEAR unit = GBP From 411dc0243dbbadefd56d46389b4b2b6270591eda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:53:14 +0000 Subject: [PATCH 082/168] Deduct only the tax the Council Tax Reduction schemes allow Scotland's working-age scheme deducts income tax and National Insurance on earnings only; the pensioner schemes of England, Wales and Scotland deduct no tax on income they do not take into account (savings, dividends, the Scottish Carer Supplement). A new council_tax_reduction_tax_deduction variable holds the deduction. Fixes #2090 for those schemes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FLTn86KDyF7Gco35i --- changelog.d/2090.fixed.md | 1 + ...l_tax_reduction_carer_support_payment.yaml | 22 +- .../council_tax_reduction_tax_deduction.yaml | 148 +++++++++++++ ...l_tax_reduction_carer_income_properties.py | 80 ++++--- ..._tax_reduction_tax_deduction_properties.py | 197 ++++++++++++++++++ ...council_tax_reduction_applicable_income.py | 16 +- .../council_tax_reduction_tax_deduction.py | 187 +++++++++++++++++ 7 files changed, 594 insertions(+), 57 deletions(-) create mode 100644 changelog.d/2090.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md new file mode 100644 index 0000000000..ed9a67373c --- /dev/null +++ b/changelog.d/2090.fixed.md @@ -0,0 +1 @@ +Council Tax Reduction income now deducts only the income tax and National Insurance each scheme allows: tax on earnings in Scotland's working-age scheme, and no tax on savings, dividends or the Scottish Carer Supplement in the pensioner schemes of England, Wales and Scotland. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index f18404306e..637e7844ad 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -12,9 +12,10 @@ # the supplement's tax does not reach Council Tax Reduction either: at working # age SSI 2021/249 deducts tax only from earnings, and at pension age SSI # 2012/319 reg 31(12) disregards the tax payable on income taken into -# account, which the supplement is not. The model deducts all income tax, so -# the taxable supplement reaches the model's Council Tax Reduction income -# through income tax and in no other way. +# account, which the supplement is not. The model deducts only the tax each +# scheme allows (council_tax_reduction_tax_deduction), with the supplement's +# tax taken as the highest part of non-savings income, so the supplement does +# not reach the model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -248,7 +249,7 @@ # jumps to 2,000 when the carer moves to Carer Support Payment in 2025. council_tax_reduction: 2_000 - 0.2 * (7_000 + 81.90 * 52 - (90.50 + 45.60) * 52) -- name: Pension-age Council Tax Reduction counts Carer Support Payment, and the supplement only through tax +- name: Pension-age Council Tax Reduction counts Carer Support Payment and deducts no tax on the supplement period: 2026 absolute_error_margin: 0.01 input: @@ -275,13 +276,18 @@ # first 3,967 at the Scottish starter rate of 19% and 2,566.80 at 20%. # The supplement adds 608.40 x 20% = 121.68 of that tax. income_tax: 3_967 * 0.19 + 2_566.80 * 0.20 - # 14,000 + 4,495.40 - 1,267.09: the supplement is not added. - council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_267.09 + # SSI 2012/319 reg 31(12) disregards only the tax on income taken into + # account. The supplement is not, so its tax, as the highest part of + # income, is not deducted: 14,000 + 4,495.40 - 12,570 = 5,925.40 taxable, + # 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so 1,145.41. + council_tax_reduction_tax_deduction: 3_967 * 0.19 + 1_958.40 * 0.20 + # 14,000 + 4,495.40 - 1,145.41: neither the supplement nor its tax counts. + council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_145.41 council_tax_reduction_applicable_amount: (256 + 48.15) * 52 # Income is above the minimum guarantee of (238 + 48.15) x 52 = 14,879.80. guarantee_credit: 0 - # 2,000 - 0.2 x (17,228.31 - 15,815.80) - council_tax_reduction: 1_717.50 + # 2,000 - 0.2 x (17,349.99 - 15,815.80) + council_tax_reduction: 1_693.16 - name: A pension-age couple's Council Tax Reduction counts the partner's Carer Support Payment period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml new file mode 100644 index 0000000000..17c68f8f51 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -0,0 +1,148 @@ +# Income tax and National Insurance deducted from Council Tax Reduction income. +# - Scotland, working age: the Council Tax Reduction (Scotland) Regulations +# 2021 (SSI 2021/249) reg 50(3)(a) reads "Earnings do not include— (a) any +# amount deducted from them by way of income tax or primary Class 1 +# contributions under the 1992 Act"; regs 49(6)(b) and 51(2) do the same, +# and the unearned income rules (regs 57 to 65) deduct no tax. So only tax +# and National Insurance on earnings come off. +# - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken +# into account for the purpose of calculating a person's income, any amount +# payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) +# and WSI 2013/3029 Sch 1 para 11(13) read the same. So tax on income not +# taken into account (savings interest, dividends, the Scottish Carer +# Supplement) does not come off. +# +# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable income: +# 19% to 3,967, 20% to 16,956, 21% to 31,092. UK basic rate 20% to 37,700. +# Class 1 primary contributions 8% above 241.73 a week (12,569.96 a year). +# Each family is a single adult with no Pension Credit or Universal Credit. + +- name: Scotland's working-age scheme deducts tax and National Insurance on earnings only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax on earnings, the earnings as the lowest part of income: + # 20,000 - 12,570 = 7,430 taxable; 3,967 x 19% = 753.73 and + # 3,463 x 20% = 692.60, so 1,446.33. Class 1: (20,000 - 12,569.96) x 8% = + # 594.40. The tax on the private pension is not deducted. + council_tax_reduction_tax_deduction: 2_040.73 + # 20,000 + 10,000 - 2,040.73. + council_tax_reduction_applicable_income: 27_959.27 + +- name: England's pensioner scheme deducts no tax on savings interest + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_308 + savings_interest_income: 6_559 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The scheme counts the pension, not the interest. Tax on the pension: + # (20,308 - 12,570) x 20% = 1,547.60. + council_tax_reduction_tax_deduction: 1_547.60 + # 20,308 - 1,547.60. + council_tax_reduction_applicable_income: 18_760.40 + +- name: Wales's pensioner scheme deducts no tax on dividends + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + dividend_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The scheme counts the pension, not the dividends. Tax on the pension: + # (20,000 - 12,570) x 20% = 1,486. + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + +- name: Scotland's pensioner scheme deducts no tax on the Scottish Carer Supplement + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 15_000 + # 11.70 a week for 52 weeks. + scottish_carer_supplement: 608.40 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/319 reg 27(1)(j)(xxib) excepts the supplement, so the scheme + # counts the pension alone. Tax on the pension with the supplement above + # it: (15,000 - 12,570) x 19% = 461.70. + council_tax_reduction_tax_deduction: 461.70 + # 15,000 - 461.70. + council_tax_reduction_applicable_income: 14_538.30 + +- name: Wales's working-age scheme still deducts the whole income tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The model keeps the whole liability here, which the cases above do not + # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. + council_tax_reduction_tax_deduction: 4_080.40 + council_tax_reduction_applicable_income: 25_919.60 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 031f5b855e..2eac04743a 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -16,41 +16,39 @@ working age SSI 2021/249 deducts income tax only from earnings (Part 6 Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax payable on income taken into account, which the supplement is not. So in law -the supplement does not touch Council Tax Reduction at all. The model deducts -all of a family's income tax from its Council Tax Reduction income, an -existing approximation, so there the supplement, which is taxable (SI -2026/93), reaches that income through the tax charged on it and in no other -way. The tax deducted is the liability before the winter fuel payment charge -(ITEPA 2003 s. 681I), which recovers a winter payment rather than taxing the -income assessed; "income tax" below means that liability. +the supplement, which is taxable (SI 2026/93), does not touch Council Tax +Reduction at all. The model deducts the tax and National Insurance each +scheme allows (council_tax_reduction_tax_deduction), with the supplement's +tax taken as the highest part of non-savings income. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all over State Pension age: 1. Counts the component: claiming Carer Support Payment raises Council Tax - Reduction income, before income tax and National Insurance and apart from - the other benefits it counts, by exactly the Carer Support Payment + Reduction income, before the income tax and National Insurance it deducts + and apart from the other benefits it counts, by exactly the Carer Support Payment component (86.45 a week), not by the component plus the supplement, and by that amount before any overlapping-benefit reduction (see below). The reduction itself never rises, and where both awards are partial it falls by 20% of the rise in income less the rise in the applicable amount (the carer premium comes with the claim). 2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment and the other benefits counted in Council - Tax Reduction income (Child Benefit, the income-related benefits, - Universal Credit and tax credits) unchanged, and changes that income only - through income tax: income plus income tax is unchanged. The supplement - never lowers the reduction, and where both awards are partial it raises - it by 20% of the tax on the supplement. + leaves Carer Support Payment, the other benefits counted in Council Tax + Reduction income (Child Benefit, the income-related benefits, Universal + Credit and tax credits) and Council Tax Reduction income itself unchanged + under the general rules. The supplement never lowers the reduction, and + under the general rules never changes it. 3. Differential: Council Tax Reduction and Housing Benefit assess the same carer income. Where Guarantee Credit does not passport Housing Benefit, Housing Benefit applicable income, before its disregard, childcare element and tariff income, equals Council Tax Reduction income less the part of Carer Support Payment that an overlapping benefit removes, which Housing - Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)). This holds in - Scotland, where the carer's benefit is Carer Support Payment, and in - England and Wales, where it is Carer's Allowance. + Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)), and less + the income tax and National Insurance that Housing Benefit deducts and + Council Tax Reduction does not. This holds in Scotland, where the carer's + benefit is Carer Support Payment, and in England and Wales, where it is + Carer's Allowance. Invariants 1 to 3 concern Council Tax Reduction income under the general rules. A pension-age family in receipt of a guarantee credit has its whole income @@ -84,9 +82,11 @@ CARER_SUPPORT_PAYMENT = 86.45 * WEEKS SCOTTISH_CARER_SUPPLEMENT = 11.70 * WEEKS WITHDRAWAL_RATE = 0.2 -# The income tax Council Tax Reduction income deducts: the liability before -# the winter fuel payment charge. +# The income tax Housing Benefit deducts: the liability before the winter fuel +# payment charge. INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +# The income tax and National Insurance Council Tax Reduction income deducts. +CTR_TAX = "council_tax_reduction_tax_deduction" PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -114,6 +114,7 @@ ] BENUNIT_VARIABLES = [ "council_tax_reduction_applicable_income", + CTR_TAX, "council_tax_reduction_applicable_amount", "council_tax_benefit", "guarantee_credit", @@ -246,8 +247,7 @@ def grid(families, reform=None): # from the other benefits it counts. g["income_before_tax"] = ( g["council_tax_reduction_applicable_income"] - + g[INCOME_TAX] - + g["national_insurance"] + + g[CTR_TAX] - g["other_counted_benefits"] ) # Cells whose Council Tax Reduction income follows the general rules, not @@ -340,29 +340,20 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( ) income = g["council_tax_reduction_applicable_income"][i] income_without = without["council_tax_reduction_applicable_income"][i] - # Only where the zero floor on income does not bind in either run, and - # both runs follow the general rules. + # Where both runs follow the general rules. general = g["general_rules"][i] & without["general_rules"][i] - compared = (income > 0) & (income_without > 0) & general - assert compared.any(), fam - assert np.allclose( - (income + g[INCOME_TAX][i])[compared], - (income_without + without[INCOME_TAX][i])[compared], - atol=0.05, - ), fam + assert general.any(), fam + assert np.allclose(income[general], income_without[general], atol=0.01), ( + fam, + income[general], + income_without[general], + ) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] - # The supplement can only add tax, which lowers income, so it never - # lowers the award, even on the Pension Credit routes (whose income - # follows invariant 4). + # The supplement never lowers the award, even on the Pension Credit + # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam - both_partial = partial(g, i) & partial(without, i) & general - tax_on_supplement = g[INCOME_TAX][i] - without[INCOME_TAX][i] - assert np.allclose( - (award - award_without)[both_partial], - WITHDRAWAL_RATE * tax_on_supplement[both_partial], - atol=0.05, - ), fam + assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam @@ -400,7 +391,12 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( + g["housing_benefit_applicable_income_childcare_element"][i] - g["housing_benefit_tariff_income"][i] ) - ctr_less_overlap = ctr_income - removed_by_overlap + # Housing Benefit deducts the whole liability; Council Tax Reduction + # the tax and National Insurance its scheme allows. + tax_only_hb_deducts = ( + g[INCOME_TAX][i] + g["national_insurance"][i] - g[CTR_TAX][i] + ) + ctr_less_overlap = ctr_income - removed_by_overlap - tax_only_hb_deducts assert np.allclose( hb_before_adjustments[compared], ctr_less_overlap[compared], atol=0.05 ), (fam, hb_before_adjustments[compared], ctr_less_overlap[compared]) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py new file mode 100644 index 0000000000..4ccf6dcd27 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -0,0 +1,197 @@ +"""Income tax and National Insurance deducted from Council Tax Reduction income. + +Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations +2021, SSI 2021/249) takes income tax and primary Class 1 contributions off +earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules +(regs 57 to 65) deduct no tax. The pensioner schemes disregard tax only on +income taken into account: SSI 2012/319 reg 31(12) in Scotland, SI 2012/2885 +Sch 1 para 17(13) in England and WSI 2013/3029 Sch 1 para 11(13) in Wales. +council_tax_reduction_tax_deduction applies these rules, and +council_tax_reduction_applicable_income deducts it. + +Each test builds single-adult households by hand in 2026, with no Universal +Credit claim, and states its invariant in its docstring. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +REGIONS = {"ENGLAND": "NORTH_WEST", "WALES": "WALES", "SCOTLAND": "SCOTLAND"} +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +WHOLE_TAX = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] +# Taxed income that is not earnings. A working-age adult has no State Pension. +UNEARNED = [ + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", +] +# Taxed income the pensioner schemes do not take into account. +UNCOUNTED = [ + "savings_interest_income", + "dividend_income", + "scottish_carer_supplement", +] + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +def simulate(people, country): + """One single-adult household per entry of people, all in one country.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, inputs in enumerate(people): + name = f"p{i}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + situation["benunits"][f"b{i}"] = { + "members": [name], + "would_claim_uc": {YEAR: False}, + } + situation["households"][f"h{i}"] = { + "members": [name], + "country": {YEAR: country}, + "region": {YEAR: REGIONS[country]}, + "council_tax": {YEAR: 2_000.0}, + "savings": {YEAR: 0.0}, + } + return Simulation(situation=situation) + + +def benunit_values(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR)) + + +def person_values(simulation, variables): + return sum( + np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) for v in variables + ) + + +@st.composite +def working_age_adult(draw): + return dict( + age=draw(st.integers(25, 60)), + employment_income=draw(st.one_of(st.just(0.0), money(80_000))), + self_employment_income=draw(st.one_of(st.just(0.0), money(40_000))), + **{v: draw(st.one_of(st.just(0.0), money(40_000))) for v in UNEARNED}, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(working_age_adult(), min_size=1, max_size=4), + st.sampled_from(UNEARNED), + money(30_000), +) +def test_scottish_working_age_income_never_falls_as_taxed_unearned_income_rises( + adults, unearned, rise +): + """In Scotland's working-age scheme, Council Tax Reduction applicable income + never falls when a taxed unearned income rises and earnings stay fixed.""" + raised = [{**a, unearned: a[unearned] + rise} for a in adults] + base = simulate(adults, "SCOTLAND") + more = simulate(raised, "SCOTLAND") + assert not benunit_values(base, "council_tax_reduction_pensioner").any() + before = benunit_values(base, "council_tax_reduction_applicable_income") + after = benunit_values(more, "council_tax_reduction_applicable_income") + assert np.all(after >= before - 0.01), (adults, unearned, rise, before, after) + + +@PROPERTY_SETTINGS +@given(st.lists(working_age_adult(), min_size=1, max_size=4)) +def test_scottish_working_age_deducts_no_tax_without_earnings(adults): + """In Scotland's working-age scheme, with no earnings the deduction for + income tax and National Insurance is nil, however much other taxed income + there is.""" + no_earnings = [ + {**a, "employment_income": 0.0, "self_employment_income": 0.0} for a in adults + ] + simulation = simulate(no_earnings, "SCOTLAND") + deduction = benunit_values(simulation, "council_tax_reduction_tax_deduction") + assert np.all(deduction == 0), (no_earnings, deduction) + + +@st.composite +def pensioner(draw): + # Pensions reach far enough, with the uncounted income, to cross the + # personal allowance taper. + return dict( + age=draw(st.integers(67, 90)), + state_pension=draw(money(12_000)), + private_pension_income=draw(money(140_000)), + property_income=draw(st.one_of(st.just(0.0), money(15_000))), + savings_interest_income=draw(st.one_of(st.just(0.0), money(20_000))), + dividend_income=draw(st.one_of(st.just(0.0), money(20_000))), + scottish_carer_supplement=draw(st.sampled_from([0.0, 608.40])), + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(pensioner(), min_size=1, max_size=4), + st.sampled_from(sorted(REGIONS)), +) +def test_pensioner_schemes_deduct_no_more_than_the_tax_on_counted_income( + pensioners, country +): + """In the pensioner schemes of England, Wales and Scotland, the income tax + and National Insurance deducted never exceed what the same person would + pay with only the income the scheme counts, nor the whole liability.""" + counted_only = [{**p, **{v: 0.0 for v in UNCOUNTED}} for p in pensioners] + full = simulate(pensioners, country) + alone = simulate(counted_only, country) + assert benunit_values(full, "council_tax_reduction_pensioner").all() + deduction = benunit_values(full, "council_tax_reduction_tax_deduction") + tax_on_counted_income = person_values(alone, WHOLE_TAX) + whole_tax = person_values(full, WHOLE_TAX) + assert np.all(deduction <= tax_on_counted_income + 0.01), ( + pensioners, + deduction, + tax_on_counted_income, + ) + assert np.all(deduction <= whole_tax + 0.01), (pensioners, deduction, whole_tax) + # Taking away the uncounted income leaves the deduction where it was. + deduction_alone = benunit_values(alone, "council_tax_reduction_tax_deduction") + assert np.allclose(deduction, deduction_alone, atol=0.01), ( + pensioners, + deduction, + deduction_alone, + ) + + +CASES = [ + ("SCOTLAND", dict(age=40, employment_income=20_000.0, dividend_income=9_000.0)), + ("SCOTLAND", dict(age=70, private_pension_income=15_000.0)), + ("ENGLAND", dict(age=70, private_pension_income=20_308.0)), + ("WALES", dict(age=70, private_pension_income=20_000.0)), +] + + +def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_first(): + """Council Tax Reduction and its tax deduction come out the same whether + the deduction or the reduction is calculated first, so the deduction reads + no benefit that depends on the reduction.""" + variables = [ + "council_tax_reduction_tax_deduction", + "council_tax_reduction_applicable_income", + "council_tax_reduction", + ] + for country, person in CASES: + inputs = {**person, "savings_interest_income": 6_559.0} + results = [] + for order in (variables, variables[::-1]): + simulation = simulate([inputs], country) + results.append( + {v: float(simulation.calculate(v, YEAR).sum()) for v in order} + ) + assert results[0] == results[1], (country, results) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index e1494e88cb..073ea7f684 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -38,7 +38,11 @@ class council_tax_reduction_applicable_income(Variable): "children or young persons. War pensions and Armed Forces " "Compensation Scheme payments are not counted: Scotland disregards or " "omits them, and English and Welsh councils may relieve them beyond " - "the prescribed £10 a week, so the model takes the full disregard." + "the prescribed £10 a week, so the model takes the full disregard. " + "The income tax and National Insurance deducted are those the scheme " + "allows (council_tax_reduction_tax_deduction): on earnings only in " + "Scotland's working-age scheme, on income taken into account in the " + "pensioner schemes, and the whole liability in the other schemes." ) definition_period = YEAR unit = GBP @@ -285,12 +289,10 @@ def formula(benunit, period, parameters): pension_contributions = add_for_members( benunit, period, ["pension_contributions"], members ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) - tax = add_for_members( - benunit, - period, - ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], - members, - ) + # The income tax and National Insurance each scheme allows: on earnings + # only in Scotland's working-age scheme, on income taken into account + # in the pensioner schemes (council_tax_reduction_tax_deduction). + tax = benunit("council_tax_reduction_tax_deduction", period) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner # earnings disregards, capped at net earnings. Zero for working-age diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py new file mode 100644 index 0000000000..147a35ddc5 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -0,0 +1,187 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) +from policyengine_uk.variables.household.demographic.country import Country + +# Taxable non-savings income other than earnings. Scotland's working-age scheme +# deducts no tax from it. +NON_EARNINGS_NON_SAVINGS_INCOME = [ + "taxable_pension_income", + "taxable_social_security_income", + "taxable_property_income", + "taxable_miscellaneous_income", +] +# Taxable non-savings income that the pensioner schemes do not take into +# account: the Scottish Carer Supplement (SSI 2012/319 reg 27(1)(j)(xxib)), +# miscellaneous income and benefits in kind, which the model's Council Tax +# Reduction income does not count. +PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME = [ + "scottish_carer_supplement", + "taxable_miscellaneous_income", + "benefits_in_kind", +] +# National Insurance charged on earnings and profits. Class 3 contributions are +# voluntary and not charged on earnings. +NATIONAL_INSURANCE_ON_EARNINGS = ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + + +def tax_below_top_slice(person, period, parameters, top_slice): + """Step 5 tax on a person's non-savings income without its top slice. + + Savings and dividend income are taxed as the highest part of income, so + the Step 5 tax on the rest is earned_income_tax plus property_income_tax. + The top_slice of non-savings income is taken as the highest part of the + non-savings income, and its tax at the person's main rates is taken off. + Allowances and reliefs therefore fall on the income below it, and that + income keeps the personal allowance it would have on its own: the part of + the allowance withdrawn only because the top slice, savings and dividends + lift adjusted net income above the taper threshold is restored to it. + """ + allowance = parameters(period).gov.hmrc.income_tax.allowances.personal_allowance + rates = parameters(period).gov.hmrc.income_tax.rates + non_savings_tax = add(person, period, ["earned_income_tax", "property_income_tax"]) + taxable = person("non_savings_taxable_income", period) + income_above = top_slice + add( + person, period, ["taxable_savings_interest_income", "taxable_dividend_income"] + ) + income_below = max_(0, person("adjusted_net_income", period) - income_above) + allowance_alone = max_( + 0, + np.ceil( + allowance.amount + - max_(0, income_below - allowance.maximum_ANI) * allowance.reduction_rate + ), + ) + restored_allowance = max_(0, allowance_alone - person("personal_allowance", period)) + below = max_(0, taxable - top_slice - restored_allowance) + gross_gift = person("gift_aid_grossed_up", period) + scottish = rates.scotland.rates + first_extended = first_extended_scottish_threshold(scottish) + scottish_bands = len(scottish.thresholds) + + def tax_at_main_rates(income): + return where( + person("pays_scottish_income_tax", period), + calc_income_tax_with_gift_aid( + scottish, income, gross_gift, first_extended, scottish_bands + ), + calc_income_tax_with_gift_aid(rates.uk, income, gross_gift), + ) + + tax_on_top_slice = max_(0, tax_at_main_rates(taxable) - tax_at_main_rates(below)) + # With no taxable income below the top slice there is no tax on it; this + # also clears the rounding left by the subtraction. + return where(below > 0, max_(0, non_savings_tax - tax_on_top_slice), 0) + + +def tax_after_reductions_and_charges(person, period, step_5_part): + """Income Tax attributed to a part of the Step 5 tax. + + The Step 6 reductions come off that part first, as they would if it were + the person's only income. The Step 7 charges other than the winter fuel + payment charge are shared in proportion to the part's share of the Step 5 + tax. The result never exceeds income_tax_before_winter_fuel_payment_charge. + """ + step_5_tax = max_(0, person("income_tax_pre_charges", period)) + reductions = person("income_tax_reductions", period) + charges = person("income_tax_charges", period) + share = np.divide( + step_5_part, + step_5_tax, + out=np.zeros_like(step_5_tax), + where=step_5_tax > 0, + ) + return max_(0, step_5_part - reductions) + charges * min_(share, 1) + + +class council_tax_reduction_tax_deduction(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction deduction for income tax and National Insurance" + documentation = ( + "Income tax and National Insurance that Council Tax Reduction income " + "deducts, for the applicant and partner " + "(is_council_tax_reduction_applicant_or_partner). Scotland's " + "working-age scheme (SSI 2021/249) deducts them from earnings only, " + "so the formula deducts National Insurance on earnings and profits " + "(Classes 1, 2 and 4) and the Income Tax on earnings, taken as the " + "Step 5 tax that employment and self-employment income bear as the " + "lowest part of non-savings income, with the other non-savings " + "income above them and savings and dividends taxed separately. The " + "pensioner schemes of England (SI 2012/2885), Wales (WSI 2013/3029) " + "and Scotland (SSI 2012/319) disregard tax only on income taken into " + "account, so the formula deducts the same National Insurance and the " + "Step 5 tax on non-savings income less the tax on the Scottish Carer " + "Supplement, miscellaneous income and benefits in kind, taken as the " + "highest part of non-savings income. The income below keeps the " + "personal allowance it would have on its own. Tax on savings and dividend " + "income is never deducted in these schemes. In each, the Step 6 tax " + "reductions come off the tax deducted first, and the Step 7 charges " + "other than the winter fuel payment charge are shared in proportion " + "to the Step 5 tax. The ordering of earnings below other non-savings " + "income is a modelling choice: the regulations name the tax deducted " + "from earnings, not a way to apportion it. The English and Welsh " + "working-age schemes, and families outside Great Britain, deduct the " + "whole Income Tax liability before the winter fuel payment charge " + "and all National Insurance." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2021/249/regulation/49", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + ] + + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_council_tax_reduction_applicant_or_partner", period) + country = benunit.household("country", period) + in_great_britain = ( + (country == Country.ENGLAND) + | (country == Country.SCOTLAND) + | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + scottish_working_age = (country == Country.SCOTLAND) & ~pensioner + pensioner_scheme = in_great_britain & pensioner + + whole_tax = add( + person, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + ) + ni_on_earnings = add(person, period, NATIONAL_INSURANCE_ON_EARNINGS) + earnings_step_5_tax = tax_below_top_slice( + person, + period, + parameters, + add(person, period, NON_EARNINGS_NON_SAVINGS_INCOME), + ) + counted_step_5_tax = tax_below_top_slice( + person, + period, + parameters, + add(person, period, PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME), + ) + tax_on_earnings = ( + tax_after_reductions_and_charges(person, period, earnings_step_5_tax) + + ni_on_earnings + ) + tax_on_counted_income = ( + tax_after_reductions_and_charges(person, period, counted_step_5_tax) + + ni_on_earnings + ) + person_scottish_working_age = benunit.project(scottish_working_age) + person_pensioner_scheme = benunit.project(pensioner_scheme) + deduction = select( + [person_scottish_working_age, person_pensioner_scheme], + [tax_on_earnings, tax_on_counted_income], + default=whole_tax, + ) + return benunit.sum(deduction * members) From def18d3a3043d1b65e74de2a69b169d4f0a550db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:01:05 +0000 Subject: [PATCH 083/168] Rename the property income band base test in the shard durations The branch renamed test_more_property_income_never_lowers_savings_or_dividend_tax to test_more_property_income_never_lowers_income_tax, and the checked-in durations still named the old test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g --- policyengine_uk/tests/shard_durations.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 2c52eb6701..7adf4b7794 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -285,7 +285,7 @@ "policyengine_uk/tests/test_trading_loss_properties.py::test_zero_profit_trade_loss_cannot_raise_tax_or_reduce_programme_award[tax_credits-2024]" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_young_singles" = 65 "policyengine_uk/tests/test_carer_overlapping_benefits_properties.py::test_claiming_a_carer_benefit_reduced_to_nil_increases_pension_credit" = 65 -"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_savings_or_dividend_tax" = 65 +"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_income_tax" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_claimant_or_partner_awards_bounded_by_benefit_unit_awards" = 65 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_no_policy_change_gives_no_response" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_working_parents" = 64 From fff13eda816c608a8dbe2e064131472363fae9f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:02:21 +0000 Subject: [PATCH 084/168] Name the housing costs the bound compares and reflow the carer comments The bound's documentation now compares the deduction with the housing costs DWP allows, capped at the Local Housing Allowance rate for a private renter. Three documentation sentences name their actor, and the YAML comments in the two carer test files wrap without orphaned words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KFgPgaVD6zHA1bFq9 --- .../council_tax_reduction_carer_premium.yaml | 11 +++++----- .../uc_carer_element_carer_test.yaml | 20 +++++++++---------- ...e_disability_minimum_guarantee_addition.py | 11 +++++----- .../universal_credit/uc_award_upper_bound.py | 8 +++++--- ...is_cared_for_by_carer_benefit_recipient.py | 6 +++--- .../demographic/severe_disability_premium.py | 9 ++++----- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index eac36da9df..80cfdf4ca3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -160,9 +160,9 @@ - name: A Scottish carer whose income tapers the award to nil has no award and no carer premium # With £20,000 of earnings the award comes to nil, which in 2026 leaves no - # award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and para 6(1)(c) - # does not apply, though the maximum amount still includes the carer - # element. + # award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and para + # 6(1)(c) does not apply, though the maximum amount still includes the + # carer element. period: 2026 absolute_error_margin: 0.01 input: @@ -226,9 +226,8 @@ # nil. From 1 April 2025 reg 4 defines an award of universal credit as one # "made under Part 1 of the 2012 Act" (SSI 2025/24 reg 8), and an income # that tapers it to nil leaves no award, so para 6(1)(c) does not apply: no - # carer premium. Neither of - # reg 10A's nil awards (a sanction, or a reclaim under the UC Claims and - # Payments Regs 2013 reg 32A) arises. + # carer premium. Neither of reg 10A's nil awards (a sanction, or a reclaim + # under the UC Claims and Payments Regs 2013 reg 32A) arises. period: 2025 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml index fd1065b142..61976b053c 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -5,9 +5,9 @@ # disability addition (SPC Regs 2002 Sch I para 1(1)(a)(iii) and (b), with # para 1(4)): they withhold the premium or addition when a carer "has an # award of universal credit which includes the carer element in respect of -# caring for" the claimant. DWP includes the carer element for regular and substantial caring -# for a severely disabled person, whether or not the carer claims Carer's -# Allowance (UC Regs 2013 regs 29 and 30(2)). +# caring for" the claimant. DWP includes the carer element for regular and +# substantial caring for a severely disabled person, whether or not the carer +# claims Carer's Allowance (UC Regs 2013 regs 29 and 30(2)). # # Amounts for 2026-27 (DWP benefit and pension rates 2026 to 2027; the Social # Security Benefits Up-rating Order 2026, SI 2026/148): severe disability @@ -192,17 +192,15 @@ - name: A carer whose income tapers the award to nil has no award and does not bar the premium # The carer's maximum amount comes to (424.90 + 209.34) x 12 = 7,610.88. - # Earnings - # of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 and - # National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, and - # with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. That - # exceeds the maximum amount even with no non-dependant deduction + # Earnings of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 + # and National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, + # and with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. + # That exceeds the maximum amount even with no non-dependant deduction # (uc_award_upper_bound; the disabled person's family, which does not # claim, has a bound of its standard allowance, 424.90 x 12 = 5,098.80), so # the award comes to nil, which leaves no award (Welfare Reform Act 2012 - # s.5(1)(b); UC Regs 2013 reg 17). No one has an award in - # respect of caring for the disabled person, who keeps the premium, 86.05 x - # 52 = 4,474.60. + # s.5(1)(b); UC Regs 2013 reg 17). No one has an award in respect of caring + # for the disabled person, who keeps the premium, 86.05 x 52 = 4,474.60. period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 3ca17774c6..537f2538e7 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -24,16 +24,15 @@ class severe_disability_minimum_guarantee_addition(Variable): "modelled: the hospital-patient deeming (para 1(2)(b)-(bd)) " "and backdating (para 1(2)(a), (c)); sight regained within 28 weeks " "(para 1(3)); polygamous marriages; and the para 2 and 3 exceptions " - "listed on is_counted_resident_for_severe_disability_addition. Whom " - "a carer benefit is paid for caring for is attributed by " - "is_cared_for_by_carer_benefit_recipient, which the legacy severe " - "disability premium shares. The attribution counts an award of " + "listed on is_counted_resident_for_severe_disability_addition. " + "is_cared_for_by_carer_benefit_recipient attributes each carer " + "benefit to the person it is paid for caring for, and the legacy " + "severe disability premium shares it. The attribution counts an award of " "Universal Credit which includes the carer element (para 1(4)), which " "has_uc_award_with_carer_element reads from the award's conditions " "and the upper bound on the award (uc_award_upper_bound). The bound " "can stay positive when the actual award comes to nil (#2795). " - "Blindness " - "(is_blind) is not in the survey data, so in microsimulation the " + "Blindness (is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) entity = BenUnit diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py index c92135e432..aade3f9656 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py @@ -14,9 +14,11 @@ class uc_award_upper_bound(Variable): "(uc_housing_costs_element): the rent a renter may claim less DWP's " "non-dependant deductions. Where that element exceeds nil, DWP " "applies the lower work allowance (reg 22(2)(b)). Where DWP deducts " - "less than the rent, the award stays at or below the award with no " - "deduction. Where it deducts at least the rent, DWP calculates the " - "award with no housing costs element and the higher work allowance " + "less than the housing costs it allows (the rent, capped at the Local " + "Housing Allowance rate for a private renter), the award stays at or " + "below the award with no deduction. Where it deducts at least those " + "housing costs, DWP calculates the award with no housing costs " + "element and the higher work allowance " "(reg 22(2)(a); Sch 4 para 13). The formula takes the larger of those " "two awards. It repeats the award's steps in their order, rounding " "where they store a value, so the bound never falls below the award " diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index dddddd6928..4ea9c3f8f6 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -39,9 +39,9 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "pays the single rate instead of the double rate. Where not " "everyone can have a carer, those who are not carers themselves come " "first, then person order; the number cared for in a benefit unit " - "does not depend on that choice. Carers outside the household are not " - "observed, and nor is a carer whose allowance runs on for up to eight " - "weeks after the person they cared for has died (s.70(1A)). The " + "does not depend on that choice. The model does not observe carers " + "outside the household, nor a carer whose allowance runs on for up to " + "eight weeks after the person they cared for has died (s.70(1A)). The " "model reads receipt from receives_carer_benefit: a positive Carer's " "Allowance or Carer " "Support Payment after the overlapping-benefit adjustment. A carer " diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 90a2c3948d..18f1f60b36 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -19,11 +19,10 @@ class severe_disability_premium(Variable): "for caring for them. A couple who both qualify get the double rate " "when no carer benefit is paid for either of them, the single rate " "when one is paid for only one of them, and nothing when carers are " - "paid for both. Whom a carer benefit is paid for caring for is " - "attributed by is_cared_for_by_carer_benefit_recipient, shared with " - "the Pension Credit severe disability addition. The attribution " - "counts an award " - "of Universal Credit which includes the carer element from the " + "paid for both. is_cared_for_by_carer_benefit_recipient attributes " + "each carer benefit to the person it is paid for caring for, and the " + "Pension Credit severe disability addition shares it. The attribution " + "counts an award of Universal Credit which includes the carer element from the " "award's conditions and an upper bound on the award " "(has_uc_award_with_carer_element). A carer whose bound comes to nil " "does not bar the premium. The model can still withhold it where a " From 66533369d19501c99f81d69d53d0f7666e641f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:09:42 +0000 Subject: [PATCH 085/168] Apply the school share in the VAT grossing property's oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vat takes private school fees' share of VAT receipts out of the grossed-up amount (from #2319, merged into this branch). The property's oracle divided the statutory liability by the shipped product without that step, so every example with more than about £0.56 of liability failed by the share (0.8353% in 2025). The oracle now reads the share from the parameters, asserts the households carry no energy-bill VAT, and always runs the minimal case as an explicit example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6J9S5H2Fv9MFjnDPCr --- changelog.d/2633.fixed.md | 2 +- .../tests/test_vat_grossing_properties.py | 62 ++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/changelog.d/2633.fixed.md b/changelog.d/2633.fixed.md index 0e5e7d303f..1ada80c7d3 100644 --- a/changelog.d/2633.fixed.md +++ b/changelog.d/2633.fixed.md @@ -1 +1 @@ -- Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product on a vector of households), a test that 2024 is grossed by the same product as later years, and a record of how the shipped coverage performs on the Microcosm UK cuts certified since `f9d1922c`. The coverage value is unchanged pending re-derivation on the next certified cut (#2633). +Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, less private school fees' share of VAT receipts, on a vector of households), a test that 2024 is grossed by the same product as later years, and a record of how the shipped coverage performs on the Microcosm UK cuts certified since `f9d1922c`. The coverage value is unchanged pending re-derivation on the next certified cut (#2633). diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py index e3eaf72e7f..3646610768 100644 --- a/policyengine_uk/tests/test_vat_grossing_properties.py +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -1,15 +1,16 @@ """Properties of VAT grossing that hold for every input (#2633). `vat` is the statutory liability on recorded consumption divided by one -grossing factor. The factor is the survey coverage times the household share -of receipts, unless a reform moves the deprecated combined parameter, in which +grossing factor, less private school fees' share of VAT receipts, plus VAT on +energy bills. The factor is the survey coverage times the household share of +receipts, unless a reform moves the deprecated combined parameter, in which case that value is used. """ from types import SimpleNamespace import pytest -from hypothesis import assume, given, settings +from hypothesis import assume, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -71,33 +72,46 @@ def test_legacy_factor_either_side_of_the_band(multiple, moved): assert factor == (legacy if moved else pytest.approx(0.35)) -@settings(max_examples=5, deadline=None) -@given(spend=st.lists(st.tuples(SPEND, SPEND), min_size=1, max_size=20)) -def test_vat_is_statutory_liability_over_the_shipped_product(spend): - # One simulation of many households, so the property is checked on a - # vector: VAT is linear in each household's recorded consumption, with - # no cross-household term. - p = system.parameters.gov - product = p.simulation.vat.survey_consumption_coverage( - 2025 - ) * p.simulation.vat.household_share_of_receipts(2025) - names = [f"h{i}" for i in range(len(spend))] +def household_vat(spend, year): + """`vat` for one household per (full-rate, reduced-rate) spend pair.""" situation = { - "people": {f"p{i}": {"age": {2025: 40}} for i in range(len(spend))}, + "people": {f"p{i}": {"age": {year: 40}} for i in range(len(spend))}, "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(spend))}, "households": { - name: { + f"h{i}": { "members": [f"p{i}"], - "full_rate_vat_consumption": {2025: full}, - "reduced_rate_vat_consumption": {2025: reduced}, + "full_rate_vat_consumption": {year: full}, + "reduced_rate_vat_consumption": {year: reduced}, } - for i, (name, (full, reduced)) in enumerate(zip(names, spend)) + for i, (full, reduced) in enumerate(spend) }, } - vat = Simulation(situation=situation).calculate("vat", 2025) + sim = Simulation(situation=situation) + # The households record no energy bills, so no energy-bill VAT is added. + assert (sim.calculate("domestic_energy_vat", year) == 0).all() + return sim.calculate("vat", year) + + +def expected_vat(full, reduced, year): + """Statutory liability over the shipped product, less the school share.""" + p = system.parameters(year).gov + product = ( + p.simulation.vat.survey_consumption_coverage + * p.simulation.vat.household_share_of_receipts + ) + liability = full * p.hmrc.vat.standard_rate + reduced * p.hmrc.vat.reduced_rate + return liability / product * (1 - p.simulation.vat.private_school_share_of_receipts) + + +# Each spend pair is (full_rate_vat_consumption, reduced_rate_vat_consumption). +@settings(max_examples=5, deadline=None) +@given(spend=st.lists(st.tuples(SPEND, SPEND), min_size=1, max_size=20)) +@example(spend=[(0.0, 15_438.0)]) +def test_vat_is_statutory_liability_over_the_shipped_product(spend): + # One simulation of many households, so the property is checked on a + # vector: VAT is linear in each household's recorded consumption, with + # no cross-household term. + vat = household_vat(spend, 2025) for value, (full, reduced) in zip(vat, spend): - expected = ( - full * p.hmrc.vat.standard_rate(2025) - + reduced * p.hmrc.vat.reduced_rate(2025) - ) / product + expected = expected_vat(full, reduced, 2025) assert value == pytest.approx(expected, rel=1e-5, abs=1e-2) From 36bbbf42f34a23968159406d1b6d8942ebcd6e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:24:01 +0000 Subject: [PATCH 086/168] Type the VAT fragment as added and pin the standard rate in the example The fragment moves to 2633.added.md with its text unchanged: against main this branch adds tests and docs and changes no model output. The statutory property's fixed example gains a full-rate household, so a formula that charges standard-rated spending at another rate fails on every run, not only on random draws. The module and oracle docstrings say the school share multiplies the grossed-up VAT by one minus the share. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHQ6oJEGs3igENBpYcM --- changelog.d/{2633.fixed.md => 2633.added.md} | 0 .../tests/test_vat_grossing_properties.py | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) rename changelog.d/{2633.fixed.md => 2633.added.md} (100%) diff --git a/changelog.d/2633.fixed.md b/changelog.d/2633.added.md similarity index 100% rename from changelog.d/2633.fixed.md rename to changelog.d/2633.added.md diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py index 3646610768..654d5bdfcb 100644 --- a/policyengine_uk/tests/test_vat_grossing_properties.py +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -1,10 +1,10 @@ """Properties of VAT grossing that hold for every input (#2633). `vat` is the statutory liability on recorded consumption divided by one -grossing factor, less private school fees' share of VAT receipts, plus VAT on -energy bills. The factor is the survey coverage times the household share of -receipts, unless a reform moves the deprecated combined parameter, in which -case that value is used. +grossing factor, times one minus private school fees' share of VAT receipts, +plus VAT on energy bills, which is not grossed up. The factor is the survey +coverage times the household share of receipts, unless a reform moves the +deprecated combined parameter, in which case that value is used. """ from types import SimpleNamespace @@ -93,7 +93,7 @@ def household_vat(spend, year): def expected_vat(full, reduced, year): - """Statutory liability over the shipped product, less the school share.""" + """Statutory liability over the shipped product, times (1 - school share).""" p = system.parameters(year).gov product = ( p.simulation.vat.survey_consumption_coverage @@ -106,7 +106,7 @@ def expected_vat(full, reduced, year): # Each spend pair is (full_rate_vat_consumption, reduced_rate_vat_consumption). @settings(max_examples=5, deadline=None) @given(spend=st.lists(st.tuples(SPEND, SPEND), min_size=1, max_size=20)) -@example(spend=[(0.0, 15_438.0)]) +@example(spend=[(0.0, 15_438.0), (10_000.0, 0.0)]) def test_vat_is_statutory_liability_over_the_shipped_product(spend): # One simulation of many households, so the property is checked on a # vector: VAT is linear in each household's recorded consumption, with From 70633f0897957186ff9128aac91eb98ea9d7c5ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:40:48 +0000 Subject: [PATCH 087/168] State the relief at source law and the search's limits as run Quote what FA 2004 s. 192(1) and (4) give for relief at source in the allocation's docstring and comment, YAML case 15 and the pension test. Give the first-threshold sample's counts, bound the float32 residue at under a penny, name the model's choice of PAYE coding for the Universal Credit slice, and note that non_savings_taxable_income reads non_savings_income_after_allowances through its two parts. No formula changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHPRRwcYXiPudqKR4bf --- .../least_tax_allocation.yaml | 11 +++--- .../tests/test_income_tax_allocation.py | 11 +++--- .../tests/test_uc_income_tax_properties.py | 11 +++--- .../utils/income_tax_allocation.py | 35 ++++++++++++------- policyengine_uk/utils/uc_income_tax.py | 9 ++--- .../non_savings_income_after_allowances.py | 3 +- 6 files changed, 49 insertions(+), 31 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index ba8b340e63..93084b4b88 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -314,10 +314,13 @@ personal_pension_contributions: 10_000 savings_interest_income: 30_000 output: - # The model takes personal contributions as relief at source, which - # extends the rate bands (FA 2004 s. 192(4)) rather than giving a Step 2 - # deduction the taxpayer can set against savings. The model deducts the - # relief from non-savings income, so the tax matches case 14. + # The model takes personal contributions as relief at source. Parliament + # deducts nothing from income: the payer keeps basic rate tax on the + # contribution (FA 2004 s. 192(1)) and, on a claim, the basic and higher + # rate limits rise by the contribution (s. 192(4)). Under the law the + # liability is £13,332 and the payer keeps £2,000; the model deducts the + # relief from non-savings income instead, which gives the same net + # £11,332, as in case 14. pension_contributions_relief: 10_000 received_allowances_earned_income: 22_570 received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 5502554d4c..d4d2b64896 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -713,10 +713,13 @@ def test_fast_path_reads_the_scottish_starter_rate(system): def test_pension_relief_stays_on_non_savings_income(system): """Pay of £40,000 with £10,000 of pension relief and £30,000 of savings in - 2026-27. The relief stays on the pay (FA 2004 ss. 192(4) and 193(2)), so - only the £12,570 allowance moves, and every share keeps the savings - allowance below the basic rate limit: the old order stands. Sharing the - pension relief would put the pay at the limit and save £100.""" + 2026-27. The relief stays on the pay: the employer deducts net pay + contributions from employment income (FA 2004 s. 193(2)), and for relief + at source Parliament raises the basic and higher rate limits instead + (s. 192(4)). So only the £12,570 allowance moves, and every share keeps + the savings allowance below the basic rate limit: the old order stands. + Sharing the pension relief would put the pay at the limit and save + £100.""" income_tax = system.parameters("2026").gov.hmrc.income_tax inputs, default = one_person( savings=30_000, diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py index 1600b44762..97f4b05284 100644 --- a/policyengine_uk/tests/test_uc_income_tax_properties.py +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -2,11 +2,12 @@ earnings (uc_income_tax_on_earnings and uc_income_tax_on_trade). The helper takes earnings as the lowest slice of non-savings income, after the -allowances set against non-savings income first, as PAYE codes set them -against the job. The least-tax share of the reliefs and allowances (ITA 2007 -s. 25(2)) can move the allowances onto savings or dividends to lower the -year's tax, but the slice follows the order non-savings, savings, dividends, -so tax on other income never comes off earnings: +allowances set against non-savings income first: the model follows PAYE +coding, which sets them against the job. The least-tax share of the reliefs +and allowances (ITA 2007 s. 25(2)) can move the allowances onto savings or +dividends to lower the year's tax, but the slice follows the order +non-savings, savings, dividends, so tax on other income never comes off +earnings: - adding savings or dividend income never raises either slice, for every person whose allowances it leaves unchanged (more income can taper the diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 71cbd784d4..834a47c799 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -21,11 +21,15 @@ Pension relief is the exception. The model takes employee contributions as a net pay arrangement, which the employer deducts from employment income (FA -2004 s. 193(2)), and personal contributions as relief at source, which -extends the rate bands (s. 192(4)). Neither is a Step 2 deduction the -taxpayer can set against savings or dividends, so the search holds -``min(pension relief, non-savings income)`` on non-savings income and shares -only the rest. +2004 s. 193(2)), and personal contributions as relief at source, under which +the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a +claim where higher or additional rate tax is chargeable, Parliament raises +the basic and higher rate limits by the contribution (s. 192(4)). Deducting +the contribution from the bottom of non-savings income moves every slice of +income down against those limits, as raising them does. Neither arrangement +gives a Step 2 deduction the taxpayer can set against savings or dividends, +so the search holds ``min(pension relief, non-savings income)`` on +non-savings income and shares only the rest. How the search finds the least tax ---------------------------------- @@ -73,10 +77,12 @@ income (s. 12) and a first UK rate threshold at nil, as in every year modelled. A reform that sets that threshold above nil leaves savings below it untaxed and adds lines on which ``x + w`` stays constant, which the search -doesn't list, so the search can then miss the least tax; the fast path sends -everyone to the search under such a reform. The search keeps the -order non-savings, savings, dividends unless another share gives less tax, so -it never charges more than that order. +doesn't list, so the search can then miss the least tax. On 6,520 synthetic +people with that threshold at £2,000, a 101 x 101 grid found less tax than +the search for 32 in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to +£214.75). The fast path sends everyone to the search under such a reform. +The search keeps the order non-savings, savings, dividends unless another +share gives less tax, so it never charges more than that order. The search ignores the Marriage Allowance. Parliament gives it as a tax reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a @@ -216,9 +222,11 @@ def _build(inputs, income_tax): # net_income, not its components, so the search follows a supplied adjusted # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) - # Net pay comes off employment income (FA 2004 s. 193(2)) and relief at - # source extends the bands (s. 192(4)), so pension relief stays on - # non-savings income as far as it reaches; the search shares the rest. + # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at + # source Parliament raises the basic and higher rate limits instead + # (s. 192(4)), which deducting the relief from the bottom of non-savings + # income matches. So pension relief stays on non-savings income as far + # as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), @@ -667,7 +675,8 @@ def non_savings_deductions(person, period): non-savings, savings, dividends, the function returns that order's amounts exactly. Elsewhere it takes the non-savings income left from the share's own arithmetic, in float64, so float32 rounding in a sum of - net income and the relief added back leaves no residue above nil. + net income and the relief added back leaves no residue above nil beyond + the float32 rounding of the moved share, under a penny. """ default = default_order_deductions(person, period) to_savings = person("received_allowances_savings_income", period) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index 70ea63aba6..e47f8d13d3 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -60,10 +60,11 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr ) # The slice follows the order non-savings, savings, dividends, not the # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): - # PAYE codes set the allowances against the job, and moving them onto - # savings or dividends to lower the year's tax would otherwise raise - # the tax on earnings. So the earnings slice reads the old order's - # deductions, and more savings or dividends never raise it. + # the model follows PAYE coding, which sets the allowances against the + # job; moving them onto savings or dividends to lower the year's tax + # would otherwise raise the tax on earnings. So the earnings slice reads + # the old order's deductions, and more savings or dividends never raise + # it. default = default_order_deductions(person, period) # The income left in earned taxable income before allowances, under # that order. The part of it above earnings is the person's other diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py index f5c5467c75..663cc3ddc3 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -17,7 +17,8 @@ class non_savings_income_after_allowances(Variable): "property income (s. 25(3A)). The allocation computes it from its own " "share, without the float32 rounding of a sum that adds the relief " "back. property_income_tax, taxable_property_income_after_allowances " - "and earned_taxable_income read it." + "and earned_taxable_income read it, and non_savings_taxable_income " + "reads it through the last two." ) definition_period = YEAR reference = [ From 4574ee1620081ebaa08f64cb9925362d7a6debca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:37:15 +0000 Subject: [PATCH 088/168] Split Council Tax Reduction's tax deduction as Pension Credit does, and price self-employed tax notionally Every scheme that deducts tax from earnings now takes the tax on earnings as the extra tax they cause, with the earnings as the top slice of income, as main's Pension Credit code does (option (a) of #2019). The Scottish working-age scheme without Universal Credit and the pensioner schemes of England, Wales and Scotland price the tax on self-employed profit at the basic rate less the personal reliefs, with Class 4 between the limits and Class 2 before each omission. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- changelog.d/2090.fixed.md | 2 +- ...l_tax_reduction_carer_support_payment.yaml | 7 +- .../council_tax_reduction_tax_deduction.yaml | 392 +++++++++++++- ...l_tax_reduction_carer_income_properties.py | 45 +- ..._tax_reduction_tax_deduction_properties.py | 505 ++++++++++++++---- ...tion_income_tax_on_other_counted_income.py | 54 ++ ...ion_scotland_self_employment_deductions.py | 87 +++ .../council_tax_reduction_tax_deduction.py | 264 ++++----- 8 files changed, 1048 insertions(+), 308 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index ed9a67373c..fe34b9d788 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -Council Tax Reduction income now deducts only the income tax and National Insurance each scheme allows: tax on earnings in Scotland's working-age scheme, and no tax on savings, dividends or the Scottish Carer Supplement in the pensioner schemes of England, Wales and Scotland. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement where earnings do not raise it. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index 637e7844ad..c4c5f44b96 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -13,9 +13,10 @@ # age SSI 2021/249 deducts tax only from earnings, and at pension age SSI # 2012/319 reg 31(12) disregards the tax payable on income taken into # account, which the supplement is not. The model deducts only the tax each -# scheme allows (council_tax_reduction_tax_deduction), with the supplement's -# tax taken as the highest part of non-savings income, so the supplement does -# not reach the model's Council Tax Reduction income. +# scheme allows (council_tax_reduction_tax_deduction). At working age that is +# the tax earnings add, and at pension age the tax on the counted income with +# the supplement above it, so without earnings the supplement does not reach +# the model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 17c68f8f51..b681ab26b6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -1,23 +1,37 @@ # Income tax and National Insurance deducted from Council Tax Reduction income. -# - Scotland, working age: the Council Tax Reduction (Scotland) Regulations -# 2021 (SSI 2021/249) reg 50(3)(a) reads "Earnings do not include— (a) any -# amount deducted from them by way of income tax or primary Class 1 -# contributions under the 1992 Act"; regs 49(6)(b) and 51(2) do the same, -# and the unearned income rules (regs 57 to 65) deduct no tax. So only tax -# and National Insurance on earnings come off. +# - Scotland, working age (SSI 2021/249): reg 50(3)(a) reads "Earnings do not +# include— (a) any amount deducted from them by way of income tax or +# primary Class 1 contributions under the 1992 Act". The Scottish Ministers +# set the same rule with Universal Credit (regs 49(6)(b) and 51(2) step 3) +# and gave no tax deduction for unearned income (regs 57 to 65). Without +# Universal Credit, reg 51(9) applies SSI 2012/303 regs 37 and 38, which +# price the tax and contributions on self-employed profit notionally: the +# basic rate on chargeable income less only the personal allowance, and +# Class 4 at the main rate between the lower and upper limits. # - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken # into account for the purpose of calculating a person's income, any amount # payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) -# and WSI 2013/3029 Sch 1 para 11(13) read the same. So tax on income not -# taken into account (savings interest, dividends, the Scottish Carer -# Supplement) does not come off. +# and WSI 2013/3029 Sch 1 para 11(13) read the same. Reg 33(2)(a), para +# 19(2)(a) and para 13(2)(a) deduct the income tax and Class 1 deducted from +# earnings, and reg 37, para 30 and para 24 price self-employed profit +# notionally, as at working age. +# No provision says how to split one income tax liability. The model follows +# the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +# (option (a)): the tax on earnings is the extra tax the earnings cause, with +# the earnings as the top slice of income and self-employed profit beneath +# them. In the pensioner schemes the tax on the other counted income is the +# tax it bears with the uncounted income above it. # -# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable income: -# 19% to 3,967, 20% to 16,956, 21% to 31,092. UK basic rate 20% to 37,700. -# Class 1 primary contributions 8% above 241.73 a week (12,569.96 a year). -# Each family is a single adult with no Pension Credit or Universal Credit. +# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable non- +# savings income: 19% to 3,967, 20% to 16,956, 21% to 31,092, 42% to 62,430. +# UK basic rate 20% to 37,700, higher rate 40%. Savings: starting rate band +# 5,000 less taxable non-savings income; personal savings allowance 1,000 for +# a basic-rate taxpayer and 500 for a higher-rate one. Class 1 primary +# contributions 8% above 241.73 a week (12,569.96 a year). Class 4 6% from +# 12,570 to 50,270. Each family is a single adult with no Pension Credit or +# Universal Credit unless stated. -- name: Scotland's working-age scheme deducts tax and National Insurance on earnings only +- name: Scotland's working-age scheme deducts the tax that pay adds to a pension period: 2026 absolute_error_margin: 0.01 input: @@ -37,13 +51,240 @@ region: SCOTLAND council_tax: 2_000 output: - # Tax on earnings, the earnings as the lowest part of income: - # 20,000 - 12,570 = 7,430 taxable; 3,967 x 19% = 753.73 and - # 3,463 x 20% = 692.60, so 1,446.33. Class 1: (20,000 - 12,569.96) x 8% = - # 594.40. The tax on the private pension is not deducted. + # Tax with the pay: 30,000 - 12,570 = 17,430 taxable; 3,967 x 19% = 753.73, + # 12,989 x 20% = 2,597.80 and 474 x 21% = 99.54, so 3,451.07. Tax without + # the pay: 10,000 is below the personal allowance, so nil. The pay adds + # 3,451.07. Class 1: (20,000 - 12,569.96) x 8% = 594.40. + council_tax_reduction_tax_deduction: 4_045.47 + # 20,000 + 10,000 - 4,045.47. + council_tax_reduction_applicable_income: 25_954.53 + +- name: Scotland's working-age scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + savings_interest_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: non-savings 17,430 taxable, 3,451.07 as above. Savings + # 60,000 sit above 17,430 in the UK bands: no starting rate; 500 personal + # savings allowance; 37,700 - 17,430 - 500 = 19,770 x 20% = 3,954; and + # 60,000 - 20,270 = 39,730 x 40% = 15,892. Total 23,297.07. + # Without the pay: 60,000 - 12,570 = 47,430 taxable savings; 5,000 at the + # starting rate and 500 allowance at nil, 32,200 x 20% = 6,440 and + # 9,730 x 40% = 3,892, so 10,332. The pay adds 12,965.07. + # Class 1: (30,000 - 12,569.96) x 8% = 1,394.40. + council_tax_reduction_tax_deduction: 14_359.47 + # The scheme counts the pay alone: 30,000 - 14,359.47. + council_tax_reduction_applicable_income: 15_640.53 + +- name: Scotland's working-age scheme deducts the tax that pay adds to property income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + property_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: 50,000 - 12,570 = 37,430 taxable; 753.73 + 2,597.80 + + # 14,136 x 21% = 2,968.56 + 6,338 x 42% = 2,661.96, so 8,982.05. Without + # the pay: 7,430 taxable; 753.73 + 3,463 x 20% = 692.60, so 1,446.33. The + # pay adds 7,535.72. Class 1 1,394.40. + council_tax_reduction_tax_deduction: 8_930.12 + # 30,000 + 20,000 - 8,930.12. + council_tax_reduction_applicable_income: 41_069.88 + +- name: Scotland's working-age scheme keeps benefits in kind beneath the pay + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + company_car_benefit: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Reg 50(3)(e): a payment in kind is not earnings, so the car benefit stays + # in both terms. With the pay: 45,000 - 12,570 = 32,430 taxable; 753.73 + + # 2,597.80 + 2,968.56 + 1,338 x 42% = 561.96, so 6,882.05. Without the pay: + # 15,000 - 12,570 = 2,430 x 19% = 461.70. The pay adds 6,420.35. Class 1 + # 1,394.40. + council_tax_reduction_tax_deduction: 7_814.75 + # 30,000 - 7,814.75: the car benefit is not counted. + council_tax_reduction_applicable_income: 22_185.25 + +- name: Scotland's working-age scheme prices tax on a 15,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/303 reg 38: (15,000 - 12,570) x 20% = 486 and Class 4 + # (15,000 - 12,570) x 6% = 145.80. That exceeds the 461.70 + 145.80 the + # person pays, as the rule intends. + council_tax_reduction_tax_deduction: 631.80 + # 15,000 - 631.80. + council_tax_reduction_applicable_income: 14_368.20 + +- name: Scotland's working-age scheme prices tax on a 60,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # (60,000 - 12,570) x 20% = 9,486, and Class 4 only between the limits: + # (50,270 - 12,570) x 6% = 2,262. + council_tax_reduction_tax_deduction: 11_748 + # 60,000 - 11,748. + council_tax_reduction_applicable_income: 48_252 + +- name: Scotland's working-age scheme with Universal Credit deducts the tax that profit adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + self_employment_income: 15_000 + savings_interest_income: 20_000 + child_1: + age: 5 + child_2: + age: 3 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + universal_credit: 7_190.20 + # Reg 51(2) step 3 deducts the tax and contributions paid. With the + # profit: non-savings 2,430 x 19% = 461.70; savings 20,000 over a 2,570 + # starting rate band and a 1,000 allowance, 16,430 x 20% = 3,286; total + # 3,747.70. Without it: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The profit adds 3,461.70. Class 4 + # charged: 145.80. + council_tax_reduction_tax_deduction: 3_607.50 + +- name: Scotland's working-age scheme counted Class 2 in the notional deduction before 2024-25 + period: 2023 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # 2023-24: SSI 2012/303 reg 38 as it stood on 5 April 2024. Income tax + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2, as the profit exceeds the 12,570 lower profits + # threshold, 3.45 x 52 weeks = 179.40. + council_tax_reduction_tax_deduction: 2_334.10 + # 20,000 - 2,334.10. + council_tax_reduction_applicable_income: 17_665.90 + +- name: Scotland's working-age scheme deducts no voluntary Class 3 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + ni_class_3: 900 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax on the pay: 7,430 taxable; 753.73 + 692.60 = 1,446.33. Class 1: + # 594.40. Class 3 is voluntary, not a primary Class 1 contribution + # deducted from earnings, so the 900 does not come off. council_tax_reduction_tax_deduction: 2_040.73 - # 20,000 + 10,000 - 2,040.73. - council_tax_reduction_applicable_income: 27_959.27 + # 20,000 - 2,040.73. + council_tax_reduction_applicable_income: 17_959.27 - name: England's pensioner scheme deducts no tax on savings interest period: 2026 @@ -64,12 +305,64 @@ region: NORTH_WEST council_tax: 2_000 output: - # The scheme counts the pension, not the interest. Tax on the pension: - # (20,308 - 12,570) x 20% = 1,547.60. + # The scheme counts the pension, not the interest. Tax on the pension + # with the interest above it: (20,308 - 12,570) x 20% = 1,547.60. council_tax_reduction_tax_deduction: 1_547.60 # 20,308 - 1,547.60. council_tax_reduction_applicable_income: 18_760.40 +- name: England's pensioner scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 20_000 + savings_interest_income: 20_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 7,430 x 20% = 1,486 on the pay; savings 20,000 with no + # starting rate, a 1,000 allowance and 19,000 x 20% = 3,800; total 5,286. + # Without the pay: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The pay adds 5,000. The scheme counts no + # other taxed income, and the person pays no Class 1 above pension age. + council_tax_reduction_tax_deduction: 5_000 + # 20,000 - 5,000 - the 5 a week earnings disregard (260). + council_tax_reduction_applicable_income: 14_740 + +- name: England's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # SI 2012/2885 Sch 1 para 30: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Wales's pensioner scheme deducts no tax on dividends period: 2026 absolute_error_margin: 0.01 @@ -95,6 +388,30 @@ # 20,000 - 1,486. council_tax_reduction_applicable_income: 18_514 +- name: Wales's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # WSI 2013/3029 Sch 1 para 24: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Scotland's pensioner scheme deducts no tax on the Scottish Carer Supplement period: 2026 absolute_error_margin: 0.01 @@ -115,13 +432,38 @@ region: SCOTLAND council_tax: 2_000 output: - # SSI 2012/319 reg 27(1)(j)(xxib) excepts the supplement, so the scheme - # counts the pension alone. Tax on the pension with the supplement above - # it: (15,000 - 12,570) x 19% = 461.70. + # The Scottish pensioner scheme does not count the Scottish Carer + # Supplement as income (SSI 2012/319 reg 27(1)(j)(xxib)), so its tax, as + # the highest part of income, does not come off. Tax on the pension: + # (15,000 - 12,570) x 19% = 461.70. council_tax_reduction_tax_deduction: 461.70 # 15,000 - 461.70. council_tax_reduction_applicable_income: 14_538.30 +- name: Scotland's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/319 reg 37: (60,000 - 12,570) x 20% = 9,486, not the 13,182.05 + # in Scottish bands the profit bears. No Class 4 above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + - name: Wales's working-age scheme still deducts the whole income tax period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 2eac04743a..6ec86bd612 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -15,11 +15,14 @@ 27(1)(j)(xxib) excepts it. Nor does the tax on the supplement count: at working age SSI 2021/249 deducts income tax only from earnings (Part 6 Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax -payable on income taken into account, which the supplement is not. So in law -the supplement, which is taxable (SI 2026/93), does not touch Council Tax -Reduction at all. The model deducts the tax and National Insurance each -scheme allows (council_tax_reduction_tax_deduction), with the supplement's -tax taken as the highest part of non-savings income. +payable on income taken into account, which the supplement is not. The +supplement is taxable (SI 2026/93). The model deducts the tax and National +Insurance each scheme allows (council_tax_reduction_tax_deduction). At working +age that includes the extra tax earnings cause, with the earnings as the top +slice of income (the choice settled for Pension Credit on +PolicyEngine/policyengine-uk#2019, option (a)), so the supplement beneath the +earnings can raise the tax the earnings add. At pension age the formula +deducts the tax on the counted income with the supplement above it. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all @@ -33,12 +36,14 @@ reduction itself never rises, and where both awards are partial it falls by 20% of the rise in income less the rise in the applicable amount (the carer premium comes with the claim). -2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment, the other benefits counted in Council Tax - Reduction income (Child Benefit, the income-related benefits, Universal - Credit and tax credits) and Council Tax Reduction income itself unchanged - under the general rules. The supplement never lowers the reduction, and - under the general rules never changes it. +2. Invariant to the supplement, apart from the tax earnings add: setting the + Scottish Carer Supplement to zero leaves Carer Support Payment and the + other benefits counted in Council Tax Reduction income (Child Benefit, the + income-related benefits, Universal Credit and tax credits) unchanged. + Under the general rules it changes Council Tax Reduction income only by + the change in the deduction for income tax and National Insurance, and in + a family with no earnings it changes neither that income nor the + reduction. The supplement never lowers the reduction. 3. Differential: Council Tax Reduction and Housing Benefit assess the same carer income. Where Guarantee Credit does not passport Housing Benefit, Housing Benefit applicable income, before its disregard, childcare element @@ -343,17 +348,25 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( # Where both runs follow the general rules. general = g["general_rules"][i] & without["general_rules"][i] assert general.any(), fam - assert np.allclose(income[general], income_without[general], atol=0.01), ( - fam, - income[general], - income_without[general], + # The supplement reaches the income only through the deduction. + assert np.allclose( + (income + g[CTR_TAX][i])[general], + (income_without + without[CTR_TAX][i])[general], + atol=0.01, + ), (fam, income[general], income_without[general]) + has_earnings = any( + a.get("employment_income", 0.0) > 0 for a in fam["adults"] ) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] # The supplement never lowers the award, even on the Pension Credit # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam - assert np.allclose(award[general], award_without[general], atol=0.01), fam + if not has_earnings: + assert np.allclose( + income[general], income_without[general], atol=0.01 + ), (fam, income[general], income_without[general]) + assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 4ccf6dcd27..71ddc95699 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -3,18 +3,26 @@ Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations 2021, SSI 2021/249) takes income tax and primary Class 1 contributions off earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules -(regs 57 to 65) deduct no tax. The pensioner schemes disregard tax only on -income taken into account: SSI 2012/319 reg 31(12) in Scotland, SI 2012/2885 -Sch 1 para 17(13) in England and WSI 2013/3029 Sch 1 para 11(13) in Wales. -council_tax_reduction_tax_deduction applies these rules, and -council_tax_reduction_applicable_income deducts it. - -Each test builds single-adult households by hand in 2026, with no Universal -Credit claim, and states its invariant in its docstring. +(regs 57 to 65) deduct no tax. Without Universal Credit, reg 51(9) applies the +notional tax and contributions of SSI 2012/303 reg 38 to self-employed profit. +The pensioner schemes disregard tax only on income taken into account and on +earnings: SSI 2012/319 regs 31(12), 33(2)(a) and 37 in Scotland, SI 2012/2885 +Sch 1 paras 17(13), 19(2)(a) and 30 in England, and WSI 2013/3029 Sch 1 paras +11(13), 13(2)(a) and 24 in Wales. + +No provision says how to split one income tax liability. The model follows +the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +(option (a)): the tax on earnings is the extra tax the earnings cause, with +the earnings as the top slice of income. council_tax_reduction_tax_deduction +applies these rules, and council_tax_reduction_applicable_income deducts it. + +Each test builds households by hand in 2026, states its invariant in its +docstring, and records the scheme of each household it draws as a Hypothesis +event, so that `--hypothesis-show-statistics` counts the examples per scheme. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, event, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -27,19 +35,29 @@ derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -WHOLE_TAX = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] -# Taxed income that is not earnings. A working-age adult has no State Pension. -UNEARNED = [ - "private_pension_income", - "property_income", +DEDUCTION = "council_tax_reduction_tax_deduction" +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +NO_SCOTTISH_CARER_SUPPLEMENT = { + "gov.social_security_scotland.carer_support_payment.supplement": { + "2026-01-01.2100-12-31": 0 + } +} +# Taxed income the pensioner schemes do not count, as inputs. +UNCOUNTED = [ "savings_interest_income", + "other_investment_income", "dividend_income", + "miscellaneous_income", + "company_car_benefit", ] -# Taxed income the pensioner schemes do not take into account. -UNCOUNTED = [ +# Taxed income other than earnings that Scotland's working-age scheme counts +# or treats as capital. +UNEARNED = [ + "private_pension_income", + "property_income", "savings_interest_income", "dividend_income", - "scottish_carer_supplement", + "miscellaneous_income", ] @@ -47,151 +65,412 @@ def money(high): return st.floats(0, high, allow_nan=False, allow_infinity=False) -def simulate(people, country): - """One single-adult household per entry of people, all in one country.""" +def maybe(high): + return st.one_of(st.just(0.0), money(high)) + + +def simulate(households, reform=None): + """One simulation of households. Each household is a dict with country, + benunits (a list of lists of person input dicts; the first benunit is + the Council Tax Reduction applicant's) and, optionally, uc.""" situation = {"people": {}, "benunits": {}, "households": {}} - for i, inputs in enumerate(people): - name = f"p{i}" - situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} - situation["benunits"][f"b{i}"] = { - "members": [name], - "would_claim_uc": {YEAR: False}, - } - situation["households"][f"h{i}"] = { - "members": [name], + for h, household in enumerate(households): + names = [] + for b, adults in enumerate(household["benunits"]): + members = [] + for p, inputs in enumerate(adults): + name = f"p{h}_{b}_{p}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + members.append(name) + situation["benunits"][f"b{h}_{b}"] = { + "members": members, + "would_claim_uc": {YEAR: household.get("uc", False)}, + } + names += members + country = household["country"] + situation["households"][f"h{h}"] = { + "members": names, "country": {YEAR: country}, "region": {YEAR: REGIONS[country]}, "council_tax": {YEAR: 2_000.0}, "savings": {YEAR: 0.0}, } - return Simulation(situation=situation) + return Simulation(situation=situation, reform=reform) def benunit_values(simulation, variable): return np.asarray(simulation.calculate(variable, YEAR)) -def person_values(simulation, variables): - return sum( - np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) for v in variables +def applicant_total(simulation, variable): + """The Council Tax Reduction applicant's and partner's total, by benunit.""" + values = np.asarray(simulation.calculate(variable, YEAR)) + members = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) + return np.asarray( + simulation.map_result(values * members, "person", "benunit", how="sum") ) +def scheme_of(country, pensioner, uc=False): + if pensioner: + return f"{country.lower()} pensioner" + if country == "SCOTLAND": + return "scotland working age " + ("with UC" if uc else "without UC") + return f"{country.lower()} working age" + + @st.composite -def working_age_adult(draw): - return dict( - age=draw(st.integers(25, 60)), - employment_income=draw(st.one_of(st.just(0.0), money(80_000))), - self_employment_income=draw(st.one_of(st.just(0.0), money(40_000))), - **{v: draw(st.one_of(st.just(0.0), money(40_000))) for v in UNEARNED}, +def adult(draw, pensioner, earnings=True, self_employment=True): + person = dict( + age=draw(st.integers(67, 90)) if pensioner else draw(st.integers(25, 60)), + employment_income=draw(maybe(90_000)) if earnings else 0.0, + self_employment_income=draw(maybe(90_000)) if self_employment else 0.0, + private_pension_income=draw(maybe(60_000)), + property_income=draw(maybe(30_000)), + **{v: draw(maybe(40_000)) for v in UNCOUNTED}, ) + if pensioner: + person["state_pension"] = draw(money(12_000)) + return person + + +@st.composite +def household(draw, countries=tuple(REGIONS), pensioner=None, **kwargs): + country = draw(st.sampled_from(countries)) + is_pensioner = draw(st.booleans()) if pensioner is None else pensioner + adults = draw(st.lists(adult(is_pensioner, **kwargs), min_size=1, max_size=2)) + return dict(country=country, benunits=[adults], pensioner=is_pensioner) + + +def schemes(simulation, households): + pensioner = benunit_values(simulation, "council_tax_reduction_pensioner") + universal_credit = benunit_values(simulation, "universal_credit") > 0 + labels = [] + for i, h in enumerate(households): + assert pensioner[i] == h["pensioner"], h + labels.append(scheme_of(h["country"], pensioner[i], universal_credit[i])) + event(labels[-1]) + return labels @PROPERTY_SETTINGS @given( - st.lists(working_age_adult(), min_size=1, max_size=4), - st.sampled_from(UNEARNED), - money(30_000), + st.lists( + household(countries=("SCOTLAND",), pensioner=False, self_employment=False), + min_size=1, + max_size=4, + ) ) -def test_scottish_working_age_income_never_falls_as_taxed_unearned_income_rises( - adults, unearned, rise +def test_scottish_working_age_deduction_matches_pension_credit_earnings_tax( + households, ): - """In Scotland's working-age scheme, Council Tax Reduction applicable income - never falls when a taxed unearned income rises and earnings stay fixed.""" - raised = [{**a, unearned: a[unearned] + rise} for a in adults] - base = simulate(adults, "SCOTLAND") - more = simulate(raised, "SCOTLAND") - assert not benunit_values(base, "council_tax_reduction_pensioner").any() - before = benunit_values(base, "council_tax_reduction_applicable_income") - after = benunit_values(more, "council_tax_reduction_applicable_income") - assert np.all(after >= before - 0.01), (adults, unearned, rise, before, after) + """Invariant 1. At working age in Scotland without self-employment income, + the deduction less primary Class 1 contributions equals the income tax on + employed earnings that Pension Credit computes + (pension_credit_employment_income_tax), wherever the Council Tax + Reduction applicant and partner are the Pension Credit claimant and + partner.""" + simulation = simulate(households) + schemes(simulation, households) + same_people = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) == np.asarray(simulation.calculate("is_claimant_or_partner", YEAR)) + assert same_people.all() + deduction = benunit_values(simulation, DEDUCTION) + class_1 = applicant_total(simulation, "ni_class_1_employee") + pension_credit = benunit_values(simulation, "pension_credit_employment_income_tax") + assert np.allclose(deduction - class_1, pension_credit, atol=0.01), ( + households, + deduction, + class_1, + pension_credit, + ) @PROPERTY_SETTINGS -@given(st.lists(working_age_adult(), min_size=1, max_size=4)) -def test_scottish_working_age_deducts_no_tax_without_earnings(adults): - """In Scotland's working-age scheme, with no earnings the deduction for - income tax and National Insurance is nil, however much other taxed income - there is.""" - no_earnings = [ - {**a, "employment_income": 0.0, "self_employment_income": 0.0} for a in adults - ] - simulation = simulate(no_earnings, "SCOTLAND") - deduction = benunit_values(simulation, "council_tax_reduction_tax_deduction") - assert np.all(deduction == 0), (no_earnings, deduction) +@given( + st.lists(household(pensioner=True, earnings=False), min_size=1, max_size=4), + st.sampled_from(UNCOUNTED), + money(60_000), + st.booleans(), +) +def test_pensioner_deduction_ignores_uncounted_income_without_earnings( + households, uncounted, change, partner +): + """Invariant 2. In every pensioner scheme, with no employed earnings, the + deduction does not change when any uncounted income changes, for either + member of a couple. The notional amount on self-employed profit reads the + personal reliefs to which the person is entitled, which the uncounted + income can taper, so the test compares the deduction less that amount.""" + changed = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = len(adults) - 1 if partner else 0 + adults[who][uncounted] = adults[who][uncounted] + change + changed.append({**h, "benunits": [adults]}) + before = simulate(households) + after = simulate(changed) + schemes(before, households) + def without_notional(simulation): + notional = np.where( + np.asarray( + [h["country"] == "SCOTLAND" for h in households], dtype=bool + ), + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + return benunit_values(simulation, DEDUCTION) - notional -@st.composite -def pensioner(draw): - # Pensions reach far enough, with the uncounted income, to cross the - # personal allowance taper. - return dict( - age=draw(st.integers(67, 90)), - state_pension=draw(money(12_000)), - private_pension_income=draw(money(140_000)), - property_income=draw(st.one_of(st.just(0.0), money(15_000))), - savings_interest_income=draw(st.one_of(st.just(0.0), money(20_000))), - dividend_income=draw(st.one_of(st.just(0.0), money(20_000))), - scottish_carer_supplement=draw(st.sampled_from([0.0, 608.40])), + assert np.allclose(without_notional(before), without_notional(after), atol=0.01), ( + households, + uncounted, + change, + without_notional(before), + without_notional(after), ) +@st.composite +def any_household(draw): + h = draw(household()) + if h["country"] == "SCOTLAND" and not h["pensioner"]: + # Children make a Universal Credit award likely. + h["uc"] = draw(st.booleans()) + if h["uc"]: + h["benunits"][0] += [dict(age=4), dict(age=7)] + return h + + @PROPERTY_SETTINGS -@given( - st.lists(pensioner(), min_size=1, max_size=4), - st.sampled_from(sorted(REGIONS)), -) -def test_pensioner_schemes_deduct_no_more_than_the_tax_on_counted_income( - pensioners, country -): - """In the pensioner schemes of England, Wales and Scotland, the income tax - and National Insurance deducted never exceed what the same person would - pay with only the income the scheme counts, nor the whole liability.""" - counted_only = [{**p, **{v: 0.0 for v in UNCOUNTED}} for p in pensioners] - full = simulate(pensioners, country) - alone = simulate(counted_only, country) - assert benunit_values(full, "council_tax_reduction_pensioner").all() - deduction = benunit_values(full, "council_tax_reduction_tax_deduction") - tax_on_counted_income = person_values(alone, WHOLE_TAX) - whole_tax = person_values(full, WHOLE_TAX) - assert np.all(deduction <= tax_on_counted_income + 0.01), ( - pensioners, +@given(st.lists(any_household(), min_size=1, max_size=4)) +def test_deduction_bounds(households): + """Invariant 3. The deduction is never negative. Without self-employment + income it never exceeds the applicant's and partner's income tax before + the winter fuel payment charge plus National Insurance. With it, the + notional amount the schemes set on profit can take the deduction above + that liability, as the law intends, but never above the liability plus + the notional amount.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + whole = applicant_total(simulation, INCOME_TAX) + applicant_total( + simulation, "national_insurance" + ) + profit = applicant_total(simulation, "self_employment_income") + notional = np.maximum( + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + assert np.all(deduction >= 0), (households, deduction) + assert np.all((deduction <= whole + 0.01)[profit == 0]), ( + households, deduction, - tax_on_counted_income, + whole, ) - assert np.all(deduction <= whole_tax + 0.01), (pensioners, deduction, whole_tax) - # Taking away the uncounted income leaves the deduction where it was. - deduction_alone = benunit_values(alone, "council_tax_reduction_tax_deduction") - assert np.allclose(deduction, deduction_alone, atol=0.01), ( - pensioners, + assert np.all(deduction <= whole + notional + 0.01), ( + households, deduction, - deduction_alone, + whole, + notional, ) +@PROPERTY_SETTINGS +@given( + st.lists( + household( + countries=("SCOTLAND",), + pensioner=False, + earnings=False, + self_employment=False, + ), + min_size=1, + max_size=4, + ) +) +def test_scottish_working_age_deducts_nothing_without_earnings(households): + """Invariant 4. At working age in Scotland, with no earnings and no + profit, the deduction is nil, however much other taxed income there is.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + assert np.all(deduction == 0), (households, deduction) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("SCOTLAND",), pensioner=False), min_size=1, max_size=4 + ), + money(20_000), + st.booleans(), +) +def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): + """Invariant 5. At working age in Scotland without Universal Credit, + raising either member's employment income never raises Council Tax + Reduction.""" + raised = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = len(adults) - 1 if partner else 0 + adults[who]["employment_income"] += rise + raised.append({**h, "benunits": [adults]}) + before = simulate(households) + after = simulate(raised) + schemes(before, households) + reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) + reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) + assert np.all(reduction_after <= reduction_before + 0.01), ( + households, + rise, + partner, + reduction_before, + reduction_after, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(any_household(), min_size=1, max_size=3), + adult(pensioner=False), + adult(pensioner=False), +) +def test_non_dependant_income_never_changes_the_deduction( + households, non_dependant, other_income +): + """Invariant 6. A non-dependant's income never changes the deduction: + adding a working-age adult with their own benefit unit to the household, + and changing that adult's income, leaves the applicant's deduction where + it was.""" + with_non_dependant = [ + {**h, "benunits": h["benunits"] + [[non_dependant]]} for h in households + ] + changed = [ + {**h, "benunits": h["benunits"] + [[other_income]]} for h in households + ] + alone = simulate(households) + schemes(alone, households) + first = simulate(with_non_dependant) + second = simulate(changed) + # The applicant's benunit is the first in each household. + applicant = np.arange(len(households)) * 2 + deduction = benunit_values(alone, DEDUCTION) + for simulation in (first, second): + assert np.allclose( + benunit_values(simulation, DEDUCTION)[applicant], deduction, atol=0.01 + ), (households, non_dependant, other_income) + + CASES = [ - ("SCOTLAND", dict(age=40, employment_income=20_000.0, dividend_income=9_000.0)), - ("SCOTLAND", dict(age=70, private_pension_income=15_000.0)), - ("ENGLAND", dict(age=70, private_pension_income=20_308.0)), - ("WALES", dict(age=70, private_pension_income=20_000.0)), + dict( + country="SCOTLAND", + benunits=[[dict(age=40, employment_income=30_000.0, dividend_income=9_000.0)]], + ), + dict(country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]]), + dict( + country="SCOTLAND", + uc=True, + benunits=[ + [ + dict(age=35, self_employment_income=15_000.0), + dict(age=5), + dict(age=3), + ] + ], + ), + dict(country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]]), + dict( + country="ENGLAND", + benunits=[[dict(age=70, employment_income=20_000.0)]], + ), + dict( + country="WALES", + benunits=[[dict(age=70, self_employment_income=60_000.0)]], + ), ] def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_first(): - """Council Tax Reduction and its tax deduction come out the same whether - the deduction or the reduction is calculated first, so the deduction reads - no benefit that depends on the reduction.""" + """Invariant 7. Council Tax Reduction and its tax deduction come out the + same whether the deduction or the reduction is calculated first.""" variables = [ - "council_tax_reduction_tax_deduction", + DEDUCTION, "council_tax_reduction_applicable_income", "council_tax_reduction", ] - for country, person in CASES: - inputs = {**person, "savings_interest_income": 6_559.0} + for case in CASES: + applicant = case["benunits"][0][0] + inputs = { + **case, + "benunits": [ + [{**applicant, "savings_interest_income": 6_559.0}] + + case["benunits"][0][1:] + ], + } results = [] for order in (variables, variables[::-1]): - simulation = simulate([inputs], country) + simulation = simulate([inputs]) results.append( {v: float(simulation.calculate(v, YEAR).sum()) for v in order} ) - assert results[0] == results[1], (country, results) + assert results[0] == results[1], (case, results) + + +UNCOUNTED_CASES = [ + ("savings_interest_income", 10_000.0), + ("other_investment_income", 10_000.0), + ("dividend_income", 10_000.0), + ("miscellaneous_income", 10_000.0), + ("company_car_benefit", 10_000.0), +] + + +def test_branch_takes_each_uncounted_income_out_of_income_tax(): + """The pensioner schemes' tax on other counted income zeroes each income + they do not count, so it equals the income tax of the same person without + that income. This covers the Scottish Carer Supplement, which the model + computes from the carer's award rather than reading as an input.""" + base = dict(age=70, private_pension_income=30_000.0) + with_income = [ + dict(country="ENGLAND", benunits=[[{**base, variable: amount}]]) + for variable, amount in UNCOUNTED_CASES + ] + without_income = [dict(country="ENGLAND", benunits=[[base]])] + branch = np.asarray( + simulate(with_income).calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ) + ) + expected = float(simulate(without_income).calculate(INCOME_TAX, YEAR)[0]) + assert np.allclose(branch, expected, atol=0.01), (branch, expected) + tax_with_income = np.asarray(simulate(with_income).calculate(INCOME_TAX, YEAR)) + assert np.all(tax_with_income > expected + 1), tax_with_income + + carer = dict( + age=70, + private_pension_income=14_000.0, + care_hours=35, + would_claim_carers_allowance=True, + ) + carer_household = [dict(country="SCOTLAND", benunits=[[carer]])] + with_supplement = simulate(carer_household) + without_supplement = simulate(carer_household, reform=NO_SCOTTISH_CARER_SUPPLEMENT) + assert with_supplement.calculate("scottish_carer_supplement", YEAR)[0] > 0 + assert np.isclose( + with_supplement.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + )[0], + without_supplement.calculate(INCOME_TAX, YEAR)[0], + atol=0.01, + ) + assert ( + with_supplement.calculate(INCOME_TAX, YEAR)[0] + > without_supplement.calculate(INCOME_TAX, YEAR)[0] + 1 + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py new file mode 100644 index 0000000000..a50e726e04 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -0,0 +1,54 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pension_credit import employed_earnings_sources +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + income_tax_with_inputs_zeroed, +) + +# Taxed income that council_tax_reduction_applicable_income leaves out on the +# pensioner schemes' general route: the Scottish Carer Supplement (SSI +# 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no +# scheme counts a payment in kind as earnings), savings interest and other +# investment income, and dividends. +PENSIONER_UNCOUNTED_INCOME = [ + "scottish_carer_supplement", + "miscellaneous_income", + "benefits_in_kind", + "savings_interest_income", + "other_investment_income", + "dividend_income", +] + + +class council_tax_reduction_income_tax_on_other_counted_income(Variable): + value_type = float + entity = Person + label = "Income tax on the other income a pensioner Council Tax Reduction counts" + documentation = ( + "The person's income tax before the winter fuel payment charge if " + "nobody in the simulation had employed earnings (employment income " + "and statutory sick, maternity and paternity pay), self-employment " + "income, or the taxed income the pensioner schemes do not count: the " + "Scottish Carer Supplement, miscellaneous income, benefits in kind, " + "savings interest, other investment income and dividends. This is the " + "tax on the rest of the income the pensioner schemes count, with the " + "uncounted income above it. The formula zeroes those inputs on a " + "branch that it drops afterwards, and builds the branch only when " + "someone has uncounted income." + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + if not any( + (person(variable, period) != 0).any() + for variable in PENSIONER_UNCOUNTED_INCOME + ): + return person("pension_credit_income_tax_without_earnings", period) + return income_tax_with_inputs_zeroed( + person.simulation, + period, + employed_earnings_sources(parameters, period) + + ["self_employment_income"] + + PENSIONER_UNCOUNTED_INCOME, + ) + diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py new file mode 100644 index 0000000000..8d2d262dc5 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -0,0 +1,87 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable + + +class council_tax_reduction_scotland_self_employment_deductions(Variable): + value_type = float + entity = Person + label = ( + "Notional tax and contributions on self-employed profit, for Scottish " + "Council Tax Reduction" + ) + documentation = ( + "The income tax and social security contributions that Scotland's " + "Council Tax Reduction schemes deduct from self-employed earnings in " + "place of those actually payable: SSI 2012/303 regs 37 and 38, which " + "SSI 2021/249 reg 51(9) applies at working age where nobody in the " + "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " + "pension age. The formula charges notional income tax at the basic " + "rate (the Scottish basic rate for a Scottish taxpayer) on the profit " + "less only the personal allowance; Class 4 contributions at the main " + "rate on the profit between the lower and upper profits limits, where " + "Class 4 is payable at all; and, for years before 2024-25, the Class " + "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " + "and reg 37(2) on 6 April 2024. The working-age rule names the " + "personal relief under ITA 2007 s. 35(1); the formula reads it as the " + "allowance s. 35(2) leaves after the taper, because s. 35(2) reduces " + "'the allowance under subsection (1)'. The pension-age rule names the " + "relief under ss. 36 or 37; Finance Act 2014 omitted s. 36, and s. 37 " + "gives the section 35 amount to a person born before 6 April 1938, " + "so the formula reads both as the same tapered s. 35 allowance. A " + "child minder's chargeable income is one-third of the earnings; the " + "model does not identify child minders, so it treats every profit as " + "chargeable income in full." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/37", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/38", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/36", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", + "https://www.legislation.gov.uk/ukpga/2007/3/section/35", + "https://www.legislation.gov.uk/ukpga/2007/3/section/37", + ) + + def formula(person, period, parameters): + profit = max_(0, person("self_employment_income", period)) + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate is the band after the starter rate. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + basic_rate = where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + rates.uk.rates[0], + ) + # "less only the personal relief": no blind person's or marriage + # allowance, and no married couple's allowance. + notional_tax = basic_rate * max_( + 0, profit - person("personal_allowance", period) + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.contribution_scale.components[1].threshold + upper = class_4.contribution_scale.components[2].threshold + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.contribution_scale.components[1].rate + * min_(max_(0, profit - lower), upper - lower), + 0, + ) + # Reg 38(2)(a) and reg 37(2)(a) as they stood on 5 April 2024: the + # Class 2 contributions payable, unless chargeable income does not + # exceed the threshold of SSCBA 1992 s. 11. + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + notional_class_2 = ( + (period.start.year < 2024) + * person("ni_liable", period) + * class_2_liable(profit, class_2) + * class_2.flat_rate + * class_2_contribution_weeks(period.start.year) + ) + return where( + profit > 0, + notional_tax + notional_class_4 + notional_class_2, + 0, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 147a35ddc5..ee51b04d54 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -1,100 +1,13 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.income_tax import ( - calc_income_tax_with_gift_aid, - first_extended_scottish_threshold, +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + DECISION, + PRE_CHARGE_TAX, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, ) from policyengine_uk.variables.household.demographic.country import Country -# Taxable non-savings income other than earnings. Scotland's working-age scheme -# deducts no tax from it. -NON_EARNINGS_NON_SAVINGS_INCOME = [ - "taxable_pension_income", - "taxable_social_security_income", - "taxable_property_income", - "taxable_miscellaneous_income", -] -# Taxable non-savings income that the pensioner schemes do not take into -# account: the Scottish Carer Supplement (SSI 2012/319 reg 27(1)(j)(xxib)), -# miscellaneous income and benefits in kind, which the model's Council Tax -# Reduction income does not count. -PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME = [ - "scottish_carer_supplement", - "taxable_miscellaneous_income", - "benefits_in_kind", -] -# National Insurance charged on earnings and profits. Class 3 contributions are -# voluntary and not charged on earnings. -NATIONAL_INSURANCE_ON_EARNINGS = ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - - -def tax_below_top_slice(person, period, parameters, top_slice): - """Step 5 tax on a person's non-savings income without its top slice. - - Savings and dividend income are taxed as the highest part of income, so - the Step 5 tax on the rest is earned_income_tax plus property_income_tax. - The top_slice of non-savings income is taken as the highest part of the - non-savings income, and its tax at the person's main rates is taken off. - Allowances and reliefs therefore fall on the income below it, and that - income keeps the personal allowance it would have on its own: the part of - the allowance withdrawn only because the top slice, savings and dividends - lift adjusted net income above the taper threshold is restored to it. - """ - allowance = parameters(period).gov.hmrc.income_tax.allowances.personal_allowance - rates = parameters(period).gov.hmrc.income_tax.rates - non_savings_tax = add(person, period, ["earned_income_tax", "property_income_tax"]) - taxable = person("non_savings_taxable_income", period) - income_above = top_slice + add( - person, period, ["taxable_savings_interest_income", "taxable_dividend_income"] - ) - income_below = max_(0, person("adjusted_net_income", period) - income_above) - allowance_alone = max_( - 0, - np.ceil( - allowance.amount - - max_(0, income_below - allowance.maximum_ANI) * allowance.reduction_rate - ), - ) - restored_allowance = max_(0, allowance_alone - person("personal_allowance", period)) - below = max_(0, taxable - top_slice - restored_allowance) - gross_gift = person("gift_aid_grossed_up", period) - scottish = rates.scotland.rates - first_extended = first_extended_scottish_threshold(scottish) - scottish_bands = len(scottish.thresholds) - - def tax_at_main_rates(income): - return where( - person("pays_scottish_income_tax", period), - calc_income_tax_with_gift_aid( - scottish, income, gross_gift, first_extended, scottish_bands - ), - calc_income_tax_with_gift_aid(rates.uk, income, gross_gift), - ) - - tax_on_top_slice = max_(0, tax_at_main_rates(taxable) - tax_at_main_rates(below)) - # With no taxable income below the top slice there is no tax on it; this - # also clears the rounding left by the subtraction. - return where(below > 0, max_(0, non_savings_tax - tax_on_top_slice), 0) - - -def tax_after_reductions_and_charges(person, period, step_5_part): - """Income Tax attributed to a part of the Step 5 tax. - - The Step 6 reductions come off that part first, as they would if it were - the person's only income. The Step 7 charges other than the winter fuel - payment charge are shared in proportion to the part's share of the Step 5 - tax. The result never exceeds income_tax_before_winter_fuel_payment_charge. - """ - step_5_tax = max_(0, person("income_tax_pre_charges", period)) - reductions = person("income_tax_reductions", period) - charges = person("income_tax_charges", period) - share = np.divide( - step_5_part, - step_5_tax, - out=np.zeros_like(step_5_tax), - where=step_5_tax > 0, - ) - return max_(0, step_5_part - reductions) + charges * min_(share, 1) - class council_tax_reduction_tax_deduction(Variable): value_type = float @@ -103,29 +16,37 @@ class council_tax_reduction_tax_deduction(Variable): documentation = ( "Income tax and National Insurance that Council Tax Reduction income " "deducts, for the applicant and partner " - "(is_council_tax_reduction_applicant_or_partner). Scotland's " - "working-age scheme (SSI 2021/249) deducts them from earnings only, " - "so the formula deducts National Insurance on earnings and profits " - "(Classes 1, 2 and 4) and the Income Tax on earnings, taken as the " - "Step 5 tax that employment and self-employment income bear as the " - "lowest part of non-savings income, with the other non-savings " - "income above them and savings and dividends taxed separately. The " - "pensioner schemes of England (SI 2012/2885), Wales (WSI 2013/3029) " - "and Scotland (SSI 2012/319) disregard tax only on income taken into " - "account, so the formula deducts the same National Insurance and the " - "Step 5 tax on non-savings income less the tax on the Scottish Carer " - "Supplement, miscellaneous income and benefits in kind, taken as the " - "highest part of non-savings income. The income below keeps the " - "personal allowance it would have on its own. Tax on savings and dividend " - "income is never deducted in these schemes. In each, the Step 6 tax " - "reductions come off the tax deducted first, and the Step 7 charges " - "other than the winter fuel payment charge are shared in proportion " - "to the Step 5 tax. The ordering of earnings below other non-savings " - "income is a modelling choice: the regulations name the tax deducted " - "from earnings, not a way to apportion it. The English and Welsh " - "working-age schemes, and families outside Great Britain, deduct the " - "whole Income Tax liability before the winter fuel payment charge " - "and all National Insurance." + "(is_council_tax_reduction_applicant_or_partner). T is their income " + "tax before the winter fuel payment charge, and T(X) the same with " + "every person's X set to nil (pension_credit_income_tax_without_" + "employment_income, pension_credit_income_tax_without_earnings and " + "council_tax_reduction_income_tax_on_other_counted_income). E is " + "employed earnings: employment income and statutory sick, maternity " + "and paternity pay. S is self-employment income. U is the taxed " + "income the pensioner schemes do not count. " + "Scotland's working-age scheme (SSI 2021/249) deducts tax from " + "earnings only. Where nobody in the family has Universal Credit, the " + "formula deducts T less T(E), never negative, primary Class 1 " + "contributions, and the notional tax and contributions on profit " + "(council_tax_reduction_scotland_self_employment_deductions; reg " + "51(9)). With Universal Credit it deducts T less T(E, S), never " + "negative, and the Class 1, 2 and 4 contributions charged (regs " + "49(6)(b) and 51(2) step 3). The pensioner schemes of England (SI " + "2012/2885), Wales (WSI 2013/3029) and Scotland (SSI 2012/319) " + "disregard the tax deducted from earnings and the tax payable on " + "income taken into account, so the formula deducts T less T(E), " + "never negative; T(E, S, U), the tax on the other counted income " + "with the uncounted income above it; the notional tax and " + "contributions on profit (pension_credit_self_employment_deductions " + "in England and Wales, the Scottish variable in Scotland); and " + "primary Class 1 contributions. Earnings are the top slice of income " + "and self-employed profit sits beneath them, so the tax on earnings " + "includes any tax they add on savings and dividends and through the " + "marriage allowance. Benefits in kind stay in both terms of each " + "difference, since no scheme counts a payment in kind as earnings. " + "The English and Welsh working-age schemes, and families outside " + "Great Britain, deduct the whole income tax before the winter fuel " + "payment charge and all National Insurance. " + DECISION ) definition_period = YEAR unit = GBP @@ -134,54 +55,97 @@ class council_tax_reduction_tax_deduction(Variable): "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/30", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/24", ] def formula(benunit, period, parameters): person = benunit.members members = person("is_council_tax_reduction_applicant_or_partner", period) + + def total(values): + return benunit.sum(values * members) + country = benunit.household("country", period) + scotland = country == Country.SCOTLAND in_great_britain = ( - (country == Country.ENGLAND) - | (country == Country.SCOTLAND) - | (country == Country.WALES) + (country == Country.ENGLAND) | scotland | (country == Country.WALES) ) pensioner = benunit("council_tax_reduction_pensioner", period) - scottish_working_age = (country == Country.SCOTLAND) & ~pensioner pensioner_scheme = in_great_britain & pensioner - - whole_tax = add( - person, - period, - ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], - ) - ni_on_earnings = add(person, period, NATIONAL_INSURANCE_ON_EARNINGS) - earnings_step_5_tax = tax_below_top_slice( - person, - period, - parameters, - add(person, period, NON_EARNINGS_NON_SAVINGS_INCOME), + scottish_working_age = scotland & ~pensioner + with_universal_credit = scottish_working_age & applicant_has_universal_credit( + benunit, period ) - counted_step_5_tax = tax_below_top_slice( - person, - period, - parameters, - add(person, period, PENSIONER_UNCOUNTED_NON_SAVINGS_INCOME), + without_universal_credit = scottish_working_age & ~with_universal_credit + + income_tax = person(PRE_CHARGE_TAX, period) + tax = total(income_tax) + whole_liability = tax + total(person("national_insurance", period)) + class_1 = total(person("ni_class_1_employee", period)) + zeros = np.zeros_like(tax) + + # Each branch of income tax is read only where a scheme uses it. + if (without_universal_credit | pensioner_scheme).any(): + tax_without_employment = total( + person("pension_credit_income_tax_without_employment_income", period) + ) + else: + tax_without_employment = tax + tax_on_employed_earnings = max_(0, tax - tax_without_employment) + + if with_universal_credit.any(): + tax_without_earnings = total( + person("pension_credit_income_tax_without_earnings", period) + ) + else: + tax_without_earnings = tax + uc_deduction = ( + max_(0, tax - tax_without_earnings) + + class_1 + + total(add(person, period, ["ni_class_2", "ni_class_4"])) ) - tax_on_earnings = ( - tax_after_reductions_and_charges(person, period, earnings_step_5_tax) - + ni_on_earnings + + scottish_notional = ( + total( + person( + "council_tax_reduction_scotland_self_employment_deductions", + period, + ) + ) + if (scotland & (without_universal_credit | pensioner_scheme)).any() + else zeros ) - tax_on_counted_income = ( - tax_after_reductions_and_charges(person, period, counted_step_5_tax) - + ni_on_earnings + no_uc_deduction = tax_on_employed_earnings + class_1 + scottish_notional + + if pensioner_scheme.any(): + tax_on_other_counted_income = total( + person( + "council_tax_reduction_income_tax_on_other_counted_income", + period, + ) + ) + # SI 2012/2885 Sch 1 para 30 and WSI 2013/3029 Sch 1 para 24 price + # the notional tax as Pension Credit does. + england_and_wales_notional = total( + person("pension_credit_self_employment_deductions", period) + ) + else: + tax_on_other_counted_income = zeros + england_and_wales_notional = zeros + notional = where(scotland, scottish_notional, england_and_wales_notional) + pensioner_deduction = ( + tax_on_employed_earnings + tax_on_other_counted_income + notional + class_1 ) - person_scottish_working_age = benunit.project(scottish_working_age) - person_pensioner_scheme = benunit.project(pensioner_scheme) - deduction = select( - [person_scottish_working_age, person_pensioner_scheme], - [tax_on_earnings, tax_on_counted_income], - default=whole_tax, + + return select( + [with_universal_credit, without_universal_credit, pensioner_scheme], + [uc_deduction, no_uc_deduction, pensioner_deduction], + default=whole_liability, ) - return benunit.sum(deduction * members) From 3d0bf96731a6bd8eee47af0481d067a63131388e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:56:09 +0000 Subject: [PATCH 089/168] Test the Council Tax Reduction tax split against Pension Credit, and its cliffs Restate the property tests for option (a): a differential test against pension_credit_employment_income_tax, the pensioner deduction's independence from uncounted income, bounds with the notional amount, and the Marriage Allowance and savings allowance cliffs. Add YAML cases for savings, property, benefits in kind, notional self-employed tax, Universal Credit, Class 2 before 2024-25 and Class 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- .../council_tax_reduction_tax_deduction.yaml | 3 ++ ...l_tax_reduction_carer_income_properties.py | 12 +++---- ..._tax_reduction_tax_deduction_properties.py | 32 +++++++++++++------ ...tion_income_tax_on_other_counted_income.py | 1 - 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index b681ab26b6..b9d4d84e67 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -231,6 +231,9 @@ # allowance, 1,430 x 20% = 286. The profit adds 3,461.70. Class 4 # charged: 145.80. council_tax_reduction_tax_deduction: 3_607.50 + # 15,000 - 3,607.50, plus Universal Credit 7,190.20 and Child Benefit + # (27.05 + 17.90) x 52 = 2,337.40. The interest is not counted. + council_tax_reduction_applicable_income: 20_920.10 - name: Scotland's working-age scheme counted Class 2 in the notional deduction before 2024-25 period: 2023 diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 6ec86bd612..e8b5775481 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -354,18 +354,18 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( (income_without + without[CTR_TAX][i])[general], atol=0.01, ), (fam, income[general], income_without[general]) - has_earnings = any( - a.get("employment_income", 0.0) > 0 for a in fam["adults"] - ) + has_earnings = any(a.get("employment_income", 0.0) > 0 for a in fam["adults"]) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] # The supplement never lowers the award, even on the Pension Credit # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam if not has_earnings: - assert np.allclose( - income[general], income_without[general], atol=0.01 - ), (fam, income[general], income_without[general]) + assert np.allclose(income[general], income_without[general], atol=0.01), ( + fam, + income[general], + income_without[general], + ) assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 71ddc95699..f9d30f6427 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -216,9 +216,7 @@ def test_pensioner_deduction_ignores_uncounted_income_without_earnings( def without_notional(simulation): notional = np.where( - np.asarray( - [h["country"] == "SCOTLAND" for h in households], dtype=bool - ), + np.asarray([h["country"] == "SCOTLAND" for h in households], dtype=bool), applicant_total( simulation, "council_tax_reduction_scotland_self_employment_deductions" ), @@ -315,7 +313,12 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction.""" + Reduction, except where the rise crosses a cliff in the income tax + system: losing Marriage Allowance, or the personal savings allowance + falling as the person enters a higher band. Those cliffs raise the tax + the earnings add by more than the rise in pay, as the ruling intends; for + example a Scottish couple whose pay rises from 43,661 to 43,662 loses + Marriage Allowance and its Council Tax Reduction rises by 52.78.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -327,7 +330,14 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p schemes(before, households) reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) - assert np.all(reduction_after <= reduction_before + 0.01), ( + cliff = np.zeros(len(households), dtype=bool) + for variable in ["marriage_allowance", "savings_allowance"]: + cliff |= ~np.isclose( + applicant_total(before, variable), applicant_total(after, variable) + ) + for crossed in cliff: + event("crosses a tax cliff" if crossed else "no tax cliff") + assert np.all((reduction_after <= reduction_before + 0.01) | cliff), ( households, rise, partner, @@ -352,9 +362,7 @@ def test_non_dependant_income_never_changes_the_deduction( with_non_dependant = [ {**h, "benunits": h["benunits"] + [[non_dependant]]} for h in households ] - changed = [ - {**h, "benunits": h["benunits"] + [[other_income]]} for h in households - ] + changed = [{**h, "benunits": h["benunits"] + [[other_income]]} for h in households] alone = simulate(households) schemes(alone, households) first = simulate(with_non_dependant) @@ -373,7 +381,9 @@ def test_non_dependant_income_never_changes_the_deduction( country="SCOTLAND", benunits=[[dict(age=40, employment_income=30_000.0, dividend_income=9_000.0)]], ), - dict(country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]]), + dict( + country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]] + ), dict( country="SCOTLAND", uc=True, @@ -385,7 +395,9 @@ def test_non_dependant_income_never_changes_the_deduction( ] ], ), - dict(country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]]), + dict( + country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]] + ), dict( country="ENGLAND", benunits=[[dict(age=70, employment_income=20_000.0)]], diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index a50e726e04..1375bb3312 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -51,4 +51,3 @@ def formula(person, period, parameters): + ["self_employment_income"] + PENSIONER_UNCOUNTED_INCOME, ) - From faa4234007ffa0dc1894652d54725fb1de6ac64b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:06:22 +0000 Subject: [PATCH 090/168] Date SDLT Table A from 4 December 2014 and fix the first.max switch test Move the s. 55(1B) Table A entries in main/subsequent.yaml from 2015-02-12 to 2014-12-04 under Stamp Duty Land Tax Act 2015 s. 1(3) and s. 2(2), and start the dated-schedule reference on that date. No model year reads a moved value. Limit the raw-switch first.max check to dates before 8 July 2020, where the parameter holds 0 until 30 June 2021, and check that 0 explicitly in the omitted-relief property. Cite s. 2(2) in higher_rates_apply. Add a Table B threshold case in model year 2015 and a property over reformed first non-residential thresholds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HqV5SA7L6CtWzKGP1 --- changelog.d/2801.fixed.md | 2 +- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 3 +- .../additional/higher_rates_apply.yaml | 4 +- .../residential/purchase/main/subsequent.yaml | 77 +++++++++++++++++-- .../stamp_duty_land_tax_commencement.yaml | 10 +++ .../test_sdlt_dated_schedule_properties.py | 23 ++++-- ..._sdlt_statutory_commencement_properties.py | 52 ++++++++++++- 7 files changed, 153 insertions(+), 18 deletions(-) diff --git a/changelog.d/2801.fixed.md b/changelog.d/2801.fixed.md index 698db21861..2af8ea2323 100644 --- a/changelog.d/2801.fixed.md +++ b/changelog.d/2801.fixed.md @@ -1 +1 @@ -The model now dates first-time buyers' relief from 22 November 2017, the higher rates for additional dwellings from 1 April 2016 and the 2016 non-residential rates from 17 March 2016. Before 17 March 2016 it computes SDLT on a non-residential purchase as one percentage of the whole price. It computes SDLT on purchases in Wales until 31 March 2018 and Land Transaction Tax from 1 April 2018. +The model now dates the main residential rates in s. 55(1B) Table A from 4 December 2014, first-time buyers' relief from 22 November 2017, the higher rates for additional dwellings from 1 April 2016 and the 2016 non-residential rates from 17 March 2016. Before 17 March 2016 it computes SDLT on a non-residential purchase as one percentage of the whole price. It computes SDLT on purchases in Wales until 31 March 2018 and Land Transaction Tax from 1 April 2018. diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index dd527960d0..5fab2894ae 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -21,10 +21,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Start dates in 2016 to 2018\n", + "## Start dates in 2014 to 2018\n", "\n", "This page lists the effective date Parliament set for each change. Each model year reads the parameters in force on 30 April:\n", "\n", + "- Parliament started the main residential rates in s55(1B) Table A, charged on slices of the price, on 4 December 2014 ([Stamp Duty Land Tax Act 2015 s2(2)](https://www.legislation.gov.uk/ukpga/2015/1/section/2)). Every model year from 2015 reads them.\n", "- Parliament started first-time buyers' relief on 22 November 2017 ([Finance Act 2018 s41(8)](https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted)), so the model grants it from model year 2018.\n", "- Parliament started the higher rates for additional dwellings on 1 April 2016 ([Finance Act 2016 s128(5)](https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted)). In model year 2015 a buyer of an additional dwelling pays the main rates at any price, and a reform to the main rates reaches that buyer too. A reform can move the switch through `gov.hmrc.stamp_duty.residential.purchase.additional.higher_rates_apply`.\n", "- Parliament moved non-residential purchases to sliced rates, and added a 2% band on lease rent above £5 million, from 17 March 2016 ([Finance Act 2016 s127(12)](https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted)). Before then HMRC charged the whole price of a non-residential purchase at the percentage for its band under [s55(2) Table B as at 30 April 2015](https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30): 0% up to £150,000, 1% up to £250,000, 3% up to £500,000 and 4% above. The model applies that table in model year 2015, so a £1,000,000 purchase pays £40,000 in model year 2015 and £39,500 from model year 2016.\n", diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml index 8b70598ce7..27be750b2b 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml @@ -3,10 +3,12 @@ values: 2003-07-10: value: false metadata: - note: Before 1 April 2016 HMRC charged a buyer of an additional dwelling the main rates in s. 55(1B) Table A at any price. + note: From 4 December 2014 (Stamp Duty Land Tax Act 2015 s. 2(2)) to 31 March 2016, HMRC charged a buyer of an additional dwelling the main rates in s. 55(1B) Table A at any price. Before 4 December 2014 that buyer paid the rates any other buyer paid. reference: - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Stamp Duty Land Tax Act 2015 s. 2(2) + href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 2016-04-01: value: true metadata: diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml index 83e1569b74..e1635b4d1c 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml @@ -5,7 +5,15 @@ brackets: 2003-07-10: 0 - rate: 2003-07-10: 0.01 - 2015-02-12: 0.02 + 2014-12-04: + value: 0.02 + metadata: + note: Parliament inserted s. 55(1B) Table A (Stamp Duty Land Tax Act 2015 s. 1(3)) with effect for any land transaction whose effective date is, or is after, 4 December 2014 (s. 2(2)). A purchaser under a contract entered into before that date could elect out (s. 2(3) to (5)); the model has no contract date, so it doesn't apply that election. + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) 2020-07-08: value: null metadata: @@ -44,7 +52,14 @@ brackets: 2025-04-01: 125000 - rate: 2003-07-10: 0.03 - 2015-02-12: 0.05 + 2014-12-04: + value: 0.05 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 250000 2020-07-08: @@ -63,22 +78,64 @@ brackets: title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1), (2), inserted by Finance Act 2021 s. 87(3) - rate: 2003-07-10: 0.04 - 2015-02-12: 0.1 + 2014-12-04: + value: 0.1 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 500000 - 2015-02-12: 925000 + 2014-12-04: + value: 925000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - rate: 2008-04-08: 0.05 - 2015-02-12: 0.12 + 2014-12-04: + value: 0.12 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 1000000 - 2015-02-12: 1500000 + 2014-12-04: + value: 1500000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - rate: 2012-07-17: 0.07 - 2015-02-12: null + 2014-12-04: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2012-07-17: 2000000 - 2015-02-12: null + 2014-12-04: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) description: Stamp Duty Land Tax progressive rates for residential property transfers. metadata: economy: false @@ -88,6 +145,10 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55 title: Finance Act 2003 s. 55 + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted title: Stamp Duty Land Tax (Temporary Relief) Act 2020 - href: https://www.legislation.gov.uk/ukpga/2021/26/section/87/enacted diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml index 404d0e633f..9ce78c0e74 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml @@ -201,6 +201,16 @@ output: stamp_duty_land_tax: 40_000 +- name: Non-residential purchase, £250,000, model year 2015, pays the lower band's percentage of the whole price + # 1% x 250,000: under s. 55(2) Table B a price of £250,000 is "More than + # £150,000 but not more than £250,000", before 17 March 2016 (FA 2016 s. 127(12)). + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 250_000 + output: + stamp_duty_land_tax: 2_500 + - name: Non-residential purchase, £1,000,000, model year 2016, pays the sliced rates # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). period: 2016 diff --git a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py index afc22b5af2..c1c8cd1248 100644 --- a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py +++ b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py @@ -1,8 +1,8 @@ """Property-based tests for the dated residential SDLT schedules (issue #2245). The reference below restates the legislation's own tables, for effective -dates from 12 February 2015, the date from which the model's main rates take -their s55(1B) Table A values: +dates from 4 December 2014, the date from which s55(1B) Table A has effect +(Stamp Duty Land Tax Act 2015 s1(3), s2(2)): - Finance Act 2003 s55(1B) Table A (main rates); Sch4ZA para 1(2) (higher rates for additional dwellings, the main rates plus 3 points, or 5 from @@ -74,7 +74,7 @@ # First effective date, main rates, first-time buyers' relief (nil band, # maximum price) or None, higher-rates surcharge or None, lease rent nil band. TIMELINE = ( - (date(2015, 2, 12), STANDARD, None, None, 125_000), + (date(2014, 12, 4), STANDARD, None, None, 125_000), (date(2016, 4, 1), STANDARD, None, 0.03, 125_000), (date(2017, 11, 22), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), (date(2018, 3, 15), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), @@ -187,8 +187,7 @@ def test_dated_parameters_match_the_statutory_tables(effective_date, price_list) ) -@pytest.mark.parametrize("effective_date", CHANGE_DAYS, ids=str) -def test_each_change_day_and_its_eve_match_the_statutory_tables(effective_date): +def _check_statutory_tables(effective_date): residential = RAW(effective_date.isoformat()) price = np.array(BOUNDARY_PRICES) for buyer in BUYERS: @@ -206,6 +205,19 @@ def test_each_change_day_and_its_eve_match_the_statutory_tables(effective_date): ) +@pytest.mark.parametrize("effective_date", CHANGE_DAYS, ids=str) +def test_each_change_day_and_its_eve_match_the_statutory_tables(effective_date): + _check_statutory_tables(effective_date) + + +def test_table_a_starts_on_4_december_2014(): + # Stamp Duty Land Tax Act 2015 s2(2): s55(1B) Table A has effect from 4 + # December 2014. The day before, the main rates hold other bands. + _check_statutory_tables(FIRST_DATE) + eve = RAW((FIRST_DATE - timedelta(days=1)).isoformat()).purchase.main.subsequent + assert tuple(zip(eve.thresholds, eve.rates)) != STANDARD + + @PROPERTY_SETTINGS @given(effective_date=dates, value_list=st.lists(prices, min_size=1, max_size=8)) def test_dated_lease_rent_matches_the_statutory_table(effective_date, value_list): @@ -265,6 +277,7 @@ def test_omitted_relief_ignores_buyer_status_under_any_rates( _change_rates_over_initial_relief(main.subsequent, main_rates) _change_rates_over_initial_relief(main.first.rate, first_time_buyer_rates) purchase = residential(effective_date.isoformat()).purchase + assert purchase.main.first.max == 0 assert list(purchase.main.subsequent.rates) == main_rates assert list(purchase.main.first.rate.rates) == first_time_buyer_rates price = np.array(price_list, dtype=float) diff --git a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py index 7a6e7c9da4..8717310ef5 100644 --- a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py +++ b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py @@ -195,6 +195,50 @@ def test_table_b_charges_the_whole_price_in_2015(prices): np.testing.assert_allclose(tax, expected, rtol=1e-6, atol=0.01) +def _whole_price_tax(table, price): + # The rate for the highest band whose threshold the price exceeds, on the + # whole price, or nothing when the price exceeds no threshold. + rates = [rate for threshold, rate in table if price > threshold] + return rates[-1] * price if rates else 0.0 + + +NON_RESIDENTIAL_FIRST_BAND = "gov.hmrc.stamp_duty.non_residential.purchase[0]" + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example(10_000, 0.005, [0, 5_000, 9_999.5, 10_000, 10_000.5, 20_000, 150_000.5]) +@given( + st.integers(1, 149_999), + st.sampled_from([0.001, 0.005, 0.0075]), + st.lists( + st.one_of(threshold_prices, st.integers(0, 3_000_000)), min_size=1, max_size=20 + ), +) +def test_reformed_first_threshold_charges_the_whole_price_above_it( + threshold, rate, prices +): + # A reform gives Table B's first band a positive threshold and rate in + # model year 2015. A price that exceeds no threshold pays nothing, and a + # price above the first threshold pays its band's rate on the whole price. + table = ((threshold, rate),) + TABLE_B[1:] + prices = prices + [threshold, threshold + 0.5, threshold + 1] + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + period = f"{TABLE_B_YEAR}-01-01.{TABLE_B_YEAR}-12-31" + reform = { + f"{NON_RESIDENTIAL_FIRST_BAND}.threshold": {period: threshold}, + f"{NON_RESIDENTIAL_FIRST_BAND}.rate": {period: rate}, + } + simulation = Simulation( + situation=_situation(households, [TABLE_B_YEAR]), reform=reform + ) + tax = _calculate(simulation, "stamp_duty_land_tax", TABLE_B_YEAR) + expected = [_whole_price_tax(table, price) for price in prices] + np.testing.assert_allclose(tax, expected, rtol=1e-6, atol=0.01, err_msg=str(table)) + + @settings(PROPERTY_SETTINGS, max_examples=6) @given( st.lists( @@ -319,7 +363,11 @@ def _check_raw_switches(effective_date): assert bool(purchase.additional.higher_rates_apply) == ( effective_date >= HIGHER_RATES_START ) - assert (purchase.main.first.max > 0) == (effective_date >= RELIEF_START) + # first.max holds 0 from 8 July 2020 to 30 June 2021, which + # test_sdlt_dated_schedule_properties.py checks in + # test_omitted_relief_ignores_buyer_status_under_any_rates. + if effective_date < date(2020, 7, 8): + assert (purchase.main.first.max > 0) == (effective_date >= RELIEF_START) @pytest.mark.parametrize("effective_date", RAW_DAYS, ids=str) @@ -367,7 +415,7 @@ def _residential_tax(purchase, price, buyer): @example(HIGHER_RATES_START - timedelta(days=1), [40_000, 300_000, 1_000_000]) @example(RELIEF_START - timedelta(days=1), [200_000, 300_000, 500_000]) @given( - st.dates(min_value=date(2015, 2, 12), max_value=RELIEF_START - timedelta(days=1)), + st.dates(min_value=date(2014, 12, 4), max_value=RELIEF_START - timedelta(days=1)), st.lists(residential_prices, min_size=1, max_size=8), ) def test_raw_buyers_pay_the_main_rates_before_their_schedules_start( From f539a569df623827dbae20816d0303ea552d40a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:23:18 +0000 Subject: [PATCH 091/168] Draw Universal Credit families in the Council Tax Reduction tax properties Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- ..._tax_reduction_tax_deduction_properties.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index f9d30f6427..6aeb80bf30 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -141,7 +141,25 @@ def household(draw, countries=tuple(REGIONS), pensioner=None, **kwargs): country = draw(st.sampled_from(countries)) is_pensioner = draw(st.booleans()) if pensioner is None else pensioner adults = draw(st.lists(adult(is_pensioner, **kwargs), min_size=1, max_size=2)) - return dict(country=country, benunits=[adults], pensioner=is_pensioner) + h = dict(country=country, benunits=[adults], pensioner=is_pensioner) + if country == "SCOTLAND" and not is_pensioner and draw(st.booleans()): + # A Universal Credit claim with two children, and earnings, profit, + # pension and property income low enough for an award. Savings + # interest, dividends and the other uncounted income stay as drawn: + # Universal Credit does not count them. + h["uc"] = True + for a in adults: + for variable in ["employment_income", "self_employment_income"]: + a[variable] = min(a[variable], 10_000.0) + a["private_pension_income"] = 0.0 + a["property_income"] = 0.0 + adults += [dict(age=4), dict(age=7)] + return h + + +def grown_ups(people): + """Indices of the adults among a benefit unit's members.""" + return [i for i, p in enumerate(people) if p["age"] >= 18] def schemes(simulation, households): @@ -207,7 +225,7 @@ def test_pensioner_deduction_ignores_uncounted_income_without_earnings( changed = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] - who = len(adults) - 1 if partner else 0 + who = grown_ups(adults)[-1] if partner else 0 adults[who][uncounted] = adults[who][uncounted] + change changed.append({**h, "benunits": [adults]}) before = simulate(households) @@ -235,13 +253,7 @@ def without_notional(simulation): @st.composite def any_household(draw): - h = draw(household()) - if h["country"] == "SCOTLAND" and not h["pensioner"]: - # Children make a Universal Credit award likely. - h["uc"] = draw(st.booleans()) - if h["uc"]: - h["benunits"][0] += [dict(age=4), dict(age=7)] - return h + return draw(household()) @PROPERTY_SETTINGS @@ -322,9 +334,11 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] - who = len(adults) - 1 if partner else 0 + who = grown_ups(adults)[-1] if partner else 0 adults[who]["employment_income"] += rise - raised.append({**h, "benunits": [adults]}) + raised.append({**h, "uc": False, "benunits": [adults]}) + # The invariant is for families without Universal Credit. + households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) schemes(before, households) From 056a674b9348c6656de2f64fb218eed6c374f938 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:06:30 +0000 Subject: [PATCH 092/168] Let the trading loss and paternity pay tests follow the least-tax share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trading loss differential now bounds the tax with a loss by the tax on income cut in the old order, and keeps the equality where the person has no savings or dividend income. The paternity pay test expects the £100.20 rise that setting £1,500 of the Personal Allowance against savings gives. The allocation docs cite FA 2004 s. 192(4A) for the Scottish rate limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KkfvQBfSHTvh7rcen --- policyengine_uk/tests/shard_durations.toml | 2 +- ...ory_paternity_pay_income_tax_properties.py | 20 +++++++----- .../tests/test_trading_loss_properties.py | 31 +++++++++++++------ .../utils/income_tax_allocation.py | 11 ++++--- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 7adf4b7794..3b27671620 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -450,7 +450,7 @@ "policyengine_uk/tests/test_housing_benefit_guarantee_credit_passport_properties.py::test_receipt_means_a_paid_guarantee_credit_and_passports" = 39 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_each_response_is_measured_before_the_other" = 39 "policyengine_uk/tests/test_pension_annual_allowance_taper.py::test_allowance_bounds_and_statutory_two_gate_reference" = 39 -"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_taxes_like_income_cut_in_section_25_order" = 38 +"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_never_taxes_more_than_income_cut_in_the_old_order" = 38 "policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py::test_ni_excess_never_falls_with_sacrifice_or_pay" = 38 "policyengine_uk/tests/test_statutory_pension_edges.py::test_trade_losses_do_not_leak_nics_reclassification_into_aa[DC-2030]" = 38 "policyengine_uk/tests/test_severe_disability_afcs_properties.py::test_severe_disability_matches_the_tax_credit_condition" = 38 diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index ad32b4fee5..c3de54856d 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -28,7 +28,7 @@ - ITA 2007 s. 12B cuts the Personal Savings Allowance from £1,000 to £500 once the recipient has higher-rate income, and to nil with additional-rate income, so one pound across the basic rate limit can - cost £100 of tax on savings. + cost £100 of tax. - ITEPA 2003 s. 681C(3)(b) rounds the charge's percentage down to a whole number, so one pound across a £200 step adds 1% of the year's Child Benefit at once. @@ -440,17 +440,23 @@ def rise(results, variable): def test_a_pound_of_statutory_paternity_pay_can_halve_the_savings_allowance(): - # Wages of £48,270 and £2,000 of savings interest give taxable income of - # 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the - # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound of - # statutory paternity pay gives higher-rate income and a £500 allowance. - # Non-savings income goes from £35,700 to £35,701 (20p more). Savings tax + # Wages of £48,270 and £2,000 of savings interest give taxable income + # of 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the + # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound + # of statutory paternity pay gives higher-rate income and a £500 + # allowance. Under the order non-savings, savings, dividends, savings tax # goes from 20% of 1,000 = £200 to 20% of 1,499 plus 40% of 1 = £300.20. + # Setting £1,500 of the Personal Allowance against savings instead leaves + # the last pound within the £500 savings allowance. Pay of 37,201 then + # bears 20%, so income tax rises from £7,340 to £7,440.20, not £7,440.40 + # (ITA 2007 s. 25(2)). That pound would bear 40% but for the savings nil + # rate, so it still counts as higher-rate income (s. 12B(8)(b)(ii)), and + # the allowance stays at £500. results = Results([single_earner(48_270.0, savings=2_000.0)]) assert results.recipient("savings_allowance", 0, LOW) == 1_000 assert results.recipient("savings_allowance", 0, HIGH) == 500 assert ( - abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.4) + abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.2) < 0.01 ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 1209ab80a9..4596b51680 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -13,12 +13,15 @@ is that less the pension contributions given relief once (own contributions up to the greater of £3,600 and pay plus profits, none from age 75). 2. Differential: a person's Income Tax before the winter fuel payment - charge with a loss equals the same tax of the same person without the - loss and with their income cut by the relief, taken from non-savings - income first, then savings, then dividends: the model's fixed allowance - order (s.25(2) asks for the order giving the greatest reduction; #2106). - The charge itself can differ between the two: it tests total income, - which the relief, at Step 2, does not lower but the cut does. + charge with a loss never exceeds the same tax of the same person without + the loss and with their income cut by the relief, taken from non-savings + income first, then savings, then dividends. The model sets the relief and + allowances together where they give the least tax (s. 25(2)), and the + cut, with the allowances set against it, is one share the model weighs. + Where the person has no savings or dividend income, every share is that + cut, so the two taxes agree. The charge itself can differ between the + two: it tests total income, which the relief, at Step 2, does not lower + but the cut does. 3. Income Tax before the winter fuel payment charge never rises with the loss and falls by at most the relief (for people without savings income: the personal savings allowance steps up by £500 when someone stops being @@ -310,8 +313,10 @@ def portfolio_holders(draw): def reduced(person): - """The person's income cut by the relief, in the s.25 order the model - uses, computed outside the model.""" + """The person's income cut by the relief in the order non-savings, + savings, dividends, which this test computes outside the model. The model + sets the relief and allowances where they give the least tax (ITA 2007 + s. 25(2)), so it charges no more tax than on this cut.""" pay = person["employment_income"] pension = person["private_pension_income"] savings = person["savings_interest_income"] @@ -378,7 +383,7 @@ def reduced(person): st.lists(portfolio_holders(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) -def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): +def test_relief_never_taxes_more_than_income_cut_in_the_old_order(people, regions): twins = [reduced(p)[0] for p in people] # The winter fuel payment charge tests total income, before Step 2 # reliefs (ITEPA 2003 s.681I(1)(b), ITA 2007 s.23), so a relief and an @@ -392,9 +397,15 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): assert abs(a["trade_loss_relief_against_general_income"][i] - relief) <= ( tolerance(relief) ), person - assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + assert a[tax][i] - b[tax][i] <= 0.5 + tolerance( a[tax][i], person["employment_income"] ), (person, a[tax][i], b[tax][i]) + # With no savings or dividend income, the relief and allowances can + # come only off non-savings income, so every share is the cut. + if person["savings_interest_income"] == 0 and person["dividend_income"] == 0: + assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + a[tax][i], person["employment_income"] + ), (person, a[tax][i], b[tax][i]) # A pensioner with a tiny Pension Credit award: Pension Credit income diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 834a47c799..f98c2d06d3 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -24,9 +24,10 @@ 2004 s. 193(2)), and personal contributions as relief at source, under which the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a claim where higher or additional rate tax is chargeable, Parliament raises -the basic and higher rate limits by the contribution (s. 192(4)). Deducting -the contribution from the bottom of non-savings income moves every slice of -income down against those limits, as raising them does. Neither arrangement +the basic and higher rate limits by the contribution (s. 192(4), and for +the Scottish rate limits s. 192(4A)). Deducting the contribution from the +bottom of non-savings income moves every slice of income down against those +limits, as raising them does. Neither arrangement gives a Step 2 deduction the taxpayer can set against savings or dividends, so the search holds ``min(pension relief, non-savings income)`` on non-savings income and shares only the rest. @@ -224,8 +225,8 @@ def _build(inputs, income_tax): non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead - # (s. 192(4)), which deducting the relief from the bottom of non-savings - # income matches. So pension relief stays on non-savings income as far + # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which + # deducting the relief from the bottom of non-savings income matches. So pension relief stays on non-savings income as far # as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( From 7778076a4b611e99978fd44256d7d04a0f6c15c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:24:00 +0000 Subject: [PATCH 093/168] Read the scheme's tax deduction in the CTR route and trading-loss tests The Pension Credit route test computed CTR income with the whole tax liability, and the trading-loss test held income tax, which the CTR branches then read unchanged. Both now use council_tax_reduction_tax_deduction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KeMAheSRPPEYghYkj --- ...est_council_tax_reduction_pension_credit_properties.py | 8 ++++++-- policyengine_uk/tests/test_trading_loss_properties.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 8f1c6c793b..c09d489ca0 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -287,7 +287,11 @@ def situation( # The means tests deduct income tax before the winter fuel payment charge # (ITEPA 2003 s.681I), which recovers a payment rather than taxing the income # they assess. A pensioner over £35,000 of total income is charged it. -MAIN_DEDUCTIONS = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] +# The income tax and National Insurance each scheme allows. Its own tests +# (test_council_tax_reduction_tax_deduction_properties.py and +# council_tax_reduction_tax_deduction.yaml) check it against the law; this file +# checks the routes around it. +MAIN_DEDUCTIONS = ["council_tax_reduction_tax_deduction"] # The pensioner earnings disregards in 2026, written out from the law, not # read from the parameters: £25 a week for a lone parent, £10 with a partner, # £5 otherwise, plus £17.10 (SI 2012/2885 Sch 4 paras 2, 8 and 10; WSI @@ -369,7 +373,7 @@ def benunit_total(names): benunit_total(["child_benefit"]), 0, ) - - benunit_total(MAIN_DEDUCTIONS) + - sum(np.asarray(sim.calculate(v, YEAR))[claimants] for v in MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 1209ab80a9..b6ce60e6ab 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -945,11 +945,15 @@ def values(sim, variable): # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives # PC income 17,809.09433 - 1,047.818866 - 314.345660 - 1,040 = 15,406.93 # on both sides; the child's loss never enters the claimant's earnings. + # Council Tax Reduction reads income tax on branches that zero earnings, + # so holding the person's tax would also hold it on those branches. Hold + # the scheme's deduction from the with-loss side instead. pc_tax = { v: values(with_loss, v) for v in [ "pension_credit_employment_income_tax", "pension_credit_self_employment_income_tax", + "council_tax_reduction_tax_deduction", ] } loss = values(with_loss, "trading_loss") From 3b54d5397e6da2c28357b84ec5e3b49476d5d27f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 15:28:29 -0500 Subject: [PATCH 094/168] Rewrap the pension relief comment in the allowance search Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/income_tax_allocation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index f98c2d06d3..6358bdd92a 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -226,8 +226,9 @@ def _build(inputs, income_tax): # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which - # deducting the relief from the bottom of non-savings income matches. So pension relief stays on non-savings income as far - # as it reaches; the search shares the rest. + # deducting the relief from the bottom of non-savings income matches. So + # pension relief stays on non-savings income as far as it reaches; the + # search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), From c3f9cd4fd55f561cabf22c30266ce2298b0859ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:29:57 +0000 Subject: [PATCH 095/168] Save the 2026 awards in six pension-age ESA cases from the abolition Cases 4, 5, 6, 12, 13 and 14 of esa_income_pension_age.yaml assert a full-year income-related ESA award in 2026-27. Each ESA reporter now has the art. 3A(2) appointee saving, so the 1 July 2026 abolition leaves the award payable for the whole year and each case still tests the pension-age bar and the Pension Credit condition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../esa_income/esa_income_pension_age.yaml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index 87e715ef82..f460e7cf4b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -139,6 +139,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -175,6 +179,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -203,6 +211,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1] @@ -377,6 +389,10 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -404,10 +420,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] @@ -438,11 +462,19 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in + # payment after the 1 July 2026 abolition, so the model pays it for + # the whole of 2026-27 (esa_income_payable_share). + esa_income_appointee_saving: true benunits: benunit: members: [person1, person2] From 496ee5285939dd4e738c72fe9637126da5d80ce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:33:37 +0000 Subject: [PATCH 096/168] Report the age addition with the full basic rate in the reported weeks test #2340 reads a basic State Pension recipient aged 80 or over as reporting the 25p age addition. The basic reporter is 85, so it now reports the full basic rate plus the addition, and the test checks that the model pays the addition at the data year's 25p in every year. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GwwVa9CRxP94r4i4J --- .../test_state_pension_reported_weeks.py | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py index 1537fec5a7..323d2ca3e8 100644 --- a/policyengine_uk/tests/test_state_pension_reported_weeks.py +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -1,10 +1,11 @@ """The State Pension a survey reports, read back at 52 weeks a year. -basic_state_pension, new_state_pension and additional_state_pension divide -state_pension_reported by policyengine-core's 52 weeks a year and split the -weekly amount at the data year's full rate for the type the person has in the -simulated year. A dataset must therefore annualise the State Pension at 52 -weeks. At 365.25 / 7, which +basic_state_pension, new_state_pension and additional_state_pension take +state_pension_reported less any age addition +(state_pension_reported_excluding_age_addition), divide it by +policyengine-core's 52 weeks a year and split the weekly amount at the data +year's full rate for the type the person has in the simulated year. A dataset +must therefore annualise the State Pension at 52 weeks. At 365.25 / 7, which policyengine-uk-data used until policyengine-uk-data#644, every full-rate pensioner read 0.34% above the full rate and carried an add-on they do not get, which put people whose only income is the full new State Pension on the @@ -14,8 +15,14 @@ data is (ages held fixed, at ages whose type stays the same in every year), and check the route from a reported weekly amount to Income Tax: -- a pensioner reporting exactly the data year's full rate has no add-on in any - year; +- a pensioner reporting the data year's full rate has no add-on in any + year. The new State Pension reporter is 70 and reports the full new rate. + The basic State Pension reporter is 85. With ages held fixed, the model + gives these women the basic type in every year to 2030-31 only from 78. At + 85 a basic State Pension recipient is over 80, so DWP pays her the 25p a + week age addition with the full basic rate. She reports both, and the model + pays the full rate as basic State Pension and the 25p, never uprated, as the + age addition; - Steph, whose only income is the full new State Pension, pays no Income Tax in 2027-28 to 2029-30, as HM Treasury's explainer says, and the same as when her State Pension is given directly; @@ -95,11 +102,19 @@ def full_rates(year) -> dict: } +# The weekly age addition in the data year. DWP pays it to a basic State +# Pension recipient aged 80 or over and never uprates it. +AGE_ADDITION = system.parameters.gov.dwp.state_pension.age_addition.amount(DATA_YEAR) + + @pytest.fixture(scope="module") def full_rate_reporters(): + """A new State Pension reporter at the full new rate, and a basic State + Pension reporter, aged 85, at the full basic rate plus the age addition.""" types = ["NEW", "BASIC"] rates = full_rates(DATA_YEAR) - return survey_simulation([rates[t] for t in types], types), types + weekly = [rates["NEW"], rates["BASIC"] + AGE_ADDITION] + return survey_simulation(weekly, types), types @pytest.mark.parametrize("year", range(DATA_YEAR, 2031)) @@ -115,6 +130,11 @@ def test_full_rate_reporters_have_no_add_on_in_any_year(full_rate_reporters, yea assert calculate(sim, "basic_state_pension", year)[1] == pytest.approx( WEEKS_IN_YEAR * rates["BASIC"], abs=PENNY ) + # The data year's 25p in every year, so not uprated, and none on the new + # State Pension. + assert calculate(sim, "state_pension_age_addition", year).tolist() == ( + pytest.approx([0, WEEKS_IN_YEAR * AGE_ADDITION], abs=PENNY) + ) @pytest.mark.parametrize("year", PTA_YEARS) From d2ce8b54a32b8cadd689c08d00f7c8f58e70dc5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:37:07 +0000 Subject: [PATCH 097/168] Hold the pension-age ESA properties with the 2026 abolition in force The 2026 examples of test_esa_income_pension_age_properties.py draw saved awards, and the three 2026 example tests give each reporter the art. 3A(2) appointee saving, so each award stays payable for the whole year and the tests still check the pension-age bar and the Pension Credit condition. legacy_award_work_reference.esa_abolition_claim reads the saving. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../tests/legacy_award_work_reference.py | 13 ++-- .../test_esa_income_pension_age_properties.py | 74 ++++++++++++++----- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 53db8306c9..7c00330b0b 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -230,16 +230,19 @@ def esa_abolition_claim(couple, parameters): reports (esa_income_report_closed). The abolition reaches every remaining award on 1 July 2026 in England - (SI 2025/1148 art. 3A), the households' default country, and no member - here has the appointee saving, so it reaches any award reported in a - year with less than all of it payable. would_claim_uc_at_legacy_closure + (SI 2025/1148 art. 3A), the households' default country, so it reaches + any award reported in a year with less than all of it payable. An + appointee saving (art. 3A(2), esa_income_appointee_saving) for a member + who reports the award keeps the whole claim payable, and a saving for a + member who reports nothing keeps nothing. would_claim_uc_at_legacy_closure keeps its default, true. The conditions are those of the other working-age closures: no Pension Credit report, and not all pension age. """ payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain - reports_award = any(m.get("esa_income_reported", 0) > 0 for m in couple) + reporters = [m for m in couple if m.get("esa_income_reported", 0) > 0] + saved = any(m.get("esa_income_appointee_saving", False) for m in reporters) on_pension_credit = any(m.get("pension_credit_reported", 0) > 0 for m in couple) - if not reports_award or payable >= 1 or on_pension_credit: + if not reporters or saved or payable >= 1 or on_pension_credit: return False assert all(not 65 <= adult["age"] < 70 for adult in couple), couple return not all(adult["age"] >= 70 for adult in couple) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 37ae283b49..6603810b3f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -39,7 +39,12 @@ mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only a mixed-age couple that keeps the SI 2019/37 art 4 saving (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. -Adults have earnings, pensions and household savings, so some families pass +The Secretary of State appointed 1 July 2026 for the abolition of +income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), +and an appointee saving keeps an award in payment (art. 3A(2)), so every +adult drawn in 2026 has the saving (esa_income_appointee_saving) and each +award stays payable for the whole year; +test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report working tax credit, which HMRC pays without its income test to a family on the award (TCA 2002 s.7(2)). A member outside the couple claims in @@ -118,7 +123,7 @@ @st.composite -def adults(draw, outside=False, tax_credits=False): +def adults(draw, outside=False, tax_credits=False, saved=False): adult = { "age": draw(st.sampled_from(AGES)), "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), @@ -129,6 +134,8 @@ def adults(draw, outside=False, tax_credits=False): "employment_income": draw(st.sampled_from(EARNINGS)), "private_pension_income": draw(st.sampled_from(PENSIONS)), } + if saved: + adult["esa_income_appointee_saving"] = True if outside: adult["current_education"] = "NOT_IN_EDUCATION" elif tax_credits: @@ -137,14 +144,19 @@ def adults(draw, outside=False, tax_credits=False): @st.composite -def families(draw, tax_credits): +def families(draw, tax_credits, saved): """A claimant, an optional partner, an optional member outside the couple, the couple's saving and the household's savings. Members of the couple - report working tax credit only in a year with tax credits.""" - couple = [draw(adults(tax_credits=tax_credits))] + report working tax credit only in a year with tax credits. With saved, + every adult has the appointee saving for income-related ESA.""" + couple = [draw(adults(tax_credits=tax_credits, saved=saved))] if draw(st.booleans()): - couple.append(draw(adults(tax_credits=tax_credits))) - others = [draw(adults(outside=True))] if draw(st.integers(0, 3)) == 0 else [] + couple.append(draw(adults(tax_credits=tax_credits, saved=saved))) + others = ( + [draw(adults(outside=True, saved=saved))] + if draw(st.integers(0, 3)) == 0 + else [] + ) saving = draw(st.booleans()) return couple, others, saving, draw(st.sampled_from(SAVINGS)) @@ -154,7 +166,11 @@ def examples(draw): year = draw(st.sampled_from(YEARS)) # Tax credits ended on 5 April 2025. tax_credits = year < 2025 - units = draw(st.lists(families(tax_credits), min_size=4, max_size=12)) + # The abolition of income-related ESA reaches unsaved awards from 1 July + # 2026 (SI 2025/1148 art. 3A(1)), so every adult drawn in 2026 has the + # appointee saving (art. 3A(2)), which keeps the award payable all year. + saved = year >= 2026 + units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) return units, year @@ -377,6 +393,11 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): start += len(members) +# An appointee saving for income-related ESA (SI 2025/1148 art. 3A(2)), which +# keeps the award payable after the abolition of 1 July 2026. +SAVED = {"esa_income_appointee_saving": True} + + class BranchLog: """Records each branch Core creates, with the simulation it branches.""" @@ -439,15 +460,19 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside a pensioner, and a pensioner reporting the award, who has reached - pensionable age and so has no claim whatever Pension Credit pays.""" + pensionable age and so has no claim whatever Pension Credit pays. Each + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the + abolition of income-related ESA on 1 July 2026 leaves each award payable + for the whole year.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} + reports = {"esa_income_reported": 5_000, **SAVED} sim = Simulation( situation=combine( - household([dict(adult, age=45, esa_income_reported=5_000)], year), + household([dict(adult, age=45, **reports)], year), household( - [dict(adult, age=70), dict(adult, age=60, esa_income_reported=5_000)], + [dict(adult, age=70), dict(adult, age=60, **reports)], year, ), household([dict(adult, age=75)], year), @@ -458,12 +483,12 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( "age": 40, "is_claimant_or_partner": False, "current_education": "NOT_IN_EDUCATION", - "esa_income_reported": 5_000, + **reports, }, ], year, ), - household([dict(adult, age=75, esa_income_reported=5_000)], year), + household([dict(adult, age=75, **reports)], year), ) ) assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000, 0] @@ -669,7 +694,10 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): parameters. A couple aged 70 and 60 without the saving then meets the age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets - them, and the model opens no branch, under the reform or without it.""" + them, and the model opens no branch, under the reform or without it. Each + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the + abolition of income-related ESA on 1 July 2026 leaves each award payable + for the whole year.""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -677,9 +705,11 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): f"{year}-01-01.{year}-12-31": False } } - working_age = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + working_age = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) mixed = household( - [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000, **SAVED)], year, has_mixed_age_couple_pension_credit_saving=False, ) @@ -1100,11 +1130,13 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( remunerative work, and a member outside the couple aged 40 reporting beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of - work passes, and the screen reads both.""" + work passes, and the screen reads both. Each reporter has the appointee + saving (SI 2025/1148 art. 3A(2)), so the abolition of income-related ESA + on 1 July 2026 leaves each report open for the whole year.""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ - household([dict(ADULT, age=75, esa_income_reported=5_000)], year), + household([dict(ADULT, age=75, esa_income_reported=5_000, **SAVED)], year), household( [ dict( @@ -1113,6 +1145,7 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( esa_income_reported=5_000, employment_income=15_000, hours_worked=1_560, + **SAVED, ) ], year, @@ -1125,6 +1158,7 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( "is_claimant_or_partner": False, "current_education": "NOT_IN_EDUCATION", "esa_income_reported": 5_000, + **SAVED, }, ], year, @@ -1136,7 +1170,9 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( assert not PENSION_CREDIT_CONDITIONS & reads.variables(year), reads.reads reads.reads.clear() - out_of_work = household([dict(ADULT, age=45, esa_income_reported=5_000)], year) + out_of_work = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) assert sim.calculate("esa_income_eligible", year).tolist() == [ False, From 71ba27523c06281eeab8a60b644d6098adb8a2c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:48:53 +0000 Subject: [PATCH 098/168] Wrap the pension-age property docstring Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6G23mTmYu4Zu1DbAPa --- .../tests/test_esa_income_pension_age_properties.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 6603810b3f..4d87db386c 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -44,12 +44,13 @@ and an appointee saving keeps an award in payment (art. 3A(2)), so every adult drawn in 2026 has the saving (esa_income_appointee_saving) and each award stays payable for the whole year; -test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass -Pension Credit's means test and others fail it, and in 2016 and 2018 some -report working tax credit, which HMRC pays without its income test to a -family on the award (TCA 2002 s.7(2)). A member outside the couple claims in -their own right and keeps that claim while below pensionable age, whatever -the couple's Pension Credit. +test_esa_income_abolition_properties.py tests unsaved awards. Adults have +earnings, pensions and household savings, so some families pass Pension +Credit's means test and others fail it, and in 2016 and 2018 some report +working tax credit, which HMRC pays without its income test to a family on +the award (TCA 2002 s.7(2)). A member outside the couple claims in their own +right and keeps that claim while below pensionable age, whatever the couple's +Pension Credit. Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving would otherwise read the ESA report, so the generator enters them, and no one From 6602ccce488f1302972331d61ba94f02528d08e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 17:14:26 -0500 Subject: [PATCH 099/168] Narrow the s. 192(4A) citation in the allocation docstring FA 2004 s. 192(4A) raises the Scottish basic rate limit and the Scottish rate limits above it, where a Scottish rate above the Scottish basic rate is chargeable. The docstring and one comment said "the Scottish rate limits". Text only; the AST with docstrings blanked matches 3b54d5397. Co-Authored-By: Claude Opus 5.5 --- .../utils/income_tax_allocation.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 6358bdd92a..c0c8967397 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -22,15 +22,17 @@ Pension relief is the exception. The model takes employee contributions as a net pay arrangement, which the employer deducts from employment income (FA 2004 s. 193(2)), and personal contributions as relief at source, under which -the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a -claim where higher or additional rate tax is chargeable, Parliament raises -the basic and higher rate limits by the contribution (s. 192(4), and for -the Scottish rate limits s. 192(4A)). Deducting the contribution from the -bottom of non-savings income moves every slice of income down against those -limits, as raising them does. Neither arrangement -gives a Step 2 deduction the taxpayer can set against savings or dividends, -so the search holds ``min(pension relief, non-savings income)`` on -non-savings income and shares only the rest. +the payer keeps basic rate tax on the contribution (s. 192(1)). On a claim, +Parliament raises the basic and higher rate limits by the contribution where +higher or additional rate tax is chargeable (s. 192(4)), and for a Scottish +taxpayer the Scottish basic rate limit and the Scottish rate limits above it +where a Scottish rate above the Scottish basic rate is chargeable +(s. 192(4A)). Deducting the contribution from the bottom of non-savings +income moves every slice of income down against those limits, as raising +them does. Neither arrangement gives a Step 2 deduction the taxpayer can set +against savings or dividends, so the search holds +``min(pension relief, non-savings income)`` on non-savings income and shares +only the rest. How the search finds the least tax ---------------------------------- @@ -225,10 +227,10 @@ def _build(inputs, income_tax): non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead - # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which - # deducting the relief from the bottom of non-savings income matches. So - # pension relief stays on non-savings income as far as it reaches; the - # search shares the rest. + # (s. 192(4); for a Scottish taxpayer, the Scottish basic rate limit and + # those above it, s. 192(4A)), which deducting the relief from the bottom + # of non-savings income matches. So pension relief stays on non-savings + # income as far as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), From caeab613dc0c67461a7ea961bc842a1a1e0755e1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 17:36:08 -0500 Subject: [PATCH 100/168] Align #2818's State Pension text with the stacked formulas state_pension_reported's documentation says the three components divide the report by 52, but on this tree they read it less any age addition. Say so. Rewrite the test docstring's sentence on the ages at which the model gives a woman the basic type. Text only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_reported_weeks.py | 2 +- .../variables/gov/dwp/state_pension_reported.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py index 323d2ca3e8..ff770b5e2f 100644 --- a/policyengine_uk/tests/test_state_pension_reported_weeks.py +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -18,7 +18,7 @@ - a pensioner reporting the data year's full rate has no add-on in any year. The new State Pension reporter is 70 and reports the full new rate. The basic State Pension reporter is 85. With ages held fixed, the model - gives these women the basic type in every year to 2030-31 only from 78. At + gives a woman aged 78 or over the basic type in every year to 2030-31. At 85 a basic State Pension recipient is over 80, so DWP pays her the 25p a week age addition with the full basic rate. She reports both, and the model pays the full rate as basic State Pension and the 25p, never uprated, as the diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported.py b/policyengine_uk/variables/gov/dwp/state_pension_reported.py index 1acf348c62..c9b9974073 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported.py @@ -8,11 +8,12 @@ class state_pension_reported(Variable): documentation = ( "The State Pension a survey reports, as an annual amount: the weekly " "amount times 52. basic_state_pension, new_state_pension and " - "additional_state_pension divide it by 52 and split the weekly amount " - "at the data year's full rate for the type the person has in the " - "simulated year, so a dataset must annualise weekly amounts at 52 " - "weeks. At 365.25 / 7, a full-rate pensioner reads 0.34% above the " - "full rate and gets the excess as additional State Pension or a " + "additional_state_pension take it less any age addition " + "(state_pension_reported_excluding_age_addition), divide it by 52 and " + "split the weekly amount at the data year's full rate for the type the " + "person has in the simulated year, so a dataset must annualise weekly " + "amounts at 52 weeks. At 365.25 / 7, a full-rate pensioner reads 0.34% " + "above the full rate and gets the excess as additional State Pension or a " "protected payment." ) definition_period = YEAR From 70563bbde1202082ca3d66307a57a20124b0e0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:39:35 +0000 Subject: [PATCH 101/168] Pay a couple on income-related ESA no Pension Credit WRA 2007 Sch 1 para 6(1)(c) and (d) make the two awards exclusive. Where the couple's Pension Credit without the award is nil, the model pays the award and no Pension Credit, whatever the award would add through the SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregard. Case 18 now expects that, case 25 states the para 4(2) limit, and the combined property requires no family to have both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KPXgYTCw1EWx4JK8T --- changelog.d/2565.fixed.md | 2 +- .../esa_income/esa_income_pension_age.yaml | 100 ++++++++++++++++-- .../test_esa_income_pension_age_properties.py | 25 +++-- .../variables/gov/dwp/esa_income_eligible.py | 27 +++-- .../gov/dwp/pension_credit/pension_credit.py | 30 +++++- 5 files changed, 158 insertions(+), 26 deletions(-) diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md index c686c53a85..aee0e6c5ca 100644 --- a/changelog.d/2565.fixed.md +++ b/changelog.d/2565.fixed.md @@ -1 +1 @@ -The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit, and it pays no Pension Credit to a couple while it pays the couple income-related ESA (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index f460e7cf4b..ae51c8dad0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -8,7 +8,8 @@ # pension credit". The model reads the Pension Credit the couple would get # with no income-related ESA in the family, B(0), and refuses the couple's # claim where B(0) exceeds nil. B(e) is the Pension Credit the family would -# get with the award e. A mixed-age couple has no entitlement to State Pension +# get with the award e. Where B(0) is nil, the model pays the couple's award +# and no Pension Credit, whatever B(e) (cases 18 and 25). A mixed-age couple has no entitlement to State Pension # Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it # keeps the SI 2019/37 art 4 saving. # Nobody here claims Universal Credit (would_claim_uc false), since a couple @@ -599,7 +600,7 @@ pension_credit: 0 esa_income: 4_919.20 -- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets that Pension Credit too. +- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets no Pension Credit. period: 2016 absolute_error_margin: 0.01 input: @@ -623,16 +624,36 @@ household: members: [person1] output: + # In 2016-17 the minimum guarantee for a single claimant is £155.60 a week. # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): - # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week. No state - # is consistent: with Pension Credit in payment, the claimant could not - # have ESA; without ESA, he gets no Pension Credit. With B(0) nil, the - # model pays the award, 52 x 105.60 = 5_491.20, beside B(e) = 52 x 10.10 - # = 525.20. + # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week, or + # 52 x 10.10 = 525.20. B(0) is nil, so he meets para 6(1)(c) and the model + # pays the award, 52 x 105.60 = 5_491.20. + # The model pays no Pension Credit beside it. Parliament allows the award + # only while he "is not entitled to state pension credit" (WRA 2007 Sch 1 + # para 6(1)(c)), so with Pension Credit in payment his ESA would stop, and + # with it the £20 that SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregards + # for a claimant "in receipt of ... employment and support allowance". + # Para 4(2) keeps the £20 after the award ends only where "£20 was + # disregarded in respect of earnings taken into account in that award". He + # works 15 hours a week, under the 16-hour limit, and his £115.50 does not + # exceed the 2016-17 permitted work limit of £115.50: 16 hours at the + # £7.20 national living wage, £115.20, which ESA Regs 2008 reg 45(9A) + # rounds up to the next 50p (esa_exempt_work_higher_earnings_limit, from + # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and + # gov.hmrc.minimum_wage.non_apprentice). So + # ESA Regs 2008 Sch 7 para 5 disregards "any earnings derived from that + # work which do not exceed the limits": all of them, not £20. Para 4(2)(a) + # fails, his Pension Credit would fall back to B(0), nil, and his ESA + # would come back. ESA with no Pension Credit holds; Pension Credit with + # no ESA does not. esa_income_eligible: true esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: 115.50 pension_credit_earnings_disregard: 1_040 - pension_credit: 525.20 + pension_credit_entitlement: 525.20 + pension_credit: 0 - name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. period: 2016 @@ -846,3 +867,66 @@ pension_credit: 0 esa_income_eligible: true esa_income: 5_000 + +- name: Case 25, a couple whose award may count the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £135 a week. + private_pension_income: 7_020 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £115.50 a week for 15 hours, under the 24 hours of para 6(1)(f) + # (ESA Regs 2008 reg 42(1)). + employment_income: 6_006 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The man reports the award and the woman has the earnings, so case 18's + # pension of £50 a week becomes £135 to land the couple in the same + # pairing. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 115.50 + 135 = 250.50 a week, 52 x 250.50 = 13_026. Neither + # member owes income tax or primary Class 1 contributions on £115.50 a + # week in 2016-17. + # B(0): 13_026 - 520 = 12_506, over 12_352.60, so nil. B(e): 13_026 - + # 1_040 = 11_986, so 12_352.60 - 11_986 = 366.60, or 52 x 7.05. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. ESA Regs 2008 Sch 7 para 7(4) + # disregards £20 of earnings "in a case where the claimant's partner is in + # part-time employment and paragraph 6 does not apply". If DWP disregarded + # £20 of the woman's earnings in the man's award, SPC Regs 2002 Sch VI + # para 4(2) keeps the £20 where "£20 was disregarded in respect of + # earnings taken into account in that award" and "the person whose + # earnings qualified for the disegard continues in employment after the + # termination of that award". Once the couple claimed, it could then keep + # 366.60 of Pension Credit with no ESA. The model holds no record of how + # an award started, and pension_credit_earnings_disregard does not apply + # para 4(2), so the model pays this couple the award and no Pension + # Credit, as in case 18. + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 366.60 + pension_credit: 0 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 4d87db386c..0f185b03c8 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -25,13 +25,13 @@ (is_SP_age) and B(0); - nobody who has reached pensionable age is on income-related ESA (is_on_income_related_esa), and a family whose every report comes from such - a member gets none. + a member gets none; +- no family has both Pension Credit and the couple's income-related ESA. DWP disregards £20 a week of earnings while the claimant or partner receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), so some families get -Pension Credit only with the award. Such a family gets both: no state is -consistent there. The test counts such families and asserts nothing more of -them. +Pension Credit only with the award. The model pays such a family the award +and no Pension Credit (pension_credit), and the test counts them. The years are 2016, when a man born before 6 December 1953 could be over the qualifying age for State Pension Credit but under his pensionable age of 65 @@ -339,8 +339,10 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): ("Pension Credit pays without the award", barred), ("Pension Credit refuses a couple with a report", refused & reports[claims]), ( - "both awards paid", - (couple_esa[claims] > 0) & (pension_credit[claims] > 0), + "Pension Credit pays only with the award", + refused + & (couple_esa[claims] > 0) + & (sim.calculate("pension_credit_entitlement", year)[claims] > 0), ), ]: if mask.any(): @@ -359,6 +361,17 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): assert np.allclose(esa[claims][refused], esa[declines][refused], atol=0.01), units # Claiming Pension Credit never raises income-related ESA. assert (esa[claims] <= esa[declines] + 0.01).all(), units + # Sch 1 para 6(1)(c) and (d): no family has both the couple's award and + # Pension Credit, whether or not it would claim. + assert not ((couple_esa > 0) & (pension_credit > 0)).any(), units + if units == ROWS: + # The third row is case 18: B(0) nil and B(e) 525.20, so the man + # keeps his award and gets no Pension Credit. + assert couple_esa[2] == pytest.approx(5_491.20, abs=0.01) + assert sim.calculate("pension_credit_entitlement", year)[2] == pytest.approx( + 525.20, abs=0.01 + ) + assert pension_credit[2] == 0 # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index b966270258..a8937b15a0 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -37,7 +37,8 @@ def pension_credit_follows_model_formulas(benunit, period, parameters): (would_claim_pc). pension_credit equals the entitlement where the family would claim and - nil otherwise. The entitlement sums the guarantee and savings credits for + the couple has no income-related ESA (claimant_or_partner_esa_income), + and nil otherwise. The entitlement sums the guarantee and savings credits for a family eligible for Pension Credit (is_pension_credit_eligible), and gives nil to any other, and only a family that meets the age conditions is eligible. A parameter reform keeps these definitions, and the age @@ -90,11 +91,18 @@ def pension_credit_without_income_related_esa(benunit, period): the claim (would_claim_pc) and an entered award. Where that Pension Credit pays, the screen refuses the couple's award, and the simulation's own Pension Credit then equals the branch's. Where it pays - nothing, the model pays the award. If the award then makes Pension - Credit pay, through the higher earnings disregard, no consistent state - exists: with Pension Credit in payment the family could not have the - award, and without the award it would get no Pension Credit. The model - then pays the award beside the Pension Credit it works out on it. + nothing, the model pays the award, and pension_credit pays nothing while + the couple has it, even where the award would make Pension Credit pay + through the higher earnings disregard. With Pension Credit in payment the + award would stop, and with it the disregard of SPC Regs 2002 Sch VI para + 4(1)(a)(vii). Para 4(2) keeps £20 only where £20 of earnings was + disregarded in the award, and ESA Regs 2008 Sch 7 para 5 disregards a + claimant's permitted-work earnings up to the limit in whole, so such a + family would lose its Pension Credit and get the award back. + pension_credit_earnings_disregard does not apply para 4(2), and the model + holds no record of how an award started, so a couple whose award + disregarded £20 of a partner's part-time earnings (Sch 7 para 7(4)) gets + the award and no Pension Credit too. In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in @@ -148,9 +156,10 @@ class esa_income_eligible(Variable): "and would claim, so the screen works it out only for such a couple. DWP " "disregards £20 a week of earnings while the claimant or partner receives " "ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Where the family gets Pension " - "Credit only with that disregard, no consistent state exists, and the family " - "gets both. A member who claims in their own right keeps their claim whatever the " - "couple's Pension Credit. The claimant must not engage in remunerative work " + "Credit only with that disregard, the screen passes the couple's claim, and " + "pension_credit pays nothing while the couple has the award. A member who " + "claims in their own right keeps their claim whatever the couple's Pension " + "Credit. The claimant must not engage in remunerative work " "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " "24 hours a week or more (ESA Regs 2008 reg 42(1)), subject to the existing " diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index d949955407..29f3a4532e 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -7,12 +7,38 @@ class pension_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/contents" + documentation = ( + "State Pension Credit the family receives: its entitlement where it " + "would claim (would_claim_pc), and nil where the claimant or partner " + "has income-related ESA (claimant_or_partner_esa_income above nil). " + "Parliament allows income-related ESA only where neither the claimant " + "nor the other member of the couple has an entitlement to State " + "Pension Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), " + "so the two awards never meet in one couple. The ESA screen " + "(esa_income_eligible) refuses the couple's award where the family " + "would get Pension Credit without it. Where the family gets Pension " + "Credit only with the award, through the higher earnings disregard " + "for a claimant or partner in receipt of ESA (SPC Regs 2002 Sch VI " + "para 4(1)(a)(vii)), the model pays the award, and this formula " + "pays no Pension Credit. A member outside the couple who claims " + "income-related ESA in their own right leaves the couple's Pension " + "Credit unchanged: in " + "para 6(1)(d) Parliament named only the other member of the " + "claimant's couple. Under gov.contrib.freeze_pension_credit, a reform " + "pays the baseline's award." + ) + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/contents", + "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + ) def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - amount = entitlement * would_claim + # WRA 2007 Sch 1 para 6(1)(c) and (d): with the couple's + # income-related ESA in payment, the couple has no Pension Credit. + couple_esa = benunit("claimant_or_partner_esa_income", period) + amount = where(couple_esa > 0, 0, entitlement * would_claim) freeze = parameters(period).gov.contrib.freeze_pension_credit baseline = benunit.simulation.baseline From a1771bb2eef9a5dceec751897e0d053a18e5af2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:50:09 +0000 Subject: [PATCH 102/168] Check the outside reporter's row in the pension-age ESA property Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KPXgYTCw1EWx4JK8T --- .../tests/test_esa_income_pension_age_properties.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 0f185b03c8..5c17a3a7c8 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -372,6 +372,12 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): 525.20, abs=0.01 ) assert pension_credit[2] == 0 + # The fifth row is case 12's pairing: the member outside the couple + # keeps their 3,000 beside the couple's minimum guarantee, 52 x + # 155.60 = 8,091.20, whether or not the couple would claim. + assert esa[[4, n + 4]] == pytest.approx([3_000, 3_000], abs=0.01) + assert couple_esa[4] == 0 + assert pension_credit[4] == pytest.approx(8_091.20, abs=0.01) # The screen, family by family. eligible = sim.calculate("esa_income_eligible", year) From 4ebd4dfe894e532bd8bf41295c015a61805b2b7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:20:27 +0000 Subject: [PATCH 103/168] Leave the Step 7 charges out of the pensioner Council Tax Reduction deduction T(E, S, U) now zeroes income_tax_charges beside the uncounted income: the High Income Child Benefit Charge recovers Child Benefit, which the pensioner schemes do not count, and the pension annual allowance charge falls on contributions. Add YAML cases for the charge, a working-age partner's Class 1 in a pensioner scheme, reg 37(1)'s personal relief with Marriage Allowance, and the Scottish Marriage Allowance cliff; a property test that a Child Benefit claim leaves the pensioner deduction unchanged; and assert invariant 5 on a simulation with the tax cliffs held, in place of the exemption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HngezwZWa7uwAqaXr --- changelog.d/2090.fixed.md | 2 +- .../council_tax_reduction_tax_deduction.yaml | 265 ++++++++++++++++++ ..._tax_reduction_tax_deduction_properties.py | 84 +++++- ...tion_income_tax_on_other_counted_income.py | 32 ++- ...ion_scotland_self_employment_deductions.py | 17 +- .../council_tax_reduction_tax_deduction.py | 12 +- 6 files changed, 380 insertions(+), 32 deletions(-) diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index fe34b9d788..d83a5b2418 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement where earnings do not raise it. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index b9d4d84e67..f0f503e326 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -467,6 +467,204 @@ # 60,000 - 9,486 - 260. council_tax_reduction_applicable_income: 50_254 +- name: England's pensioner scheme deducts no High Income Child Benefit Charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Tax on the pension: 70,000 - 12,570 = 57,430 taxable; 37,700 x 20% = + # 7,540 and 19,730 x 40% = 7,892, so 15,432. Child Benefit 27.05 x 52 = + # 1,406.60, 1,406 in whole pounds; the charge takes (70,000 - 60,000) / + # 200 = 50% of it, 703, so income tax before the winter fuel payment + # charge comes to 16,135. The scheme does not count Child Benefit, and the + # charge recovers it, so the charge is not tax payable on income taken + # into account. + income_tax_before_winter_fuel_payment_charge: [16_135, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432; the scheme does not count Child Benefit. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the same tax without a Child Benefit claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: false + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # No Child Benefit, so no charge: (70,000 - 12,570) taxed as above, 15,432. + income_tax_before_winter_fuel_payment_charge: [15_432, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the rise in the Child Benefit charge that pay causes + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + employment_income: 5_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 75,000 - 12,570 = 62,430 taxable; 7,540 + 24,730 x 40% = + # 9,892, so 17,432; the charge takes (75,000 - 60,000) / 200 = 75% of + # 1,406, 1,054 in whole pounds; T = 18,486. Without the pay: 15,432 and + # a charge of 703, T(E) = 16,135. T - T(E) = 2,351: the 2,000 of tax on + # the pay and the 351 rise in the charge. T(E, S, U) leaves out the charge + # the pensioner would pay without the pay: 15,432. No Class 1 above + # pension age. 2,351 + 15,432 = 17,783. + income_tax_before_winter_fuel_payment_charge: [18_486, 0] + council_tax_reduction_tax_deduction: 17_783 + # 75,000 - 17,783 - the 25 a week lone parent's earnings disregard (1,300). + council_tax_reduction_applicable_income: 55_917 + +- name: England's pensioner scheme deducts a Child Benefit charge that pay causes in full + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 50_000 + employment_income: 30_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Without the pay: (50,000 - 12,570) x 20% = 7,486, and adjusted net + # income 50,000 brings no charge. With it: 80,000 - 12,570 = 67,430 + # taxable; the pay adds 270 x 20% = 54 and 29,730 x 40% = 11,892, so + # 11,946; the charge takes (80,000 - 60,000) / 200 = 100% of 1,406. T = + # 7,486 + 11,946 + 1,406 = 20,838; T(E) = 7,486; T(E, S, U) = 7,486. The + # deduction comes to the whole liability. + income_tax_before_winter_fuel_payment_charge: [20_838, 0] + council_tax_reduction_tax_deduction: 20_838 + # 80,000 - 20,838 - 1,300. + council_tax_reduction_applicable_income: 57_862 + +- name: England's pensioner scheme deducts a working-age partner's Class 1 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + partner: + age: 60 + employment_income: 20_000 + benunits: + benunit: + members: [pensioner, partner] + would_claim_uc: false + households: + household: + members: [pensioner, partner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Para 19(2)(a) deducts the income tax and primary Class 1 deducted from + # the partner's pay. T: the pension bears (20,000 - 12,570) x 20% = 1,486 + # and the pay the same 1,486, so 2,972. Without the pay the partner has + # no income, and the model transfers 1,260 of Marriage Allowance to the + # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: + # T(E) = 1,234, so the pay adds 2,972 - 1,234 = 1,738. T(E, S, U) = + # 1,234. Class 1 (20,000 - 12,569.96) x 8% = 594.40. 1,738 + 1,234 + + # 594.40 = 3,566.40. + council_tax_reduction_tax_deduction: 3_566.40 + # 40,000 - 3,566.40 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_913.60 + +- name: Scotland's pensioner scheme allows only the personal relief against a profit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + self_employment_income: 30_000 + younger: + age: 68 + private_pension_income: 10_000 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # The younger partner's pension falls within the personal allowance, so + # the model transfers 1,260 of Marriage Allowance to the older partner. + marriage_allowance: [1_260, 0] + # SSI 2012/319 reg 37(1) allows "only the personal relief", so the + # Marriage Allowance does not come off: (30,000 - 12,570) x 20% = 3,486. + # No Class 4 above pension age. T(E, S, U): without the profit the older + # partner has no income and the pension bears no tax, so nil. No + # employed earnings. + council_tax_reduction_tax_deduction: 3_486 + # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_994 + - name: Wales's working-age scheme still deducts the whole income tax period: 2026 absolute_error_margin: 0.01 @@ -491,3 +689,70 @@ # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. council_tax_reduction_tax_deduction: 4_080.40 council_tax_reduction_applicable_income: 25_919.60 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,661 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_661 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable non-savings income 43,661 - 12,570 = 31,091, within the + # intermediate band, so the earner takes the partner's 1,260 of Marriage + # Allowance, which the model takes off taxable income: 29,831 taxable; + # 3,967 x 19% = 753.73, 12,989 x 20% = 2,597.80 and 12,875 x 21% = + # 2,703.75, so 6,055.28. Without the pay, nil. Class 1 (43,661 - + # 12,569.96) x 8% = 2,487.28. 6,055.28 + 2,487.28 = 8,542.56. + marriage_allowance: [1_260, 0] + council_tax_reduction_tax_deduction: 8_542.56 + # 43,661 - 8,542.56. + council_tax_reduction_applicable_income: 35_118.44 + # 15,000 - 20% x (35,118.44 - the couple's allowance 150.15 x 52 = + # 7,807.80). + council_tax_reduction: 9_537.87 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,662 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_662 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 31,092 reaches the Scottish higher band, so no Marriage + # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56, so 6,320.09. + # Class 1 (43,662 - 12,569.96) x 8% = 2,487.36. 6,320.09 + 2,487.36 = + # 8,807.45: one pound of pay raises the deduction by 264.89. + marriage_allowance: [0, 0] + council_tax_reduction_tax_deduction: 8_807.45 + # 43,662 - 8,807.45. + council_tax_reduction_applicable_income: 34_854.55 + # 15,000 - 20% x (34,854.55 - 7,807.80): the reduction rises by 52.78. + council_tax_reduction: 9_590.65 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 6aeb80bf30..ed6eada144 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -72,7 +72,9 @@ def maybe(high): def simulate(households, reform=None): """One simulation of households. Each household is a dict with country, benunits (a list of lists of person input dicts; the first benunit is - the Council Tax Reduction applicant's) and, optionally, uc.""" + the Council Tax Reduction applicant's) and, optionally, uc and + child_benefit (whether the applicant's benefit unit claims Child + Benefit).""" situation = {"people": {}, "benunits": {}, "households": {}} for h, household in enumerate(households): names = [] @@ -86,6 +88,10 @@ def simulate(households, reform=None): "members": members, "would_claim_uc": {YEAR: household.get("uc", False)}, } + if b == 0 and "child_benefit" in household: + situation["benunits"][f"b{h}_{b}"]["would_claim_child_benefit"] = { + YEAR: household["child_benefit"] + } names += members country = household["country"] situation["households"][f"h{h}"] = { @@ -251,6 +257,53 @@ def without_notional(simulation): ) +@st.composite +def pensioner_parent_household(draw): + """A pensioner family with one or two children and no employed earnings, + each adult drawing a private pension of up to 120,000, so that the + applicant's adjusted net income often exceeds the 60,000 at which the High + Income Child Benefit Charge starts.""" + h = draw(household(pensioner=True, earnings=False)) + adults = h["benunits"][0] + for a in adults: + a["private_pension_income"] = draw(money(120_000)) + children = [dict(age=draw(st.integers(0, 15)))] + if draw(st.booleans()): + children.append(dict(age=draw(st.integers(0, 15)))) + return {**h, "benunits": [adults + children]} + + +@PROPERTY_SETTINGS +@given(st.lists(pensioner_parent_household(), min_size=1, max_size=4)) +def test_pensioner_deduction_ignores_a_child_benefit_claim(households): + """Invariant 2, for Child Benefit. In every pensioner scheme, with no + employed earnings, the deduction does not change with a claim for Child + Benefit. The pensioner schemes do not count Child Benefit, and the High + Income Child Benefit Charge recovers it, so the charge is not tax payable + on income taken into account. The test compares two simulations of the + same families, one claiming Child Benefit and one not.""" + claiming = [{**h, "child_benefit": True} for h in households] + not_claiming = [{**h, "child_benefit": False} for h in households] + with_claim = simulate(claiming) + without_claim = simulate(not_claiming) + schemes(with_claim, households) + charge = applicant_total(with_claim, "CB_HITC") + for charged in charge > 0: + event("High Income Child Benefit Charge" if charged else "no charge") + assert np.all(benunit_values(with_claim, "child_benefit") > 0) + assert np.all(benunit_values(without_claim, "child_benefit") == 0) + assert np.allclose( + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + atol=0.01, + ), ( + households, + charge, + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + ) + + @st.composite def any_household(draw): return draw(household()) @@ -325,12 +378,17 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction, except where the rise crosses a cliff in the income tax - system: losing Marriage Allowance, or the personal savings allowance - falling as the person enters a higher band. Those cliffs raise the tax - the earnings add by more than the rise in pay, as the ruling intends; for - example a Scottish couple whose pay rises from 43,661 to 43,662 loses - Marriage Allowance and its Council Tax Reduction rises by 52.78.""" + Reduction once the income tax system's cliffs are held still. Two cliffs + raise the tax the earnings add by more than the rise in pay, and option + (a) passes both through to the deduction: losing Marriage Allowance, and + the personal savings allowance falling as the person enters a higher + band. A Scottish couple, one partner with no income and council tax of + 15,000, whose pay rises from 43,661 to 43,662 loses Marriage Allowance: + the deduction rises by 264.89 and the reduction by 52.78 (the YAML cases + pin both). The test therefore builds a third simulation of the raised + families with marriage_allowance and savings_allowance held at their + values before the rise, and asserts there, for every family, that the + reduction does not rise.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -341,22 +399,26 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) + held = simulate(raised) + cliffs = ["marriage_allowance", "savings_allowance"] + for variable in cliffs: + held.set_input(variable, YEAR, np.asarray(before.calculate(variable, YEAR))) schemes(before, households) reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) - reduction_after = np.asarray(after.calculate("council_tax_reduction", YEAR)) + reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) cliff = np.zeros(len(households), dtype=bool) - for variable in ["marriage_allowance", "savings_allowance"]: + for variable in cliffs: cliff |= ~np.isclose( applicant_total(before, variable), applicant_total(after, variable) ) for crossed in cliff: event("crosses a tax cliff" if crossed else "no tax cliff") - assert np.all((reduction_after <= reduction_before + 0.01) | cliff), ( + assert np.all(reduction_held <= reduction_before + 0.01), ( households, rise, partner, reduction_before, - reduction_after, + reduction_held, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index 1375bb3312..186457263d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -18,6 +18,14 @@ "dividend_income", ] +# The Step 7 charges of ITA 2007 s. 23 other than the winter fuel payment +# charge (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child +# Benefit Charge and the pension annual allowance charge). Neither falls on +# income the pensioner schemes count: the first recovers Child Benefit, which +# council_tax_reduction_applicable_income leaves out on the pensioner route, +# and the second falls on pension contributions. +STEP_7_CHARGES = ["income_tax_charges"] + class council_tax_reduction_income_tax_on_other_counted_income(Variable): value_type = float @@ -29,25 +37,29 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "and statutory sick, maternity and paternity pay), self-employment " "income, or the taxed income the pensioner schemes do not count: the " "Scottish Carer Supplement, miscellaneous income, benefits in kind, " - "savings interest, other investment income and dividends. This is the " - "tax on the rest of the income the pensioner schemes count, with the " - "uncounted income above it. The formula zeroes those inputs on a " - "branch that it drops afterwards, and builds the branch only when " - "someone has uncounted income." + "savings interest, other investment income and dividends. It also " + "leaves out the Step 7 charges of ITA 2007 s. 23 that " + "income_tax_charges holds, the High Income Child Benefit Charge and " + "the pension annual allowance charge: the first recovers Child " + "Benefit, which the pensioner schemes do not count, and the second " + "falls on pension contributions, so neither is tax payable on income " + "taken into account. This is the tax on the rest of the income the " + "pensioner schemes count, with the uncounted income above it. The " + "formula zeroes those inputs and the charges on a branch that it " + "drops afterwards, and builds the branch only when someone has " + "uncounted income or a charge." ) definition_period = YEAR unit = GBP def formula(person, period, parameters): - if not any( - (person(variable, period) != 0).any() - for variable in PENSIONER_UNCOUNTED_INCOME - ): + zeroed = PENSIONER_UNCOUNTED_INCOME + STEP_7_CHARGES + if not any((person(variable, period) != 0).any() for variable in zeroed): return person("pension_credit_income_tax_without_earnings", period) return income_tax_with_inputs_zeroed( person.simulation, period, employed_earnings_sources(parameters, period) + ["self_employment_income"] - + PENSIONER_UNCOUNTED_INCOME, + + zeroed, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 8d2d262dc5..d5e754de84 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -16,18 +16,21 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "SSI 2021/249 reg 51(9) applies at working age where nobody in the " "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " "pension age. The formula charges notional income tax at the basic " - "rate (the Scottish basic rate for a Scottish taxpayer) on the profit " + "rate (the formula reads the Scottish basic rate for a Scottish " + "taxpayer; both rates are 20%) on the profit " "less only the personal allowance; Class 4 contributions at the main " "rate on the profit between the lower and upper profits limits, where " "Class 4 is payable at all; and, for years before 2024-25, the Class " "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " - "and reg 37(2) on 6 April 2024. The working-age rule names the " + "and reg 37(2) on 6 April 2024. SSI 2012/303 reg 38(1) allows the " "personal relief under ITA 2007 s. 35(1); the formula reads it as the " - "allowance s. 35(2) leaves after the taper, because s. 35(2) reduces " - "'the allowance under subsection (1)'. The pension-age rule names the " - "relief under ss. 36 or 37; Finance Act 2014 omitted s. 36, and s. 37 " - "gives the section 35 amount to a person born before 6 April 1938, " - "so the formula reads both as the same tapered s. 35 allowance. A " + "allowance left after the s. 35(2) taper, because s. 35(2) reduces " + "'the allowance under subsection (1)'. SSI 2012/319 reg 37(1) allows " + "the relief under ss. 36 or 37. Parliament omitted s. 36 (Finance Act " + "2014), and s. 37 gives the section 35 amount only to a person born " + "before 6 April 1938; read literally, the reference gives nobody " + "younger any relief. The formula reads it as the same tapered s. 35 " + "allowance for every applicant. A " "child minder's chargeable income is one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index ee51b04d54..62a83eacb7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -36,7 +36,11 @@ class council_tax_reduction_tax_deduction(Variable): "disregard the tax deducted from earnings and the tax payable on " "income taken into account, so the formula deducts T less T(E), " "never negative; T(E, S, U), the tax on the other counted income " - "with the uncounted income above it; the notional tax and " + "with the uncounted income above it and without the Step 7 charges in " + "income_tax_charges (the High Income Child Benefit Charge recovers " + "Child Benefit, which these schemes do not count, and the pension " + "annual allowance charge falls on contributions, so neither is tax " + "payable on income taken into account); the notional tax and " "contributions on profit (pension_credit_self_employment_deductions " "in England and Wales, the Scottish variable in Scotland); and " "primary Class 1 contributions. Earnings are the top slice of income " @@ -131,8 +135,10 @@ def total(values): period, ) ) - # SI 2012/2885 Sch 1 para 30 and WSI 2013/3029 Sch 1 para 24 price - # the notional tax as Pension Credit does. + # The formula prices the English and Welsh notional tax with + # Pension Credit's variable, because SI 2012/2885 Sch 1 para 30 and + # WSI 2013/3029 Sch 1 para 24 allow the same personal reliefs (ITA + # 2007 Part 3 Chapters 2, 3 and 3A). england_and_wales_notional = total( person("pension_credit_self_employment_deductions", period) ) From d5b8d12d87cdf159d3d6172cf86921d76c82ecbc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:51:47 +0000 Subject: [PATCH 104/168] Pin own awards and Income Support against the 19th-birthday terminal date Add YAML cases 13 and 14: a 19-year-old at school before the 1 September following their 19th birthday who reports their own Universal Credit or income-related ESA is a qualifying young person for neither programme. State Income Support as the exception to Universal Credit and Pension Credit agreeing, assert the agreement grid holds nobody on it, and test that own Income Support in 2025-26 ends the Pension Credit status only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Jra6ok6eCQvZUMMLg --- ...qualifying_young_person_terminal_date.yaml | 53 ++++++++++++ ..._child_and_adult_definitions_properties.py | 8 +- ...g_young_person_terminal_date_properties.py | 81 ++++++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml index 3a3e2493f8..69535d4b29 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -294,3 +294,56 @@ is_before_first_september_following_19th_birthday: [true, false, true] is_qualifying_young_person_for_universal_credit: [false, false, true] is_qualifying_young_person_for_pension_credit: [false, false, true] + +- name: Case 13, a 19-year-old at school before the date who reports their own Universal Credit is no qualifying young person. + # UC reg. 5(5) and SPC reg. 4A(5) both list Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + universal_credit_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 14, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own income-related ESA. + # SPC reg. 4A(5) and UC reg. 5(5) both list employment and support allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The pensioner, aged 70, is neither under 19 nor before the date. + meets_qualifying_young_person_terminal_date_condition_for_pension_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + child_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 34431e6fab..630693f7da 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -64,8 +64,12 @@ contracts, approved providers and interruptions are unobserved. CB/CTC leaver extensions are omitted. UC/PC's route to the 1 September after the 16th birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). UC's terminal date is an input; PC's September -terminal date is absent. +before that date (invariant 5). The tests here set UC's terminal-date input +false; PC's terminal date follows the 19th-birthday date fact, which is true +for everyone under 20 when only an age is given +(test_qualifying_young_person_terminal_date_properties.py covers both dates). +Nobody here reports Income Support, which SPC reg 4A(5) lists and UC reg 5(5) +does not. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py index 8d4975de36..66318d090c 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -14,8 +14,13 @@ 2. Universal Credit's own terminal-date variable follows that fact unless it is set, and setting it changes Universal Credit only. 3. Over every combination of age, education, training, apprenticeship, - own-right benefits, entry age and the two date facts: Universal Credit - and Pension Credit agree, and both equal the regulation's conditions. + own-right Universal Credit, ESA or JSA, entry age and the two date facts, + for people not on Income Support: Universal Credit and Pension Credit + agree, and both equal the regulation's conditions. Income Support is the + one exception: SPC reg. 4A(5) lists it and UC reg. 5(5) does not, so a + young person on their own Income Support keeps the Universal Credit status + and loses the Pension Credit one, on either route and on either side of + the 1 September following the 19th birthday. 4. With the 19th-birthday fact on, both statuses are the earlier definition without a terminal date, so nothing changes below 19 or for a 19-year-old before the date; switching the fact on never removes the status; and it @@ -203,6 +208,9 @@ def qualifying_young_person(p, terminal_date=True): def test_universal_credit_and_pension_credit_agree_on_every_combination(): sim = simulate(GRID) + # The population invariant 3 covers: Income Support stops paying before + # 2026-27 (gov.dwp.income_support.active), so nobody here is on it. + assert not sim.calculate("is_on_income_support", YEAR).any() uc, pc = sim.calculate(UC, YEAR), sim.calculate(PC, YEAR) expected = np.array([qualifying_young_person(p) for p in GRID]) # Invariant 3. @@ -223,6 +231,75 @@ def test_universal_credit_and_pension_credit_agree_on_every_combination(): ) +def test_own_income_support_ends_pension_credit_status_only(): + # Invariant 3's exception, in 2025-26, while Income Support pays. A + # pensioner aged 70 with one member aged 16 to 19, over education, entry + # age, both date facts and the member's own reported Income Support. + year = 2025 + rows = list( + product( + range(16, 20), + ("NOT_IN_EDUCATION", "UPPER_SECONDARY"), + (17, 19), + (False, True), + (False, True), + (0, 5_000), + ) + ) + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, education, entry_age, sixteen, nineteen, report) in enumerate(rows): + members = [f"pensioner{i}", f"young{i}"] + situation["people"][members[0]] = { + "age": {year: 70}, + "is_parent": {year: True}, + "is_benunit_head": {year: True}, + } + situation["people"][members[1]] = { + "age": {year: age}, + "is_benunit_head": {year: False}, + "current_education": {year: education}, + "age_started_or_accepted_current_education_or_training": {year: entry_age}, + SIXTEEN: {year: sixteen}, + NINETEEN: {year: nineteen}, + "income_support_reported": {year: report}, + } + situation["benunits"][f"b{i}"] = {"members": members} + situation["households"][f"h{i}"] = {"members": members} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + young = slice(1, None, 2) + own_income_support = np.array([row[-1] > 0 for row in rows]) + np.testing.assert_array_equal( + sim.calculate("is_on_income_support", year)[young], own_income_support + ) + expected = np.array( + [ + qualifying_young_person( + person( + age, + current_education=education, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + ) + for age, education, entry_age, sixteen, nineteen, _ in rows + ] + ) + np.testing.assert_array_equal(sim.calculate(UC, year)[young], expected) + np.testing.assert_array_equal( + sim.calculate(PC, year)[young], expected & ~own_income_support + ) + # Not vacuous: own Income Support removes the Pension Credit status from + # a 19-year-old before the date. + assert any( + e and o and row[0] == 19 and row[4] + for e, o, row in zip(expected, own_income_support, rows) + ) + + def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): # Invariant 2: a 19-year-old at school, with each programme's input set # against the fact. From 93d59f052ae6d5c2a08a5333332544fe78f83992 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:55:34 +0000 Subject: [PATCH 105/168] Drop the list marker from the terminal-date changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Jra6ok6eCQvZUMMLg --- changelog.d/qyp-terminal-date-19.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md index f6ffcb4951..844e2a2657 100644 --- a/changelog.d/qyp-terminal-date-19.fixed.md +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -1 +1 @@ -- Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). A 19-year-old in education now counts for Universal Credit by default in household calculations, as in the microdata, and Pension Credit applies the date, which it did not model. +Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). A 19-year-old in education now counts for Universal Credit by default in household calculations, as in the microdata, and Pension Credit applies the date, which it did not model. From 419bccb447a8fbb005c2ade0304cad43046dad28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:16:28 +0000 Subject: [PATCH 106/168] Name every route to the ESA-only row and the other reading of para 4(2)(a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 26 takes a couple whose award could disregard £20 of the partner's earnings under ESA Regs 2008 Sch 7 para 6. The docstring and case 25 name paras 6 and 7, case 18 states the other reading of SPC Regs Sch VI para 4(2)(a), and the comments give DWP or the formula the verb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../esa_income/esa_income_pension_age.yaml | 138 ++++++++++++++---- .../variables/gov/dwp/esa_income_eligible.py | 12 +- .../gov/dwp/pension_credit/pension_credit.py | 4 +- 3 files changed, 118 insertions(+), 36 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index ae51c8dad0..c5365e5325 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -9,9 +9,10 @@ # with no income-related ESA in the family, B(0), and refuses the couple's # claim where B(0) exceeds nil. B(e) is the Pension Credit the family would # get with the award e. Where B(0) is nil, the model pays the couple's award -# and no Pension Credit, whatever B(e) (cases 18 and 25). A mixed-age couple has no entitlement to State Pension -# Credit from 15 May 2019 (State Pension Credit Act 2002 s.4(1A)) unless it -# keeps the SI 2019/37 art 4 saving. +# and no Pension Credit, whatever B(e) (cases 18, 25 and 26). A mixed-age +# couple has no entitlement to State Pension Credit from 15 May 2019 (State +# Pension Credit Act 2002 s.4(1A)) unless it keeps the SI 2019/37 art 4 +# saving. # Nobody here claims Universal Credit (would_claim_uc false), since a couple # that claims it loses its income-related ESA anyway. No case has capital, so # no tariff income: the model pays an award that passes at the amount @@ -632,21 +633,26 @@ # The model pays no Pension Credit beside it. Parliament allows the award # only while he "is not entitled to state pension credit" (WRA 2007 Sch 1 # para 6(1)(c)), so with Pension Credit in payment his ESA would stop, and - # with it the £20 that SPC Regs 2002 Sch VI para 4(1)(a)(vii) disregards - # for a claimant "in receipt of ... employment and support allowance". - # Para 4(2) keeps the £20 after the award ends only where "£20 was - # disregarded in respect of earnings taken into account in that award". He - # works 15 hours a week, under the 16-hour limit, and his £115.50 does not - # exceed the 2016-17 permitted work limit of £115.50: 16 hours at the - # £7.20 national living wage, £115.20, which ESA Regs 2008 reg 45(9A) - # rounds up to the next 50p (esa_exempt_work_higher_earnings_limit, from + # with it the £20 that DWP disregards for a claimant "in receipt of ... + # employment and support allowance" (SPC Regs 2002 Sch VI para + # 4(1)(a)(vii)). DWP keeps disregarding the £20 after the award ends only + # where (para 4(2)) "£20 was disregarded in respect of earnings taken into + # account in that award". He works 15 hours a week, under the 16-hour + # limit, and his £115.50 does not exceed the 2016-17 permitted work limit + # of £115.50: 16 hours at the £7.20 national living wage, £115.20, which + # esa_exempt_work_higher_earnings_limit rounds up to the next 50p, citing + # ESA Regs 2008 reg 45(9A) (from # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and - # gov.hmrc.minimum_wage.non_apprentice). So - # ESA Regs 2008 Sch 7 para 5 disregards "any earnings derived from that - # work which do not exceed the limits": all of them, not £20. Para 4(2)(a) - # fails, his Pension Credit would fall back to B(0), nil, and his ESA - # would come back. ESA with no Pension Credit holds; Pension Credit with - # no ESA does not. + # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings + # derived from that work which do not exceed the limits" (ESA Regs 2008 + # Sch 7 para 5): all of them, not £20. He fails para 4(2)(a), his Pension + # Credit would fall back to B(0), nil, and his ESA would come back. ESA + # with no Pension Credit holds; Pension Credit with no ESA does not. + # On another reading, para 4(2)(a) holds wherever the award disregarded at + # least £20 of earnings, and he would then keep £525.20 of Pension Credit + # with no ESA. The model takes "£20 was disregarded" to mean the £20 + # disregard of Sch 7 paras 6 and 7(1), as para 5 disregards "any earnings + # ... which do not exceed the limits", not £20. esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -911,22 +917,98 @@ # 1_040 = 11_986, so 12_352.60 - 11_986 = 366.60, or 52 x 7.05. Neither # member has reached pensionable age, so no savings credit is due (SPCA # 2002 s.3(1)). - # Here the law may differ from the model. ESA Regs 2008 Sch 7 para 7(4) - # disregards £20 of earnings "in a case where the claimant's partner is in - # part-time employment and paragraph 6 does not apply". If DWP disregarded - # £20 of the woman's earnings in the man's award, SPC Regs 2002 Sch VI - # para 4(2) keeps the £20 where "£20 was disregarded in respect of - # earnings taken into account in that award" and "the person whose - # earnings qualified for the disegard continues in employment after the - # termination of that award". Once the couple claimed, it could then keep - # 366.60 of Pension Credit with no ESA. The model holds no record of how + # Here the law may differ from the model. DWP disregards £20 of earnings + # "in a case where the claimant's partner is in part-time employment and + # paragraph 6 does not apply" (ESA Regs 2008 Sch 7 para 7(4)). If DWP + # disregarded £20 of the woman's earnings in the man's award, DWP keeps + # disregarding the £20 (SPC Regs 2002 Sch VI para 4(2)) where "£20 was + # disregarded in respect of earnings taken into account in that award" + # and "the person whose earnings qualified for the disegard continues in + # employment after the termination of that award". Once the couple + # claimed, it could then keep 366.60 of Pension Credit with no ESA. The + # model holds no record of how an award started, and + # pension_credit_earnings_disregard does not apply para 4(2), so the model + # pays this couple the award and no Pension Credit, as in case 18. + # The same holds where DWP disregarded £20 of the partner's earnings + # because the claimant's permitted-work earnings fell short of the limit + # (Sch 7 para 6, case 26), or £20 of the claimant's own earnings (Sch 7 + # para 7(1) and (2)). + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 366.60 + pension_credit: 0 + +- name: Case 26, a couple whose award may count £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £90 a week for 15 hours, under the 16 hours and the £115.50 limit of + # permitted work (ESA Regs 2008 reg 45(4)). + employment_income: 4_680 + hours_worked: 780 + # £130 a week. + private_pension_income: 6_760 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £30 a week for 5 hours, under the 24 hours of para 6(1)(f) (ESA Regs + # 2008 reg 42(1)). + employment_income: 1_560 + hours_worked: 260 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 90 + 130 + 30 = 250 a week, 52 x 250 = 13_000. Neither member + # owes primary Class 1 contributions on £90 or £30 a week in 2016-17. His + # £11,440 of earnings and pension exceeds the £11,000 personal allowance + # by £440, and the model transfers £1,100 of her unused allowance to him + # (marriage_allowance, 10% of £11,000), so neither member owes income tax + # and the earnings count in full. + # B(0): 13_000 - 520 = 12_480, over 12_352.60, so nil. B(e): 13_000 - + # 1_040 = 11_960, so 12_352.60 - 11_960 = 392.60, or 52 x 7.55. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. His £90 falls £25.50 short of + # the £115.50 limit, so DWP disregards the woman's earnings "to the extent + # that the claimant's earnings are less than ... 16 x National Minimum + # Wage, but only up to a maximum of £20" (ESA Regs 2008 Sch 7 para 6): + # £20 of her £30. DWP keeps disregarding the £20 (SPC Regs 2002 Sch VI + # para 4(2)) where "£20 was disregarded in respect of earnings taken into + # account in that award" and "the person whose earnings qualified for the + # disegard continues in employment after the termination of that award". + # Once the couple claimed, it could then keep 392.60 of Pension Credit + # with no ESA while she stays in work. The model holds no record of how # an award started, and pension_credit_earnings_disregard does not apply # para 4(2), so the model pays this couple the award and no Pension - # Credit, as in case 18. + # Credit, as in cases 18 and 25. meets_pension_credit_age_conditions: true esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: [115.50, 115.50] + pension_credit_employment_income_tax: 0 pension_credit_earnings_disregard: 1_040 - pension_credit_entitlement: 366.60 + pension_credit_income: 11_960 + pension_credit_entitlement: 392.60 pension_credit: 0 diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index a8937b15a0..93714aa986 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -95,14 +95,14 @@ def pension_credit_without_income_related_esa(benunit, period): the couple has it, even where the award would make Pension Credit pay through the higher earnings disregard. With Pension Credit in payment the award would stop, and with it the disregard of SPC Regs 2002 Sch VI para - 4(1)(a)(vii). Para 4(2) keeps £20 only where £20 of earnings was - disregarded in the award, and ESA Regs 2008 Sch 7 para 5 disregards a - claimant's permitted-work earnings up to the limit in whole, so such a - family would lose its Pension Credit and get the award back. + 4(1)(a)(vii). DWP keeps disregarding £20 only where it disregarded £20 of + earnings in the award (para 4(2)), and it disregards all of a claimant's + permitted-work earnings up to the limit (ESA Regs 2008 Sch 7 para 5), so + such a family would lose its Pension Credit and get the award back. pension_credit_earnings_disregard does not apply para 4(2), and the model holds no record of how an award started, so a couple whose award - disregarded £20 of a partner's part-time earnings (Sch 7 para 7(4)) gets - the award and no Pension Credit too. + disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7) gets the + award and no Pension Credit too. In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index 29f3a4532e..acff1f9705 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -24,8 +24,8 @@ class pension_credit(Variable): "income-related ESA in their own right leaves the couple's Pension " "Credit unchanged: in " "para 6(1)(d) Parliament named only the other member of the " - "claimant's couple. Under gov.contrib.freeze_pension_credit, a reform " - "pays the baseline's award." + "claimant's couple. Under gov.contrib.freeze_pension_credit, the " + "formula returns the baseline's award." ) reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/contents", From d6bf2f6576a890fbfcd7042d501f5b839679f214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:16:28 +0000 Subject: [PATCH 107/168] Add case 18's man to every generated 2016 example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../tests/test_esa_income_pension_age_properties.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 5c17a3a7c8..2d64b03cba 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -172,6 +172,10 @@ def examples(draw): # appointee saving (art. 3A(2)), which keeps the award payable all year. saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) + # In 2016, add case 18's man, whom Pension Credit pays only with the + # award. + if year == 2016: + units = units + [ROWS[2]] return units, year From d0ffe7fd320c24b5bb070730ca50e2fc8c304a71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:21:06 +0000 Subject: [PATCH 108/168] Give DWP the verb where an appointee saving or a regulation acted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KLYbnfoF71N8hxyDp --- .../tests/legacy_award_work_reference.py | 9 +-- .../esa_income/esa_income_pension_age.yaml | 61 ++++++++++--------- .../test_esa_income_pension_age_properties.py | 9 +-- 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 7c00330b0b..3ad35da3b0 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -231,10 +231,11 @@ def esa_abolition_claim(couple, parameters): The abolition reaches every remaining award on 1 July 2026 in England (SI 2025/1148 art. 3A), the households' default country, so it reaches - any award reported in a year with less than all of it payable. An - appointee saving (art. 3A(2), esa_income_appointee_saving) for a member - who reports the award keeps the whole claim payable, and a saving for a - member who reports nothing keeps nothing. would_claim_uc_at_legacy_closure + any award reported in a year with less than all of it payable. Where a + member who reports the award has an appointee saving + (esa_income_appointee_saving), DWP keeps the whole claim payable (art. + 3A(2)); where only a member who reports nothing has one, DWP keeps + nothing in payment. would_claim_uc_at_legacy_closure keeps its default, true. The conditions are those of the other working-age closures: no Pension Credit report, and not all pension age. """ diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index c5365e5325..b56711549f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -141,9 +141,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -181,9 +181,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -213,9 +213,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -391,9 +391,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -422,17 +422,17 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -464,18 +464,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # An appointee saving (SI 2025/1148 art. 3A(2)) keeps this award in - # payment after the 1 July 2026 abolition, so the model pays it for - # the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award, saved for an appointee, in payment after the + # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays + # it for the whole of 2026-27 (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -651,8 +651,8 @@ # On another reading, para 4(2)(a) holds wherever the award disregarded at # least £20 of earnings, and he would then keep £525.20 of Pension Credit # with no ESA. The model takes "£20 was disregarded" to mean the £20 - # disregard of Sch 7 paras 6 and 7(1), as para 5 disregards "any earnings - # ... which do not exceed the limits", not £20. + # disregard of Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any + # earnings ... which do not exceed the limits", not £20. esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -696,10 +696,11 @@ # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts # as Pension Credit income (SPCA 2002 s.15(1)(b)). # B(e): with the award HMRC pays working tax credit without its income - # test (TCA 2002 s.7(2)), so working tax credit is 3_970, and 10_000 + 3_970 - 1_040 = 12_930, over - # 12_352.60: nil. Savings credit counts no working tax credit (SPC Regs - # 2002 reg 9(a)): 10_000 - 1_040 = 8_960, 172.31 a week, under the - # £212.97 couple threshold: nil. + # test (TCA 2002 s.7(2)), so working tax credit comes to 3_970, and + # 10_000 + 3_970 - 1_040 = 12_930, over 12_352.60: nil. DWP counts no + # working tax credit in the savings credit (SPC Regs 2002 reg 9(a)): + # 10_000 - 1_040 = 8_960, 172.31 a week, under the £212.97 couple + # threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 2d64b03cba..9becee7bb7 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -41,9 +41,9 @@ (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. The Secretary of State appointed 1 July 2026 for the abolition of income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), -and an appointee saving keeps an award in payment (art. 3A(2)), so every -adult drawn in 2026 has the saving (esa_income_appointee_saving) and each -award stays payable for the whole year; +and DWP keeps an award saved for an appointee in payment (art. 3A(2)), so +every adult drawn in 2026 has the saving (esa_income_appointee_saving) and +each award stays payable for the whole year; test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report @@ -169,7 +169,8 @@ def examples(draw): tax_credits = year < 2025 # The abolition of income-related ESA reaches unsaved awards from 1 July # 2026 (SI 2025/1148 art. 3A(1)), so every adult drawn in 2026 has the - # appointee saving (art. 3A(2)), which keeps the award payable all year. + # appointee saving, under which DWP keeps the award payable all year + # (art. 3A(2)). saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) # In 2016, add case 18's man, whom Pension Credit pays only with the From e35595651db0198bb91932900ec8a9acf19b9716 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:24:07 +0000 Subject: [PATCH 109/168] Bar Child Tax Credit qualifying young persons who receive Income Support CTC Regs 2002 reg. 5(4)(c) bars a period in which the young person receives Income Support, and reg. 4(2) keeps that second condition for the 16th-birthday period. is_qualifying_young_person_for_child_tax_credit now reads is_on_income_support on every route. The H1 merge brought main's eligibility gate on CTC_child_element (is_CTC_eligible), so the existing cases that read the element now give the claimant a reported award and the family no Universal Credit claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- .../ctc-qyp-school-leaver-routes.fixed.md | 2 +- ...child_tax_credit_school_leaver_routes.yaml | 148 ++++++++++++++++++ ..._credit_school_leaver_routes_properties.py | 88 ++++++++++- ...fying_young_person_for_child_tax_credit.py | 21 ++- 4 files changed, 247 insertions(+), 12 deletions(-) diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md index 33a0ea5ad5..091fbd5db6 100644 --- a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -- Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)). +Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)), and Income Support received by the young person ends every route (reg. 5(4)(c)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index 0db7aece61..1ad7341981 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -8,6 +8,10 @@ # # The child element was £3,455 in 2024-25, the last year of tax credits # (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). +# +# The child element needs an existing claim that a Universal Credit claim has +# not ended (is_CTC_eligible): each case that reads it gives the claimant a +# reported award and the family no Universal Credit claim. - name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). period: 2024 @@ -15,6 +19,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -25,6 +30,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] @@ -37,6 +43,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -48,6 +55,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_in_remunerative_work_for_child_tax_credit: [false, true] is_qualifying_young_person_for_child_tax_credit: [false, false] @@ -61,6 +69,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -72,6 +81,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -82,6 +92,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -93,6 +104,7 @@ benunits: benunit: members: [parent, pupil] + would_claim_uc: false output: # Reg. 5(4)(a) applies to a person "having ceased" education or training. is_qualifying_young_person_for_child_tax_credit: [false, true] @@ -104,6 +116,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -116,6 +129,7 @@ benunits: benunit: members: [parent, apprentice] + would_claim_uc: false output: # Education "by virtue of his employment" is left out of the first # condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts. The apprentice has @@ -130,6 +144,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -141,6 +156,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -151,6 +167,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -162,6 +179,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -172,6 +190,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -183,6 +202,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -193,6 +213,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -204,6 +225,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -214,6 +236,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -224,6 +247,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -234,6 +258,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -245,6 +270,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -255,6 +281,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -267,6 +294,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -277,6 +305,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -288,6 +317,7 @@ benunits: benunit: members: [parent, school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -298,12 +328,14 @@ input: people: school_leaver: + child_tax_credit_reported: 1 age: 16 current_education: NOT_IN_EDUCATION is_claimant_or_partner: true benunits: benunit: members: [school_leaver] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: true is_child_or_qualifying_young_person_for_child_tax_credit: false @@ -315,6 +347,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -327,6 +360,7 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: # Advanced education fails the first condition, which reg. 4(2) lifts. # It is still full-time education (reg. 5(5)), so the student has not @@ -340,6 +374,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -351,6 +386,7 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -361,6 +397,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -373,7 +410,118 @@ benunits: benunit: members: [parent, apprentice] + would_claim_uc: false output: # Ceased full-time education and in remunerative work (reg. 5(4)(a)). is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 + +# Cases 18 to 20: the Income Support bar in reg. 5(4)(c), which reg. 4(2) +# keeps for the 16th-birthday period. 2018-19 rates: child element £2,780 and +# family element £545 a year (child_element.yaml, family_element.yaml); PIP +# daily living standard rate £57.30 a week; Carer's Allowance £64.60 a week; +# Child Benefit eldest rate £20.70 a week; colour TV licence £150.50. The +# disability cost-of-living payment parameter starts at £150 on 1 January +# 2022, and the model applies that first value to earlier years, so the +# household receives £150 for the parent's PIP. + +- name: Case 18, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4(2), reg. 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. + is_before_first_september_following_16th_birthday: [false, true] + # Not the claimant or partner, so their own report while Income Support + # is in payment. + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + # No child or qualifying young person, so no CTC. + child_tax_credit: 0 + # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; + # Child Benefit 20.70 x 52 = 1,076.40; cost-of-living payment 150; less + # TV licence 150.50: 7,414.70. + household_net_income: 7_414.70 + +- name: Case 19, the same family without Income Support for the 16-year-old gets CTC until 31 August 2018 (reg. 4(1)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_on_income_support: [false, false] + is_qualifying_young_person_for_child_tax_credit: [false, true] + # No income: child element 2,780 + family element 545 = 3,325. + child_tax_credit: 3_325 + # Case 18's 7,414.70 + 3,325 = 10,739.70. + household_net_income: 10_739.70 + +- name: Case 20, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 5(3)(a), 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1 + student: + age: 17 + date_of_birth: 20010115 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + # The first condition holds: full-time non-advanced education begun + # before 19. + meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit: [false, true] + is_before_first_september_following_16th_birthday: [false, false] + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + child_tax_credit: 0 diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 0381c3728a..1f24a96512 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -37,6 +37,11 @@ 3. Switching a leaver fact on never removes the status. 4. The benefit unit's child or qualifying young person is a child or qualifying young person who is not its claimant or partner. +5. Reg. 5(4)(c), which reg. 4(2) keeps for the reg. 4 period: a member who + is not the claimant or partner and reports Income Support of their own + while it is in payment is never a qualifying young person, on any route; + once Income Support has stopped paying (from April 2026) or a reform + neutralises it, the report does not change the status. """ from datetime import date, timedelta @@ -79,6 +84,7 @@ def person(age, **inputs): "weekly_hours": 0, EXTENSION: False, "is_claimant_or_partner": False, + "income_support_reported": 0, **inputs, } @@ -169,6 +175,66 @@ def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): assert_regulation_4_matches_calendar(births, year) +INCOME_SUPPORT_IN_PAYMENT = {2018: True, 2024: True, 2026: False} + + +def qualifying_young_person_given_income_support(p, year): + # Reg. 5(4)(c): a member who is not the claimant or partner receives + # Income Support on their own report while Income Support is paid. + own_income_support = ( + INCOME_SUPPORT_IN_PAYMENT[year] + and not p["is_claimant_or_partner"] + and p["income_support_reported"] > 0 + ) + return qualifying_young_person(p) and not own_income_support + + +def test_income_support_ends_every_route(): + # Invariant 5 on every grid row that qualifies without Income Support. + qualifying = [p for p in GRID if qualifying_young_person(p)] + reporting = [dict(p, income_support_reported=3_000) for p in qualifying] + routes = { + "education": [first_condition_through_education(p) for p in qualifying], + "regulation 4": [ + p[ROUTE] and not first_condition_through_education(p) for p in qualifying + ], + "20 weeks": [ + p[EXTENSION] and not p[ROUTE] and not first_condition_through_education(p) + for p in qualifying + ], + } + assert all(any(rows) for rows in routes.values()) + for year in INCOME_SUPPORT_IN_PAYMENT: + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] + for p in reporting + ] + status = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal( + status, + [qualifying_young_person_given_income_support(p, year) for p in reporting], + ) + if INCOME_SUPPORT_IN_PAYMENT[year]: + assert not status.any(), year + else: + assert status.all(), year + + +def test_income_support_does_not_end_the_status_once_neutralised(): + # Invariant 5: a reform that removes Income Support leaves no award. + rows = [ + person(16, income_support_reported=3_000, **{ROUTE: True}), + person(17, current_education="UPPER_SECONDARY", income_support_reported=3_000), + person(17, income_support_reported=3_000, **{EXTENSION: True}), + ] + units = [[person(45, is_claimant_or_partner=True), p] for p in rows] + assert not simulate(units, 2018).calculate(QYP, 2018)[1::2].any() + removed = simulate(units, 2018) + removed.tax_benefit_system.neutralize_variable("income_support") + removed.delete_arrays("income_support") + assert removed.calculate(QYP, 2018)[1::2].all() + + def receiving_education_or_training(p): # Education status stands in for full-time non-advanced education that is # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). @@ -310,6 +376,7 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): receives_benefits_in_own_right=st.booleans(), is_looked_after_by_local_authority=st.booleans(), weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + income_support_reported=st.sampled_from([0, 0, 3_000]), **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, ) @@ -318,23 +385,34 @@ def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): def benefit_units(draw): claimants = draw(st.lists(member, min_size=1, max_size=2)) others = draw(st.lists(member, max_size=4)) - return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + # Claimants report no Income Support, so the family claims none and the + # model calculates no award; another member's report is their own claim. + return [ + dict(p, is_claimant_or_partner=True, income_support_reported=0) + for p in claimants + ] + others @SETTINGS -@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2020, 2024])) +@given( + st.lists(benefit_units(), min_size=1, max_size=6), + st.sampled_from(sorted(INCOME_SUPPORT_IN_PAYMENT)), +) def test_family_members_in_any_benefit_unit(units, year): - # Invariants 2 and 4 on benefit units of any shape, where a claimant or + # Invariants 2, 4 and 5 on benefit units of any shape, where a claimant or # partner can be a qualifying young person by status. sim = simulate(units, year) people = [p for unit in units for p in unit] status = sim.calculate(QYP, year) - np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in people]) + expected_status = [ + qualifying_young_person_given_income_support(p, year) for p in people + ] + np.testing.assert_array_equal(status, expected_status) family = sim.calculate( "is_child_or_qualifying_young_person_for_child_tax_credit", year ) expected = [ - (p["age"] < 16 or qualifying_young_person(p)) + (p["age"] < 16 or qualifying_young_person_given_income_support(p, year)) and not p["is_claimant_or_partner"] and not p["is_looked_after_by_local_authority"] for p in people diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index e23714dd8b..c54648254a 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -26,11 +26,18 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "no one may be receiving a benefit in their own right. Full-time " "education at any level counts (reg. 5(5)), so an advanced student " "has not ceased it, nor has an apprentice still on a course; the " - "employment exclusion is part of the first condition only. The " - "own-right benefits " - "input covers Universal Credit, Employment and Support Allowance and " - "Jobseeker's Allowance, where reg. 5(4)(c) lists Universal Credit, " - "Income Support and the income-related and income-based allowances. " + "employment exclusion is part of the first condition only. Reg. " + "5(4)(c) bars every route for a person who receives Income Support, " + "and the formula reads is_on_income_support for it: the award the " + "model calculates for a claimant or partner, and their own reported " + "award for any other member while Income Support is in payment and " + "no reform neutralises it. A reform that replaces Income Support's " + "formula with zero ends a claimant's or partner's receipt; for " + "another member it must neutralise income_support or set their " + "reported award to zero. The own-right benefits input covers " + "Universal Credit, Employment and Support Allowance and Jobseeker's " + "Allowance of either kind, where reg. 5(4)(c) lists only Universal " + "Credit and the income-related and income-based allowances. " "Interruptions (reg. 5(7)) are not modelled." ) definition_period = YEAR @@ -84,6 +91,8 @@ def formula(person, period, parameters): period, ) & (in_education_or_training | in_regulation_4_period | in_20_weeks) - # Reg. 5(4)(c). + # Reg. 5(4)(c): Universal Credit, ESA and JSA in their own right, + # and Income Support, which reg. 4(2) keeps for the reg. 4 period. & ~person("receives_benefits_in_own_right", period) + & ~person("is_on_income_support", period) ) From e5f239b3a72cadde6c384ef3bdc39a80e18b6c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:33:40 +0000 Subject: [PATCH 110/168] Fix the CTC Income Support properties' inputs and start the any-year test at 2015 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- ...x_credit_school_leaver_routes_properties.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py index 1f24a96512..5796d5c8e0 100644 --- a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -167,7 +167,10 @@ def test_regulation_4_for_every_date_of_birth(): @SETTINGS @given( st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), - st.integers(2013, 2031), + # From 2015: the status reads is_on_income_support, and Income Support + # reads Pension Credit's mixed-age couple date, which has no value + # before 2015 (child_tax_credit fails there already). + st.integers(2015, 2031), ) def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): births = [born for born in births if age_on_6_october(born, year) >= 0] @@ -224,10 +227,17 @@ def test_income_support_does_not_end_the_status_once_neutralised(): # Invariant 5: a reform that removes Income Support leaves no award. rows = [ person(16, income_support_reported=3_000, **{ROUTE: True}), - person(17, current_education="UPPER_SECONDARY", income_support_reported=3_000), - person(17, income_support_reported=3_000, **{EXTENSION: True}), + person( + 17, + current_education="UPPER_SECONDARY", + income_support_reported=3_000, + **{ROUTE: False}, + ), + person(17, income_support_reported=3_000, **{ROUTE: False, EXTENSION: True}), + ] + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in rows ] - units = [[person(45, is_claimant_or_partner=True), p] for p in rows] assert not simulate(units, 2018).calculate(QYP, 2018)[1::2].any() removed = simulate(units, 2018) removed.tax_benefit_system.neutralize_variable("income_support") From a447fe760351e09929bb0010ab0acc28ede23653 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:50:13 +0000 Subject: [PATCH 111/168] Read own reported awards in the Child Benefit qualifying young person test #2279's test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged failed at H1: a 16-year-old outside education who reported their own income-related ESA or income-based JSA became a Child Benefit qualifying young person through reg. 4, because the test read only the own-right flag. Child Benefit (General) Regulations 2006 reg. 8(2)(b), (e) and (f) list income-based JSA, ESA and Universal Credit, so the test now also reads the person's own reports of them, as the Universal Credit and Pension Credit tests do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- .../cb-qyp-school-leaver-routes.fixed.md | 2 +- .../child_benefit_school_leaver_routes.yaml | 48 +++++++++++++++++++ ...alifying_young_person_for_child_benefit.py | 22 +++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md index ae0e08f434..e1566ac616 100644 --- a/changelog.d/cb-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -- Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). +Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance is not one (reg. 8). diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index 75a9d25169..385a88be11 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -392,3 +392,51 @@ is_qualifying_young_person_for_child_benefit: true is_child_or_qualifying_young_person_for_child_benefit: false child_benefit_entitlement: 0 + +- name: Case 17, a 16-year-old who reports their own income-related ESA is not a qualifying young person (reg. 8(2)(e)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false + +- name: Case 18, a 16-year-old who reports their own income-based JSA is not a qualifying young person (reg. 8(2)(b)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 03a6cdd3ce..5d5b157ff4 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -19,10 +19,13 @@ class is_qualifying_young_person_for_child_benefit(Variable): "August (1 September for a 16th birthday on 31 August) falls in the " "same fiscal year as Universal Credit's 1 September, so a year that " "starts before one starts before the other. Interruptions (reg. 6) " - "are not modelled. The own-right benefits input covers Universal " - "Credit, Employment and Support Allowance and Jobseeker's Allowance " - "but not the Income Support, incapacity benefit and tax credits that " - "reg. 8 also lists." + "are not modelled. Reg. 8 reads the own-right benefits input, which " + "covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance, and the person's own reported Universal " + "Credit, Employment and Support Allowance of either kind and " + "income-based Jobseeker's Allowance, the awards reg. 8(2)(b), (e) " + "and (f) list. It does not read the Income Support, incapacity " + "benefit and tax credits that reg. 8 also lists." ) definition_period = YEAR reference = [ @@ -56,6 +59,15 @@ def formula(person, period, parameters): ) # Reg. 7: until the terminal date after leaving. before_terminal_date = person("is_before_child_benefit_terminal_date", period) + # Reg. 8(2)(b), (e) and (f): income-based JSA, ESA and Universal + # Credit. Reports are inputs, so reading them makes no cycle. + receives_own_benefits = ( + person("receives_benefits_in_own_right", period) + | (person("universal_credit_reported", period) > 0) + | (person("esa_income_reported", period) > 0) + | (person("esa_contrib_reported", period) > 0) + | (person("jsa_income_reported", period) > 0) + ) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", @@ -67,5 +79,5 @@ def formula(person, period, parameters): | in_extension_period | before_terminal_date ) - & ~person("receives_benefits_in_own_right", period) + & ~receives_own_benefits ) From 4e1e0176ac013b1dfb41bcbc858ceb073b1057b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:00:41 +0000 Subject: [PATCH 112/168] Read calculated Income Support for Pension Credit and CTC qualifying young persons receives_income_support_in_own_right treated every positive report as receipt, so a reform replacing income_support's formula with zero left a claimant's report standing: in 2019 an 18-year-old student-carer on their own Income Support stayed excluded under SPC Regs 2002 reg. 4A(5), and a co-resident pensioner lost the 3,424.20 severe disability addition. Pension Credit and Child Tax Credit now read is_on_income_support alone (the award the model calculates for a claimant or partner, and another member's own report while Income Support is in payment and not neutralised), which H2 and #2279 already read. Child Benefit keeps the report: reading the calculated award makes a cycle through income_support_applicable_amount and the legacy young person. receives_income_support_in_own_right documents that and what a reform must do for Child Benefit to follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- .../qyp-income-support-exclusion.fixed.md | 2 +- ...child_tax_credit_school_leaver_routes.yaml | 11 +- ...ualifying_young_person_income_support.yaml | 75 +++- ..._young_person_income_support_properties.py | 351 ++++++++++++++---- ...fying_young_person_for_child_tax_credit.py | 1 - ...lifying_young_person_for_pension_credit.py | 5 +- ...alifying_young_person_for_child_benefit.py | 3 + .../receives_income_support_in_own_right.py | 38 +- 8 files changed, 393 insertions(+), 93 deletions(-) diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md index 35769651aa..cbc3f67373 100644 --- a/changelog.d/qyp-income-support-exclusion.fixed.md +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -1 +1 @@ -- Stopped counting a young person who receives Income Support in their own right as a qualifying young person for Pension Credit, Child Benefit (and so Housing Benefit and the legacy benefits) and Child Tax Credit (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c)), read from their own reported Income Support while it was in payment. +Stopped counting a young person who receives Income Support in their own right as a qualifying young person for Pension Credit, Child Benefit (and so Housing Benefit and the legacy benefits) and Child Tax Credit (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c)). Pension Credit and Child Tax Credit read the Income Support the model calculates for a claimant or partner, so a reform that pays no Income Support ends their exclusion; Child Benefit reads the young person's own report while Income Support is in payment. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index 1ad7341981..2fbce2e8c9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -459,10 +459,12 @@ is_qualifying_young_person_for_child_tax_credit: [false, false] # No child or qualifying young person, so no CTC. child_tax_credit: 0 + # Child Benefit reg. 8(2)(a) ends their Child Benefit status too. + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; - # Child Benefit 20.70 x 52 = 1,076.40; cost-of-living payment 150; less - # TV licence 150.50: 7,414.70. - household_net_income: 7_414.70 + # cost-of-living payment 150; less TV licence 150.50: 6,338.30. + household_net_income: 6_338.30 - name: Case 19, the same family without Income Support for the 16-year-old gets CTC until 31 August 2018 (reg. 4(1)). period: 2018 @@ -494,7 +496,8 @@ is_qualifying_young_person_for_child_tax_credit: [false, true] # No income: child element 2,780 + family element 545 = 3,325. child_tax_credit: 3_325 - # Case 18's 7,414.70 + 3,325 = 10,739.70. + # Case 18's 6,338.30 + Child Benefit 20.70 x 52 = 1,076.40 + 3,325 = + # 10,739.70. household_net_income: 10_739.70 - name: Case 20, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 5(3)(a), 5(4)(c)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml index 3d25203df1..43b55a36b7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml @@ -5,10 +5,19 @@ # names Universal Credit, Employment and Support Allowance and Jobseeker's # Allowance only. # +# Pension Credit and Child Tax Credit read is_on_income_support: the award the +# model calculates for a claimant or partner, and the person's own report for +# any other member. Child Benefit reads receives_income_support_in_own_right, +# the person's own report, because the family's Income Support reads the +# Child Benefit test. +# # Income Support stopped paying on 31 March 2026 # (gov/dwp/income_support/active.yaml), so reported receipt counts to 2025-26. # 2025-26 amounts: Child Benefit eldest child £26.05 a week; Pension Credit -# first-child addition £78.10 a week. CTC child element £3,455 in 2024-25. +# first-child addition £78.10 a week. CTC child element £3,455 in 2024-25; the +# element needs an existing claim that a Universal Credit claim has not ended +# (is_CTC_eligible), so the CTC cases give the parent a reported award and the +# family no Universal Credit claim. - name: Case 1, a 17-year-old at school with no Income Support is every programme's qualifying young person. period: 2025 @@ -66,6 +75,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -77,7 +87,9 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: + is_on_income_support: [false, true] is_qualifying_young_person_for_child_tax_credit: [false, false] CTC_child_element: 0 @@ -87,6 +99,7 @@ input: people: parent: + child_tax_credit_reported: 1 age: 40 is_parent: true is_claimant_or_partner: true @@ -97,6 +110,7 @@ benunits: benunit: members: [parent, student] + would_claim_uc: false output: is_qualifying_young_person_for_child_tax_credit: [false, true] CTC_child_element: 3_455 @@ -143,7 +157,7 @@ is_qualifying_young_person_for_pension_credit: [false, true] is_qualifying_young_person_for_child_benefit: [false, true] -- name: Case 7, the input can be set directly. +- name: Case 7, the Child Benefit input can be set directly; it does not decide Pension Credit or Child Tax Credit. period: 2025 absolute_error_margin: 0.01 input: @@ -160,6 +174,61 @@ benunit: members: [grandparent, student] output: - is_qualifying_young_person_for_pension_credit: [false, false] + # No Income Support reported or calculated. + is_on_income_support: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, true] is_qualifying_young_person_for_child_benefit: [false, false] is_qualifying_young_person_for_universal_credit: [false, true] + +# Case 8, 2019-20 amounts: standard minimum guarantee £167.25 a week; severe +# disability addition £65.85 a week; PIP daily living standard rate £58.70 a +# week; Income Support personal allowance for a single person aged 18 to 24 +# £57.90 a week; winter fuel payment £200; colour TV licence £154.50. The +# cost-of-living payment parameters start on 1 January 2022 (means-tested +# £650, pensioner £300, disability £150), and the model applies those first +# values to earlier years, so the household receives £1,100. + +- name: Case 8, a student-carer on Income Support of their own is not the pensioner's qualifying young person, so the pensioner gets no severe disability addition. + period: 2019 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + pip_dl_category: STANDARD + state_pension: 5_000 + student: + # An orphan in full-time non-advanced education with an existing + # Income Support claim (IS Regs 1987 reg. 13(2)(c)), caring for the + # pensioner 35 hours a week without Carer's Allowance. + age: 18 + current_education: UPPER_SECONDARY + care_hours: 35 + would_claim_carers_allowance: false + income_support_reported: 3_000 + benunits: + pensioner_unit: + members: [pensioner] + would_claim_uc: false + student_unit: + members: [student] + would_claim_uc: false + households: + household: + members: [pensioner, student] + output: + # The student claims Income Support for their own benefit unit: no + # income, so the award is the personal allowance, 57.90 x 52 = 3,010.80. + income_support: [0, 3_010.80] + is_on_income_support: [false, true] + # Reg. 4A(5): not a qualifying young person, so not disregarded under + # Schedule I para. 2(2)(f), and the pensioner does not live alone. + is_qualifying_young_person_for_pension_credit: [false, false] + severe_disability_minimum_guarantee_addition: [0, 0] + # 167.25 x 52 = 8,697, less the State Pension 5,000. + pension_credit: [3_697, 0] + # State Pension 5,000 + PIP 58.70 x 52 = 3,052.40 + Pension Credit 3,697 + # + Income Support 3,010.80 + cost-of-living payments 1,100 + winter fuel + # payment 200, less TV licence 154.50: 15,905.70. + household_net_income: 15_905.70 diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py index fef49a5512..21f07c2473 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -7,20 +7,37 @@ reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) does not include Income Support. +Pension Credit and Child Tax Credit read is_on_income_support: the award the +model calculates for a claimant or partner, and the person's own report for +any other member while Income Support is in payment and not neutralised. +Child Benefit reads receives_income_support_in_own_right, the person's own +report on the same terms, because the family's Income Support reads the +Child Benefit test and the calculated award would make a cycle. + Invariants: 1. Over every combination of age, education, training, the other own-right - benefits and the 16th-birthday fact: with Income Support in their own - right, nobody is a Pension Credit, Child Benefit or Child Tax Credit - qualifying young person. -2. Without it, each of those three statuses is exactly what it is when the - input is left alone, so nothing else changes. -3. Universal Credit status never depends on it. -4. Without it, Pension Credit and Universal Credit agree on every combination; - with it, Pension Credit is the narrower. -5. By default the input is the person's own reported Income Support while - Income Support is in payment (to 2025-26) and no reform removes it, and - false otherwise. + benefits and the 16th-birthday fact, for a member who is not the claimant + or partner: reporting Income Support of their own while it is in payment + ends the Pension Credit, Child Benefit and Child Tax Credit statuses; + without a report each status is what it is when the report is left + alone; Universal Credit status never depends on it; and Pension Credit + and Universal Credit agree without it. +2. By default receives_income_support_in_own_right is the person's own + reported Income Support while Income Support is in payment (to 2025-26) + and no reform neutralises it, and false otherwise. +3. Under a reform that neutralises income_support, no Pension Credit, Child + Tax Credit or Child Benefit status depends on reported Income Support. + Under a reform that replaces income_support's formula with one returning + zero, no Pension Credit or Child Tax Credit status of a claimant or + partner depends on it; another member's report and the Child Benefit + reader still exclude, as their documentation says, because only + neutralising Income Support, or setting the report to zero, removes a + report. +4. Universal Credit status never depends on reported Income Support, in the + baseline or under either reform. +5. Setting a young person's reported Income Support to zero never lowers + their household's Pension Credit. """ from itertools import product @@ -31,6 +48,8 @@ from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.model_api import YEAR as YEAR_PERIOD +from policyengine_uk.model_api import BenUnit, Variable from policyengine_uk.tax_benefit_system import system from policyengine_uk.variables.household.demographic.highest_education import ( EducationType, @@ -42,21 +61,53 @@ SIXTEEN = "is_before_first_september_following_16th_birthday" INCOME_SUPPORT = "receives_income_support_in_own_right" EXCLUDING = ("pension_credit", "child_benefit", "child_tax_credit") +# Income Support paid its last awards on 31 March 2026. +IN_PAYMENT = {2016: True, 2019: True, 2024: True, 2025: True, 2026: False} SETTINGS = settings( - max_examples=25, + max_examples=10, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -def simulate(people, year=YEAR): - """Each person alone in a benefit unit: the status tests are personal.""" +class income_support(Variable): + """A reform's Income Support formula that pays nothing.""" + + value_type = float + entity = BenUnit + label = "Income Support" + definition_period = YEAR_PERIOD + + def formula(benunit, period, parameters): + return benunit.empty_array() + + +def apply(sim, reform): + if reform == "zero formula": + sim.tax_benefit_system.update_variable(income_support) + elif reform == "neutralised": + sim.tax_benefit_system.neutralize_variable("income_support") + return sim + + +def simulate(households, year=YEAR, reform=None): + """households: a list of households, each a list of benefit units, each a + list of people's inputs. Every person gives the same input names.""" situation = {"people": {}, "benunits": {}, "households": {}} - for i in range(len(people)): - situation["people"][f"p{i}"] = {} - situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} - situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + people = [] + for h, units in enumerate(households): + household_members = [] + for b, unit in enumerate(units): + names = [] + for p, inputs in enumerate(unit): + name = f"p{h}_{b}_{p}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{h}_{b}"] = {"members": names} + household_members += names + situation["households"][f"h{h}"] = {"members": household_members} with patch( "policyengine_uk.simulation.CountryTaxBenefitSystem", side_effect=_PROPERTY_SYSTEM.clone, @@ -66,50 +117,79 @@ def simulate(people, year=YEAR): assert all(p.keys() == variables for p in people) for variable in variables: sim.set_input(variable, year, [p[variable] for p in people]) - sim.input_variables = list(variables) + # No family claims Universal Credit, so legacy awards, Child Tax Credit + # included, continue. + benunits = len(situation["benunits"]) + sim.set_input("would_claim_uc", year, np.zeros(benunits, dtype=bool)) + sim.input_variables = list(variables) + ["would_claim_uc"] sim.reset_calculations() - return sim + return apply(sim, reform) def status(sim, programme, year=YEAR): return sim.calculate(f"is_qualifying_young_person_for_{programme}", year) -BASE = [ - { +def member(age, **inputs): + return { "age": age, - "current_education": education, - "is_in_approved_training": training, - "receives_benefits_in_own_right": own_right, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "receives_benefits_in_own_right": False, "age_started_or_accepted_current_education_or_training": 17, - SIXTEEN: sixteen, + "is_claimant_or_partner": False, + "income_support_reported": 0, + "state_pension": 0, + "pip_dl_category": "NONE", + "care_hours": 0, + "would_claim_carers_allowance": False, + "child_tax_credit_reported": 0, + **inputs, } - for age, education, training, own_right, sixteen in product( - range(14, 22), EDUCATIONS, (False, True), (False, True), (False, True) + + +# --- Invariants 1 and 2 --- + +BASE = [ + dict( + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + **{SIXTEEN: sixteen}, + ) + for education, training, own_right, sixteen in product( + EDUCATIONS, (False, True), (False, True), (False, True) ) ] +GRID = [dict(row, age=age) for age, row in product(range(14, 22), BASE)] + + +def as_members(rows, **inputs): + # Each young person sits in the benefit unit of a 70-year-old claimant, + # so they are not the claimant or partner. + claimant = member(70, is_claimant_or_partner=True, **{SIXTEEN: False}) + return [ + [[claimant, member(**{SIXTEEN: False, **row, **inputs})]] + for row in rows + ] def test_income_support_ends_three_statuses_and_not_universal_credit(): - untouched = simulate(BASE) - without = simulate([dict(p, **{INCOME_SUPPORT: False}) for p in BASE]) - with_it = simulate([dict(p, **{INCOME_SUPPORT: True}) for p in BASE]) + untouched = simulate(as_members(GRID)) + reporting = simulate(as_members(GRID, income_support_reported=3_000)) for programme in EXCLUDING: - before = status(untouched, programme) + before = status(untouched, programme)[1::2] # Not vacuous: there is a status to lose. assert before.any(), programme - # Invariant 1. - assert not status(with_it, programme).any(), programme - # Invariant 2. - np.testing.assert_array_equal(status(without, programme), before) - # Invariant 3. - universal_credit = status(untouched, "universal_credit") - np.testing.assert_array_equal(status(with_it, "universal_credit"), universal_credit) - np.testing.assert_array_equal(status(without, "universal_credit"), universal_credit) - # Invariant 4. - np.testing.assert_array_equal(status(without, "pension_credit"), universal_credit) - assert (status(with_it, "pension_credit") <= universal_credit).all() + assert not status(reporting, programme)[1::2].any(), programme + universal_credit = status(untouched, "universal_credit")[1::2] assert universal_credit.any() + np.testing.assert_array_equal( + status(reporting, "universal_credit")[1::2], universal_credit + ) + np.testing.assert_array_equal( + status(untouched, "pension_credit")[1::2], universal_credit + ) @SETTINGS @@ -122,28 +202,171 @@ def test_income_support_ends_three_statuses_and_not_universal_credit(): st.sampled_from([2021, 2024, 2025, 2026, 2028]), ) def test_default_follows_reported_income_support_while_it_is_paid(rows, year): - # Invariant 5. Income Support paid its last awards on 31 March 2026. - people = [{"age": age, "income_support_reported": amount} for age, amount in rows] - sim = simulate(people, year) + # Invariant 2. + households = [ + [[member(age, income_support_reported=amount)]] for age, amount in rows + ] + sim = simulate(households, year) expected = [year <= 2025 and amount > 0 for _, amount in rows] np.testing.assert_array_equal(sim.calculate(INCOME_SUPPORT, year), expected) + neutralised = simulate(households, year, reform="neutralised") + assert not neutralised.calculate(INCOME_SUPPORT, year).any() -def test_no_receipt_once_a_reform_removes_income_support(): - # Invariant 5 under a reform: a 17-year-old at school reporting £3,000 of - # Income Support of their own is excluded while Income Support exists, - # and is a qualifying young person again once a reform neutralizes it. - student = { - "age": 17, - "current_education": "UPPER_SECONDARY", - "income_support_reported": 3_000, - } - kept = simulate([student]) - assert kept.calculate(INCOME_SUPPORT, YEAR).all() - removed = simulate([student]) - removed.tax_benefit_system.neutralize_variable("income_support") - removed.delete_arrays("income_support") - assert not removed.calculate(INCOME_SUPPORT, YEAR).any() - for programme in EXCLUDING: - assert not status(kept, programme).any(), programme - assert status(removed, programme).all(), programme +# --- Invariants 3 to 5, on generated households --- + + +@st.composite +def household(draw): + young = member( + draw(st.integers(15, 20)), + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + income_support_reported=draw(st.sampled_from([0, 3_000])), + care_hours=draw(st.sampled_from([0, 35])), + ) + shape = draw(st.sampled_from(["pensioner's unit", "own unit", "parent's unit"])) + if shape == "parent's unit": + # A Child Tax Credit claimant and their young person. + parent = member( + draw(st.integers(35, 55)), + is_claimant_or_partner=True, + child_tax_credit_reported=1, + ) + return [[parent, young]] + pensioner = member( + draw(st.integers(66, 90)), + is_claimant_or_partner=True, + state_pension=draw(st.sampled_from([0, 5_000, 9_000])), + pip_dl_category=draw(st.sampled_from(["NONE", "STANDARD", "ENHANCED"])), + ) + if shape == "own unit": + # The young person claims for their own benefit unit, so the model + # calculates their Income Support. + return [[pensioner], [dict(young, is_claimant_or_partner=True)]] + return [[pensioner, young]] + + +def without_reports(households): + return [ + [[dict(p, income_support_reported=0) for p in unit] for unit in units] + for units in households + ] + + +def flat(households, key): + return np.array([p[key] for units in households for unit in units for p in unit]) + + +GENERATED = dict( + households=st.lists(household(), min_size=1, max_size=8), + year=st.sampled_from([2016, 2019, 2024]), +) + + +@SETTINGS +@given(**GENERATED, reform=st.sampled_from(["zero formula", "neutralised"])) +def test_reforms_that_remove_income_support_end_the_exclusions( + households, year, reform +): + # Invariants 3 and 4. + reformed = simulate(households, year, reform) + unreported = simulate(without_reports(households), year, reform) + reported = flat(households, "income_support_reported") > 0 + claimant = flat(households, "is_claimant_or_partner") + # A report counts where it is the reader's evidence: for Child Benefit + # always, and for Pension Credit and Child Tax Credit for anyone who is + # not the claimant or partner; and only until a reform neutralises it. + report_stands = reported & IN_PAYMENT[year] & (reform != "neutralised") + for programme in ("pension_credit", "child_tax_credit"): + np.testing.assert_array_equal( + status(reformed, programme, year), + status(unreported, programme, year) & ~(report_stands & ~claimant), + err_msg=programme, + ) + np.testing.assert_array_equal( + status(reformed, "child_benefit", year), + status(unreported, "child_benefit", year) & ~report_stands, + ) + np.testing.assert_array_equal( + status(reformed, "universal_credit", year), + status(unreported, "universal_credit", year), + ) + assert not reformed.calculate("income_support", year).any() + + +@SETTINGS +@given(**GENERATED) +def test_universal_credit_and_pension_credit_without_reported_income_support( + households, year +): + # Invariants 4 and 5 in the baseline. + reported = simulate(households, year) + unreported = simulate(without_reports(households), year) + np.testing.assert_array_equal( + status(reported, "universal_credit", year), + status(unreported, "universal_credit", year), + ) + gained = unreported.calculate("pension_credit", year, map_to="household") + kept = reported.calculate("pension_credit", year, map_to="household") + assert (gained >= kept - 0.01).all(), (gained, kept) + + +# --- The review's household: a pensioner and a student-carer --- + + +def pensioner_and_student_carer(): + # 2019: a pensioner aged 70 on PIP daily living with £5,000 of State + # Pension, and an unrelated 18-year-old in their own benefit unit: an + # orphan in full-time non-advanced education with an existing Income + # Support claim (IS Regs 1987 reg. 13(2)(c)), caring for the pensioner 35 + # hours a week without Carer's Allowance. + pensioner = member( + 70, + is_claimant_or_partner=True, + state_pension=5_000, + pip_dl_category="STANDARD", + ) + student = member( + 18, + is_claimant_or_partner=True, + current_education="UPPER_SECONDARY", + income_support_reported=3_000, + care_hours=35, + ) + return [[[pensioner], [student]]] + + +def test_a_reform_paying_no_income_support_restores_the_severe_disability_addition(): + # With the student's Income Support in payment, reg. 4A(5) excludes them, + # the pensioner does not live alone, and Pension Credit is the standard + # minimum guarantee 167.25 x 52 = 8,697 less the State Pension: 3,697. + # A reform that pays no Income Support leaves the student a qualifying + # young person, disregarded under Schedule I para. 2(2)(f), so the + # pensioner gets the severe disability addition 65.85 x 52 = 3,424.20 as + # well: 7,121.20. Household net income: State Pension 5,000 + PIP 58.70 x + # 52 = 3,052.40 + Pension Credit 7,121.20 + the cost-of-living payments + # the model applies before 2022 (650 + 300 + 150) + winter fuel payment + # 200, less TV licence 154.50: 16,319.10. + year = 2019 + for reform in ("zero formula", "neutralised"): + sim = simulate(pensioner_and_student_carer(), year, reform) + np.testing.assert_array_equal(status(sim, "pension_credit", year), [0, 1]) + np.testing.assert_allclose( + sim.calculate("severe_disability_minimum_guarantee_addition", year), + [3_424.20, 0], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("pension_credit", year), [7_121.20, 0], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("household_net_income", year), [16_319.10], atol=0.01 + ) + # Child Benefit reads the report: only neutralising Income Support ends + # its exclusion. + zero = simulate(pensioner_and_student_carer(), year, "zero formula") + assert zero.calculate(INCOME_SUPPORT, year)[1] + assert not status(zero, "child_benefit", year)[1] + neutralised = simulate(pensioner_and_student_carer(), year, "neutralised") + assert status(neutralised, "child_benefit", year)[1] diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 0b6bbf9c7c..c54648254a 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -95,5 +95,4 @@ def formula(person, period, parameters): # and Income Support, which reg. 4(2) keeps for the reg. 4 period. & ~person("receives_benefits_in_own_right", period) & ~person("is_on_income_support", period) - & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 80a567762c..9637a2228d 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -43,7 +43,9 @@ def formula(person, period, parameters): # own awards without calculating family benefits, which depend on QYP # status and could recurse. Unlike UC reg. 5(5), it lists income # support, whose family follows the Child Benefit test and not this - # one, so is_on_income_support can read the calculated award. + # one, so is_on_income_support can read the calculated award: a + # claimant or partner receives Income Support only while the model + # pays their benefit unit an award. receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) @@ -63,5 +65,4 @@ def formula(person, period, parameters): | in_education_or_training ) & ~receives_own_benefits - & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 42004f3327..63ff9225a5 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -81,5 +81,8 @@ def formula(person, period, parameters): | before_terminal_date ) & ~receives_own_benefits + # Reg. 8(2)(a). The reported award, not is_on_income_support: + # the family's Income Support reads this test, so the calculated + # award would make a cycle. & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py index 648ba7221c..5035be6ea0 100644 --- a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -6,26 +6,28 @@ class receives_income_support_in_own_right(Variable): entity = Person label = "Receives Income Support in own right" documentation = ( - "Whether this person receives Income Support on a claim of their own. " - "It ends qualifying young person status for Pension Credit (SPC Regs " - "2002 reg. 4A(5)), Child Benefit (Child Benefit (General) Regulations " - "2006 reg. 8(2)(a)), with it the Housing Benefit and legacy young " - "person, and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)). Universal " - "Credit's list (UC Regs 2013 reg. 5(5)) does not include it. By " - "default it is the person's own reported Income Support " - "(income_support_reported) while Income Support is in payment and no " - "reform removes it (neutralizes income_support), as is_on_income_support " - "reads another member's own claim; set it directly otherwise. It does " - "not read the benefit unit's calculated " - "award, which belongs to the claimant and depends on who the family's " - "young persons are." + "Whether this person receives Income Support on a claim of their own, " + "as the Child Benefit qualifying young person test reads it (Child " + "Benefit (General) Regulations 2006 reg. 8(2)(a)); through that test " + "it also ends the Housing Benefit and legacy young person. It is the " + "person's own reported Income Support (income_support_reported) " + "while Income Support is in payment and no reform neutralises " + "income_support; set it directly otherwise. For a member who is not " + "the claimant or partner, is_on_income_support reads the same report. " + "For a claimant or partner, is_on_income_support reads the award the " + "model calculates for their benefit unit, but that award depends on " + "who the family's Child Benefit young persons are, so this variable " + "cannot read it without a cycle. A reform that removes Income Support " + "must neutralise income_support, or set reported awards to zero, for " + "the Child Benefit status to follow; replacing its formula with zero " + "leaves this variable true. Pension Credit (SPC Regs 2002 reg. 4A(5)) " + "and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)) read " + "is_on_income_support instead, so they follow such a reform for a " + "claimant or partner. Universal Credit's list (UC Regs 2013 reg. " + "5(5)) does not include Income Support." ) definition_period = YEAR - reference = ( - "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", - "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", - "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", - ) + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/8" def formula(person, period, parameters): active = parameters(period).gov.dwp.income_support.active From 6db2b6d47320cc380dc69bb3946e738304674e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:08:34 +0000 Subject: [PATCH 113/168] Format the Income Support properties Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FJSEjTyXcaymMgSqM --- ...test_qualifying_young_person_income_support_properties.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py index 21f07c2473..517afa354e 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -168,10 +168,7 @@ def as_members(rows, **inputs): # Each young person sits in the benefit unit of a 70-year-old claimant, # so they are not the claimant or partner. claimant = member(70, is_claimant_or_partner=True, **{SIXTEEN: False}) - return [ - [[claimant, member(**{SIXTEEN: False, **row, **inputs})]] - for row in rows - ] + return [[[claimant, member(**{SIXTEEN: False, **row, **inputs})]] for row in rows] def test_income_support_ends_three_statuses_and_not_universal_credit(): From ee0482e75e0230e01ae55598df8d24e0089c2566 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 23:23:23 -0500 Subject: [PATCH 114/168] Pin the reg. 4A(5) Income Support exclusion at 19 and tighten three notes - Add Case 15: in 2025-26, a 19-year-old at school before the date who reports their own Income Support is a UC qualifying young person but not a PC one (SPC reg. 4A(5) lists Income Support; UC reg. 5(5) doesn't). - Invariant 3's docstring: the Income Support split applies to a young person who otherwise qualifies, on either route. - The scope note: the tests set UC's terminal-date input false unless a test varies it. - The changelog fragment: the model applies the date to Pension Credit. Co-Authored-By: Claude Opus 5.5 --- changelog.d/qyp-terminal-date-19.fixed.md | 2 +- ...qualifying_young_person_terminal_date.yaml | 29 +++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 6 ++-- ...g_young_person_terminal_date_properties.py | 6 ++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md index 844e2a2657..72d9b148e8 100644 --- a/changelog.d/qyp-terminal-date-19.fixed.md +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -1 +1 @@ -Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). A 19-year-old in education now counts for Universal Credit by default in household calculations, as in the microdata, and Pension Credit applies the date, which it did not model. +Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). The model now counts a 19-year-old in education for Universal Credit by default in household calculations, as the microdata already did, and applies the date to Pension Credit, which had no terminal date. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml index 69535d4b29..4a05b360b7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -347,3 +347,32 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_qualifying_young_person_for_universal_credit: [false, false] child_minimum_guarantee_addition: 0 + +- name: Case 15, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own Income Support. + # SPC reg. 4A(5) lists income support; UC reg. 5(5) does not. The model + # pays Income Support until 2026-04-01 (gov.dwp.income_support.active), so + # the case runs in 2025-26. The student is not a claimant or partner, so + # is_on_income_support reads their own report. Without it, the first-child + # addition would be £78.10 a week (2025-04-01) x 52 = £4,061.20. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + income_support_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_on_income_support: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 630693f7da..d7df438ec9 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -64,9 +64,9 @@ contracts, approved providers and interruptions are unobserved. CB/CTC leaver extensions are omitted. UC/PC's route to the 1 September after the 16th birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). The tests here set UC's terminal-date input -false; PC's terminal date follows the 19th-birthday date fact, which is true -for everyone under 20 when only an age is given +before that date (invariant 5). The tests here set UC's terminal-date input, +false unless a test varies it; PC's terminal date follows the 19th-birthday +date fact, which is true for everyone under 20 when only an age is given (test_qualifying_young_person_terminal_date_properties.py covers both dates). Nobody here reports Income Support, which SPC reg 4A(5) lists and UC reg 5(5) does not. diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py index 66318d090c..f60c2f4583 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -18,9 +18,9 @@ for people not on Income Support: Universal Credit and Pension Credit agree, and both equal the regulation's conditions. Income Support is the one exception: SPC reg. 4A(5) lists it and UC reg. 5(5) does not, so a - young person on their own Income Support keeps the Universal Credit status - and loses the Pension Credit one, on either route and on either side of - the 1 September following the 19th birthday. + young person on their own Income Support who otherwise qualifies keeps + the Universal Credit status and loses the Pension Credit one, on either + route. 4. With the 19th-birthday fact on, both statuses are the earlier definition without a terminal date, so nothing changes below 19 or for a 19-year-old before the date; switching the fact on never removes the status; and it From 6cd70815ab7f2c7340408b96bdd8a34a543e01a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:04:18 +0000 Subject: [PATCH 115/168] Add case 26's couple and a 2018 example to the pension-age ESA properties examples() now adds case 26's couple (COUPLE_ROW) beside case 18's man to every generated 2016 example, so a couple with Pension Credit only with the award reaches the combined property on every run. Both the combined property and the differential also run an explicit 2018 example (ROWS_2018): case 19's working tax credit couple and case 20's couple. The couple is appended after the draws, so every drawn family stays the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014eCgZmDCqK2orSpEYYaPgu --- .../test_esa_income_pension_age_properties.py | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index 9becee7bb7..d7c828356f 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -173,10 +173,10 @@ def examples(draw): # (art. 3A(2)). saved = year >= 2026 units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) - # In 2016, add case 18's man, whom Pension Credit pays only with the - # award. + # In 2016, add case 18's man and case 26's couple, who get Pension Credit + # only with the award. if year == 2016: - units = units + [ROWS[2]] + units = units + [ROWS[2], COUPLE_ROW] return units, year @@ -309,6 +309,73 @@ def couple_reports(sim, year): ), ] +# Case 26's couple: the man of case 18 earning £90 a week in 15 hours with a +# £130 weekly pension, and a woman aged 60 earning £30 a week in 5 hours. +COUPLE_ROW = ( + [ + dict( + MAN, + esa_income_reported=5_491.20, + employment_income=4_680, + hours_worked=780, + private_pension_income=6_760, + ), + { + "age": 60, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 260, + "employment_income": 1_560, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, +) + +# Two couples in 2018, a year with tax credits and before the exclusion of +# mixed-age couples. Case 19's couple (ROWS[3]) gets Pension Credit with or +# without the award that year. In case 20's couple, a man aged 66 has a £200 +# weekly pension and a man aged 58 reports support-group income-related ESA +# and earns £124 a week in 15 hours, and the couple gets no Pension Credit +# with or without the award. +ROWS_2018 = [ + ROWS[3], + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 10_400, + "working_tax_credit_reported": 0, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 2_537.60, + "esa_includes_support_component": True, + "hours_worked": 780, + "employment_income": 6_448, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), +] + @settings( max_examples=4, @@ -318,6 +385,7 @@ def couple_reports(sim, year): ) @given(examples()) @example((ROWS, 2016)) +@example((ROWS_2018, 2018)) def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): units, year = drawn n = len(units) @@ -687,6 +755,7 @@ def formula(benunit, period, parameters): ) @given(examples()) @example((ROWS, 2016)) +@example((ROWS_2018, 2018)) def test_reading_pension_credit_for_every_couple_matches_the_model(drawn): """Differential: a reform that calculates the model's Pension Credit by a formula of its own makes the screen read Pension Credit without the award From 0822532e4a97b23d3b53139073f2f4d52ca78f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:12:28 +0000 Subject: [PATCH 116/168] Say which reading of para 4(2)(a) the expected values take, and give DWP the verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 18's comment and the docstring of pension_credit_without_income_related_esa now say which reading of SPC Regs 2002 Sch VI para 4(2)(a) the expected values or the model's result agree with, since no code applies para 4(2), and give the other reading with para 4(2)(b)'s condition. Para 5's limit makes "not £20" "not a fixed £20". The docstring also names pension_credit's own read of the award. Cases 12, 15, 16, 18, 19, 20, 25 and 26 get names in which DWP, HMRC or the couple acts, and case 26's name puts para 6 the right way round. Case 26's comments compare his earnings with 16 x £7.20 = £115.20, name the model's marriage allowance defaults, and cite WRA 2007 Sch 1 para 6(1)(f), as case 25's does. Case 19 limits reg 9(a) to the savings credit's qualifying income. The appointee-saving comments name the claimant's saving, and esa_abolition_claim's docstring says it ignores a saving held only by a member who reports nothing. Comments in this PR's other test files give DWP the verb where Pension Credit acted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014eCgZmDCqK2orSpEYYaPgu --- .../tests/legacy_award_work_reference.py | 21 +-- .../esa_income/esa_income_pension_age.yaml | 136 ++++++++++-------- .../test_esa_income_pension_age_properties.py | 34 ++--- ...t_income_support_eligibility_properties.py | 5 +- ...t_gate_reads_claimant_or_partner_awards.py | 7 +- .../variables/gov/dwp/esa_income_eligible.py | 48 ++++--- 6 files changed, 137 insertions(+), 114 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 3ad35da3b0..e63ecfafab 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -35,16 +35,16 @@ - ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant nor the other member of the couple may be entitled to it. A caller that knows passes whether the couple is entitled (on_pension_credit): it would - claim, and the Pension Credit it would get with no income-related ESA in - the family pays. Otherwise no member reports Pension Credit or Housing - Benefit, so no mixed-age couple keeps the SI 2019/37 art 4 saving and the + claim, and it would get Pension Credit with no income-related ESA in the + family. Otherwise no member reports Pension Credit or Housing Benefit, so + no mixed-age couple keeps the SI 2019/37 art 4 saving and the exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a couple every member of which has reached the qualifying age can then be entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 s.1(6)), so each of them has reached pensionable age and has no claim - under s.1(3)(c), whether or not Pension Credit pays. A member outside the - couple keeps their own claim even when the couple has an entitlement to - Pension Credit. + under s.1(3)(c), whether or not DWP pays the couple Pension Credit. A + member outside the couple keeps their own claim even when the couple has + an entitlement to Pension Credit. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -234,10 +234,11 @@ def esa_abolition_claim(couple, parameters): any award reported in a year with less than all of it payable. Where a member who reports the award has an appointee saving (esa_income_appointee_saving), DWP keeps the whole claim payable (art. - 3A(2)); where only a member who reports nothing has one, DWP keeps - nothing in payment. would_claim_uc_at_legacy_closure - keeps its default, true. The conditions are those of the other - working-age closures: no Pension Credit report, and not all pension age. + 3A(2)); like esa_income_payable_share, this function ignores a saving + held only by a member who reports nothing. + would_claim_uc_at_legacy_closure keeps its default, true. The conditions + are those of the other working-age closures: no Pension Credit report, + and not all pension age. """ payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain reporters = [m for m in couple if m.get("esa_income_reported", 0) > 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index b56711549f..dadc199e8f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -141,9 +141,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -181,9 +182,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -213,9 +215,10 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -378,7 +381,7 @@ claimant_or_partner_esa_income: 0 is_on_income_related_esa: [false, false] -- name: Case 12, the couple's Pension Credit does not bar a member outside the couple who claims income-related ESA in their own right. +- name: Case 12, a member outside the couple who claims income-related ESA in their own right keeps the award beside the couple's Pension Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -391,9 +394,10 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -422,17 +426,18 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. esa_income_appointee_saving: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -464,18 +469,19 @@ age: 70 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. esa_income_appointee_saving: true esa_includes_support_component: true person2: age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # DWP keeps this award, saved for an appointee, in payment after the - # 1 July 2026 abolition (SI 2025/1148 art. 3A(2)), so the model pays - # it for the whole of 2026-27 (esa_income_payable_share). + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). esa_income_appointee_saving: true benunits: benunit: @@ -508,7 +514,7 @@ # threshold (£155 a week), so he owes no income tax or primary Class 1 # contributions. -- name: Case 15, where Pension Credit pays with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. +- name: Case 15, where DWP would pay Pension Credit with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -541,7 +547,7 @@ pension_credit: 3_671.20 pension_credit_earnings_disregard: 260 -- name: Case 16, where Pension Credit pays nothing with or without the award, the man keeps income-related ESA. +- name: Case 16, where DWP would pay no Pension Credit with or without the award, the man keeps income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -601,7 +607,7 @@ pension_credit: 0 esa_income: 4_919.20 -- name: Case 18, where Pension Credit pays only with the award, the man keeps income-related ESA and gets no Pension Credit. +- name: Case 18, where DWP would pay Pension Credit only with the award, the man keeps income-related ESA and gets no Pension Credit. period: 2016 absolute_error_margin: 0.01 input: @@ -645,14 +651,15 @@ # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings # derived from that work which do not exceed the limits" (ESA Regs 2008 - # Sch 7 para 5): all of them, not £20. He fails para 4(2)(a), his Pension - # Credit would fall back to B(0), nil, and his ESA would come back. ESA - # with no Pension Credit holds; Pension Credit with no ESA does not. - # On another reading, para 4(2)(a) holds wherever the award disregarded at - # least £20 of earnings, and he would then keep £525.20 of Pension Credit - # with no ESA. The model takes "£20 was disregarded" to mean the £20 - # disregard of Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any - # earnings ... which do not exceed the limits", not £20. + # Sch 7 para 5): all of them, not £20. The expected values take "£20 was + # disregarded" to mean the £20 disregard of Sch 7 paras 6 and 7(1), as + # under para 5 DWP disregards "any earnings ... which do not exceed the + # limits", not a fixed £20. On that reading he fails para 4(2)(a), his + # Pension Credit would fall back to B(0), nil, and his ESA would come back, + # so ESA with no Pension Credit holds and Pension Credit with no ESA does + # not. On another reading, para 4(2)(a) holds wherever DWP disregarded at + # least £20 of earnings in the award, and he would then keep £525.20 of + # Pension Credit with no ESA while he stays in employment (para 4(2)(b)). esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -661,7 +668,7 @@ pension_credit_entitlement: 525.20 pension_credit: 0 -- name: Case 19, where working tax credit makes Pension Credit pay only without the award, a couple aged 66 and 58 get Pension Credit and no income-related ESA. +- name: Case 19, a couple aged 66 and 58 to whom DWP would pay Pension Credit only without the award, since HMRC then applies working tax credit's income test, get Pension Credit and no income-related ESA. period: 2016 absolute_error_margin: 0.01 input: @@ -698,9 +705,9 @@ # B(e): with the award HMRC pays working tax credit without its income # test (TCA 2002 s.7(2)), so working tax credit comes to 3_970, and # 10_000 + 3_970 - 1_040 = 12_930, over 12_352.60: nil. DWP counts no - # working tax credit in the savings credit (SPC Regs 2002 reg 9(a)): - # 10_000 - 1_040 = 8_960, 172.31 a week, under the £212.97 couple - # threshold: nil. + # working tax credit in the savings credit's qualifying income (SPC Regs + # 2002 reg 9(a); savings_credit_income): 10_000 - 1_040 = 8_960, 172.31 a + # week, under the £212.97 couple threshold: nil. # B(0): Pension Credit on 3_970 would be nil too, so it gives no # passport. The income test applies: 41% of income over £6,420 comes off, # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - @@ -712,7 +719,7 @@ working_tax_credit: 2_502.20 pension_credit: 370.40 -- name: Case 20, in 2018 a couple aged 66 and 58 whose income Pension Credit refuses keep support-group ESA and both benefit cap exceptions. +- name: Case 20, in 2018 a couple aged 66 and 58 to whom DWP would pay no Pension Credit on their income keep support-group ESA and both benefit cap exceptions. period: 2018 absolute_error_margin: 0.01 input: @@ -875,7 +882,7 @@ esa_income_eligible: true esa_income: 5_000 -- name: Case 25, a couple whose award may count the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. +- name: Case 25, a couple in whose award DWP may disregard £20 of the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. period: 2016 absolute_error_margin: 0.01 input: @@ -894,8 +901,8 @@ date_of_birth: 19560301 gender: FEMALE is_claimant_or_partner: true - # £115.50 a week for 15 hours, under the 24 hours of para 6(1)(f) - # (ESA Regs 2008 reg 42(1)). + # £115.50 a week for 15 hours, under the 24 hours of WRA 2007 Sch 1 + # para 6(1)(f) (ESA Regs 2008 reg 42(1)). employment_income: 6_006 hours_worked: 780 benunits: @@ -942,7 +949,7 @@ pension_credit_entitlement: 366.60 pension_credit: 0 -- name: Case 26, a couple whose award may count £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where Pension Credit pays only with the award. +- name: Case 26, a couple in whose award DWP may disregard £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. period: 2016 absolute_error_margin: 0.01 input: @@ -965,8 +972,8 @@ date_of_birth: 19560301 gender: FEMALE is_claimant_or_partner: true - # £30 a week for 5 hours, under the 24 hours of para 6(1)(f) (ESA Regs - # 2008 reg 42(1)). + # £30 a week for 5 hours, under the 24 hours of WRA 2007 Sch 1 para + # 6(1)(f) (ESA Regs 2008 reg 42(1)). employment_income: 1_560 hours_worked: 260 benunits: @@ -983,26 +990,29 @@ # Income: 90 + 130 + 30 = 250 a week, 52 x 250 = 13_000. Neither member # owes primary Class 1 contributions on £90 or £30 a week in 2016-17. His # £11,440 of earnings and pension exceeds the £11,000 personal allowance - # by £440, and the model transfers £1,100 of her unused allowance to him - # (marriage_allowance, 10% of £11,000), so neither member owes income tax - # and the earnings count in full. + # by £440. The model takes the couple to be married (is_married) and to + # claim the marriage allowance (would_claim_marriage_allowance), so it + # transfers £1,100 of her unused allowance to him (marriage_allowance, 10% + # of £11,000) and neither member owes income tax or contributions on the + # earnings. Without the transfer he would owe £88 and B(e) would come to + # 480.60. # B(0): 13_000 - 520 = 12_480, over 12_352.60, so nil. B(e): 13_000 - # 1_040 = 11_960, so 12_352.60 - 11_960 = 392.60, or 52 x 7.55. Neither # member has reached pensionable age, so no savings credit is due (SPCA # 2002 s.3(1)). - # Here the law may differ from the model. His £90 falls £25.50 short of - # the £115.50 limit, so DWP disregards the woman's earnings "to the extent - # that the claimant's earnings are less than ... 16 x National Minimum - # Wage, but only up to a maximum of £20" (ESA Regs 2008 Sch 7 para 6): - # £20 of her £30. DWP keeps disregarding the £20 (SPC Regs 2002 Sch VI - # para 4(2)) where "£20 was disregarded in respect of earnings taken into - # account in that award" and "the person whose earnings qualified for the - # disegard continues in employment after the termination of that award". - # Once the couple claimed, it could then keep 392.60 of Pension Credit - # with no ESA while she stays in work. The model holds no record of how - # an award started, and pension_credit_earnings_disregard does not apply - # para 4(2), so the model pays this couple the award and no Pension - # Credit, as in cases 18 and 25. + # Here the law may differ from the model. His £90 falls more than £20 + # short of 16 x £7.20 = £115.20, so DWP disregards the woman's earnings + # "to the extent that the claimant's earnings are less than ... 16 x + # National Minimum Wage, but only up to a maximum of £20" (ESA Regs 2008 + # Sch 7 para 6): £20 of her £30. DWP keeps disregarding the £20 (SPC Regs + # 2002 Sch VI para 4(2)) where "£20 was disregarded in respect of earnings + # taken into account in that award" and "the person whose earnings + # qualified for the disegard continues in employment after the termination + # of that award". Once the couple claimed, it could then keep £392.60 of + # Pension Credit with no ESA while she stays in work. The model holds no + # record of how an award started, and pension_credit_earnings_disregard + # does not apply para 4(2), so the model pays this couple the award and + # no Pension Credit, as in cases 18 and 25. meets_pension_credit_age_conditions: true esa_income_eligible: true esa_income: 5_491.20 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index d7c828356f..b1f7fe9ea9 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -41,9 +41,9 @@ (has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. The Secretary of State appointed 1 July 2026 for the abolition of income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), -and DWP keeps an award saved for an appointee in payment (art. 3A(2)), so -every adult drawn in 2026 has the saving (esa_income_appointee_saving) and -each award stays payable for the whole year; +and under the claimant's appointee saving DWP keeps the award in payment +(art. 3A(2)), so every adult drawn in 2026 has the saving +(esa_income_appointee_saving) and each award stays payable for the whole year; test_esa_income_abolition_properties.py tests unsaved awards. Adults have earnings, pensions and household savings, so some families pass Pension Credit's means test and others fail it, and in 2016 and 2018 some report @@ -421,8 +421,9 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): if mask.any(): event(label) - # Sch 1 para 6(1)(c) and (d): where Pension Credit pays without the award, - # the couple's award is nil and the family gets that Pension Credit. + # Sch 1 para 6(1)(c) and (d): where the family would get Pension Credit + # without the award, the couple's award is nil and the family gets that + # Pension Credit. assert (couple_esa[claims][barred] == 0).all(), units assert np.allclose( pension_credit[claims][barred], without_award[barred], atol=0.01 @@ -486,8 +487,8 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): start += len(members) -# An appointee saving for income-related ESA (SI 2025/1148 art. 3A(2)), which -# keeps the award payable after the abolition of 1 July 2026. +# The appointee saving for income-related ESA, under which DWP keeps the award +# in payment after the abolition of 1 July 2026 (SI 2025/1148 art. 3A(2)). SAVED = {"esa_income_appointee_saving": True} @@ -553,10 +554,10 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( the award, a couple aged 70 and 60 without the saving (case 4's kind), a pensioner without a report, a member outside the couple reporting beside a pensioner, and a pensioner reporting the award, who has reached - pensionable age and so has no claim whatever Pension Credit pays. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the - abolition of income-related ESA on 1 July 2026 leaves each award payable - for the whole year.""" + pensionable age and so has no claim whatever the couple's Pension + Credit. Each reporter has the appointee saving (SI 2025/1148 art. 3A(2)), + so DWP keeps each award in payment after the abolition of income-related + ESA on 1 July 2026, and each award stays payable for the whole year.""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -789,9 +790,9 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets them, and the model opens no branch, under the reform or without it. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the - abolition of income-related ESA on 1 July 2026 leaves each award payable - for the whole year.""" + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so DWP keeps + each award in payment after the abolition of income-related ESA on 1 July + 2026, and each award stays payable for the whole year.""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -1225,8 +1226,9 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of work passes, and the screen reads both. Each reporter has the appointee - saving (SI 2025/1148 art. 3A(2)), so the abolition of income-related ESA - on 1 July 2026 leaves each report open for the whole year.""" + saving (SI 2025/1148 art. 3A(2)), so DWP keeps each award in payment + after the abolition of income-related ESA on 1 July 2026, and each report + stays open for the whole year.""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 3d41cc4c07..9556084c54 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -528,8 +528,9 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) - # Entitlement for the ESA screen: Pension Credit pays. Without earnings - # or tax credits, it pays the same with the couple's award as without. + # Entitlement for the ESA screen: the couple would get Pension Credit. + # Without earnings or tax credits, DWP would pay the same Pension Credit + # with the couple's award as without. entitled = on_pension_credit & (sim.calculate("pension_credit", YEAR) > 0) pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index f5cd9310c2..05f1fd1794 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -81,9 +81,10 @@ def reference_report_awards(benunit, period, award): screen = reference.esa_screen if is_esa else reference.jsa_screen if is_esa: # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension - # Credit and it pays. No one here has earnings, and in 2025 no one has - # tax credits, so Pension Credit pays the same with the couple's award - # as without. An outside member keeps their own claim whatever it pays. + # Credit and DWP would pay it. No one here has earnings, and in 2025 no + # one has tax credits, so DWP would pay the same Pension Credit with + # the couple's award as without. An outside member keeps their own + # claim whatever the couple's Pension Credit. on_pension_credit = ( benunit("meets_pension_credit_age_conditions", period) & benunit("would_claim_pc", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 93714aa986..5acdcb0986 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -75,11 +75,13 @@ def pension_credit_without_income_related_esa(benunit, period): Parliament bars the couple's income-related ESA while the claimant, or the other member of the couple, has an entitlement to State Pension Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), and the - model's Pension Credit reads that award. Its higher earnings disregard - counts the couple's award (claimant_or_partner_esa_income; SPC Regs 2002 - Sch VI para 4(1)(a)(vii)). It counts working tax credit as income, and - the tax credit income test reads the award (TCA 2002 s.7(2)), as does - Income Support's gate, which that test reads. Its route test + model's Pension Credit reads that award in five ways. pension_credit + itself pays nothing while the couple has the award + (claimant_or_partner_esa_income). pension_credit_earnings_disregard reads + the couple's award for the higher earnings disregard (SPC Regs 2002 Sch + VI para 4(1)(a)(vii)). pension_credit_income counts working tax credit, + and the tax credit income test reads the award (TCA 2002 s.7(2)), as + does Income Support's gate, which that test reads. The route test (left_pension_route_at_legacy_closure) reads Universal Credit entitlement, whose work capability test reads esa_income (has_own_esa_award). A formula that read Pension Credit in the @@ -88,21 +90,27 @@ def pension_credit_without_income_related_esa(benunit, period): This function calculates Pension Credit in a branch that sets esa_income, the couple's award and esa_income_eligible to nil, so the screen never runs there. The branch reads pension_credit, so it counts - the claim (would_claim_pc) and an entered award. Where that Pension - Credit pays, the screen refuses the couple's award, and the - simulation's own Pension Credit then equals the branch's. Where it pays - nothing, the model pays the award, and pension_credit pays nothing while - the couple has it, even where the award would make Pension Credit pay - through the higher earnings disregard. With Pension Credit in payment the - award would stop, and with it the disregard of SPC Regs 2002 Sch VI para - 4(1)(a)(vii). DWP keeps disregarding £20 only where it disregarded £20 of - earnings in the award (para 4(2)), and it disregards all of a claimant's - permitted-work earnings up to the limit (ESA Regs 2008 Sch 7 para 5), so - such a family would lose its Pension Credit and get the award back. - pension_credit_earnings_disregard does not apply para 4(2), and the model - holds no record of how an award started, so a couple whose award - disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7) gets the - award and no Pension Credit too. + the claim (would_claim_pc) and an entered award. Where the branch's + Pension Credit exceeds nil, the screen refuses the couple's award, and + the simulation's own Pension Credit then equals the branch's. Where it + comes to nil, the model pays the award, and pension_credit pays nothing + while the couple has it, even where the family would get Pension Credit + with the award through the higher earnings disregard. With Pension + Credit in payment the award would stop, and with it the disregard of SPC + Regs 2002 Sch VI para 4(1)(a)(vii). DWP keeps disregarding £20 only + where it disregarded £20 of earnings in the award (para 4(2)), and it + disregards all of a claimant's permitted-work earnings up to the limit + (ESA Regs 2008 Sch 7 para 5). For a family whose only earnings are + those, the model's result agrees with the reading of para 4(2)(a) under + which "£20 was disregarded" means the £20 disregard of Sch 7 paras 6 and + 7(1): the family would lose its Pension Credit and get the award back. + On the reading under which any disregard of at least £20 meets para + 4(2)(a), DWP could keep that family on Pension Credit with no award. + pension_credit_earnings_disregard does not apply para 4(2), and the + model holds no record of how an award started, so the model also pays + the award and no Pension Credit to a couple in whose award DWP + disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7), though + DWP could keep that couple on Pension Credit with no award (para 4(2)). In a year with tax credits, a calculation in this branch opens the passport branch (pension_credit_with_passported_tax_credits), and one in From 3c16aea5ae07c928b222408aeef1a58d62f8ab64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:42:20 +0000 Subject: [PATCH 117/168] Test the Marriage Allowance cliff at pay of 43,663 and an annual allowance charge Move the second Marriage Allowance cliff case from pay of 43,662 to 43,663. At 43,662 the earner's taxable income of 31,092 reaches the Scottish intermediate limit without passing it. The model drops the allowance there only because tax_band tests income < threshold and so counts that income as HIGHER. At 43,663 the earner pays 1 pound at the higher rate and loses the allowance either way. The invariant 5 test now holds marriage_allowance and savings_allowance on both sides of the pay rise. income_tax_with_inputs_zeroed keeps stored inputs on its branches, so when the test held them on the raised side alone, it also held them on that side's branch without the pay. Add a pensioner with a pension annual allowance charge: the formula leaves the charge out of the tax on the other counted income, as it does the High Income Child Benefit Charge. Name the actor in comments and docstrings this pull request adds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHf6NVfdP4sQfNWoZ5U --- .../council_tax_reduction_tax_deduction.yaml | 73 ++++++++++++++----- ..._tax_reduction_tax_deduction_properties.py | 39 ++++++---- ...tion_income_tax_on_other_counted_income.py | 25 ++++--- ...ion_scotland_self_employment_deductions.py | 14 ++-- .../council_tax_reduction_tax_deduction.py | 16 ++-- 5 files changed, 111 insertions(+), 56 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index f0f503e326..c7a0013e36 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -493,9 +493,9 @@ # 7,540 and 19,730 x 40% = 7,892, so 15,432. Child Benefit 27.05 x 52 = # 1,406.60, 1,406 in whole pounds; the charge takes (70,000 - 60,000) / # 200 = 50% of it, 703, so income tax before the winter fuel payment - # charge comes to 16,135. The scheme does not count Child Benefit, and the - # charge recovers it, so the charge is not tax payable on income taken - # into account. + # charge comes to 16,135. The scheme does not count Child Benefit, and + # through the charge HMRC recovers it, so the formula treats the charge as + # not tax payable on income taken into account. income_tax_before_winter_fuel_payment_charge: [16_135, 0] council_tax_reduction_tax_deduction: 15_432 # 70,000 - 15,432; the scheme does not count Child Benefit. @@ -598,6 +598,39 @@ # 80,000 - 20,838 - 1,300. council_tax_reduction_applicable_income: 57_862 +- name: England's pensioner scheme deducts no pension annual allowance charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + personal_pension_contributions: 70_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner has no earnings, so the model gives relief on 3,600 of + # the contributions: 70,000 - 3,600 - 12,570 = 53,830 taxable; 37,700 x + # 20% = 7,540 and 16,130 x 40% = 6,452, so 13,992. The contributions + # exceed the 60,000 annual allowance by 10,000, and the model charges that + # amount at the higher rate, 40%, so the charge comes to 4,000 and income + # tax before the winter fuel payment charge to 17,992. + personal_pension_contributions_tax: 4_000 + income_tax_before_winter_fuel_payment_charge: 17_992 + # The charge falls on the contributions, not on income the scheme + # counts, so the formula leaves it out of T(E, S, U): the deduction comes + # to the 13,992 of tax on the pension. + council_tax_reduction_tax_deduction: 13_992 + - name: England's pensioner scheme deducts a working-age partner's Class 1 contributions period: 2026 absolute_error_margin: 0.01 @@ -620,8 +653,9 @@ region: NORTH_WEST council_tax: 2_000 output: - # Para 19(2)(a) deducts the income tax and primary Class 1 deducted from - # the partner's pay. T: the pension bears (20,000 - 12,570) x 20% = 1,486 + # Under para 19(2)(a), the council deducts the income tax and primary + # Class 1 deducted from the partner's pay. T: the pension bears (20,000 - + # 12,570) x 20% = 1,486 # and the pay the same 1,486, so 2,972. Without the pay the partner has # no income, and the model transfers 1,260 of Marriage Allowance to the # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: @@ -656,8 +690,9 @@ # The younger partner's pension falls within the personal allowance, so # the model transfers 1,260 of Marriage Allowance to the older partner. marriage_allowance: [1_260, 0] - # SSI 2012/319 reg 37(1) allows "only the personal relief", so the - # Marriage Allowance does not come off: (30,000 - 12,570) x 20% = 3,486. + # The Scottish Ministers allow "only the personal relief" (SSI 2012/319 + # reg 37(1)), so the Marriage Allowance does not come off: (30,000 - + # 12,570) x 20% = 3,486. # No Class 4 above pension age. T(E, S, U): without the profit the older # partner has no income and the pension bears no tax, so nil. No # employed earnings. @@ -725,14 +760,14 @@ # 7,807.80). council_tax_reduction: 9_537.87 -- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,662 +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,663 period: 2026 absolute_error_margin: 0.01 input: people: earner: age: 40 - employment_income: 43_662 + employment_income: 43_663 partner: age: 40 benunits: @@ -746,13 +781,15 @@ region: SCOTLAND council_tax: 15_000 output: - # Taxable 31,092 reaches the Scottish higher band, so no Marriage - # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56, so 6,320.09. - # Class 1 (43,662 - 12,569.96) x 8% = 2,487.36. 6,320.09 + 2,487.36 = - # 8,807.45: one pound of pay raises the deduction by 264.89. + # Taxable 31,093 exceeds the Scottish intermediate limit of 31,092, so + # the earner pays 1 pound at the higher rate and gets no Marriage + # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56 + 1 x 42% = + # 0.42, so 6,320.51. Class 1 (43,663 - 12,569.96) x 8% = 2,487.44. + # 6,320.51 + 2,487.44 = 8,807.95: two pounds of pay raise the deduction + # by 265.39. marriage_allowance: [0, 0] - council_tax_reduction_tax_deduction: 8_807.45 - # 43,662 - 8,807.45. - council_tax_reduction_applicable_income: 34_854.55 - # 15,000 - 20% x (34,854.55 - 7,807.80): the reduction rises by 52.78. - council_tax_reduction: 9_590.65 + council_tax_reduction_tax_deduction: 8_807.95 + # 43,663 - 8,807.95. + council_tax_reduction_applicable_income: 34_855.05 + # 15,000 - 20% x (34,855.05 - 7,807.80): the reduction rises by 52.68. + council_tax_reduction: 9_590.55 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index ed6eada144..514cf1d7bd 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -278,10 +278,11 @@ def pensioner_parent_household(draw): def test_pensioner_deduction_ignores_a_child_benefit_claim(households): """Invariant 2, for Child Benefit. In every pensioner scheme, with no employed earnings, the deduction does not change with a claim for Child - Benefit. The pensioner schemes do not count Child Benefit, and the High - Income Child Benefit Charge recovers it, so the charge is not tax payable - on income taken into account. The test compares two simulations of the - same families, one claiming Child Benefit and one not.""" + Benefit. The pensioner schemes do not count Child Benefit, and through the + High Income Child Benefit Charge HMRC recovers it, so the formula treats + the charge as not tax payable on income taken into account. The test + compares two simulations of the same families, one claiming Child Benefit + and one not.""" claiming = [{**h, "child_benefit": True} for h in households] not_claiming = [{**h, "child_benefit": False} for h in households] with_claim = simulate(claiming) @@ -383,12 +384,15 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p (a) passes both through to the deduction: losing Marriage Allowance, and the personal savings allowance falling as the person enters a higher band. A Scottish couple, one partner with no income and council tax of - 15,000, whose pay rises from 43,661 to 43,662 loses Marriage Allowance: - the deduction rises by 264.89 and the reduction by 52.78 (the YAML cases - pin both). The test therefore builds a third simulation of the raised - families with marriage_allowance and savings_allowance held at their - values before the rise, and asserts there, for every family, that the - reduction does not rise.""" + 15,000, whose pay rises from 43,661 to 43,663 loses Marriage Allowance: + the deduction rises by 265.39 and the reduction by 52.68 (the YAML cases + pin both). The test therefore holds marriage_allowance and + savings_allowance at their values before the rise in two more + simulations, one of the families before the rise and one of the raised + families, and asserts, for every family, that the reduction in the second + does not exceed the reduction in the first. Because + income_tax_with_inputs_zeroed keeps stored inputs on its branches, the + branch without the pay then reads the same held values on both sides.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -399,12 +403,19 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p households = [{**h, "uc": False} for h in households] before = simulate(households) after = simulate(raised) + # income_tax_with_inputs_zeroed keeps stored inputs on its branches, so + # hold the cliffs on both sides. + held_before = simulate(households) held = simulate(raised) cliffs = ["marriage_allowance", "savings_allowance"] for variable in cliffs: - held.set_input(variable, YEAR, np.asarray(before.calculate(variable, YEAR))) + values = np.asarray(before.calculate(variable, YEAR)) + held_before.set_input(variable, YEAR, values) + held.set_input(variable, YEAR, values) schemes(before, households) - reduction_before = np.asarray(before.calculate("council_tax_reduction", YEAR)) + reduction_held_before = np.asarray( + held_before.calculate("council_tax_reduction", YEAR) + ) reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) cliff = np.zeros(len(households), dtype=bool) for variable in cliffs: @@ -413,11 +424,11 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p ) for crossed in cliff: event("crosses a tax cliff" if crossed else "no tax cliff") - assert np.all(reduction_held <= reduction_before + 0.01), ( + assert np.all(reduction_held <= reduction_held_before + 0.01), ( households, rise, partner, - reduction_before, + reduction_held_before, reduction_held, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index 186457263d..b6276d5ee9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -18,12 +18,14 @@ "dividend_income", ] -# The Step 7 charges of ITA 2007 s. 23 other than the winter fuel payment -# charge (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child -# Benefit Charge and the pension annual allowance charge). Neither falls on -# income the pensioner schemes count: the first recovers Child Benefit, which -# council_tax_reduction_applicable_income leaves out on the pensioner route, -# and the second falls on pension contributions. +# The Step 7 charges of ITA 2007 s. 23 that income_tax_charges holds, +# other than the winter fuel payment charge +# (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child +# Benefit Charge and the pension annual allowance charge). Neither falls +# on income the pensioner schemes count: through the first, HMRC +# recovers Child Benefit, which council_tax_reduction_applicable_income +# leaves out on the pensioner route, and the second falls on pension +# contributions. STEP_7_CHARGES = ["income_tax_charges"] @@ -40,11 +42,12 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "savings interest, other investment income and dividends. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " - "the pension annual allowance charge: the first recovers Child " - "Benefit, which the pensioner schemes do not count, and the second " - "falls on pension contributions, so neither is tax payable on income " - "taken into account. This is the tax on the rest of the income the " - "pensioner schemes count, with the uncounted income above it. The " + "the pension annual allowance charge: through the first, HMRC " + "recovers Child Benefit, which the pensioner schemes do not count, " + "and the second falls on pension contributions, so the formula " + "treats neither as tax payable on income taken into account. This is " + "the tax on the rest of the income the pensioner schemes count, with " + "the uncounted income above it. The " "formula zeroes those inputs and the charges on a branch that it " "drops afterwards, and builds the branch only when someone has " "uncounted income or a charge." diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index d5e754de84..7f69c57f22 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -21,12 +21,14 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "less only the personal allowance; Class 4 contributions at the main " "rate on the profit between the lower and upper profits limits, where " "Class 4 is payable at all; and, for years before 2024-25, the Class " - "2 contributions payable, which SI 2024/377 omitted from reg 38(2) " - "and reg 37(2) on 6 April 2024. SSI 2012/303 reg 38(1) allows the " - "personal relief under ITA 2007 s. 35(1); the formula reads it as the " - "allowance left after the s. 35(2) taper, because s. 35(2) reduces " - "'the allowance under subsection (1)'. SSI 2012/319 reg 37(1) allows " - "the relief under ss. 36 or 37. Parliament omitted s. 36 (Finance Act " + "2 contributions payable, which the Treasury omitted from reg 38(2) " + "and reg 37(2) from 6 April 2024 (SI 2024/377). At working age, the " + "Scottish Ministers allow the personal relief under ITA 2007 s. " + "35(1) (SSI 2012/303 reg 38(1)); the formula reads it as the " + "allowance left after the taper, because in s. 35(2) Parliament " + "reduces 'the allowance under subsection (1)'. At pension age, they " + "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " + "Parliament omitted s. 36 (Finance Act " "2014), and s. 37 gives the section 35 amount only to a person born " "before 6 April 1938; read literally, the reference gives nobody " "younger any relief. The formula reads it as the same tapered s. 35 " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 62a83eacb7..bb4c51b8cf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -37,10 +37,11 @@ class council_tax_reduction_tax_deduction(Variable): "income taken into account, so the formula deducts T less T(E), " "never negative; T(E, S, U), the tax on the other counted income " "with the uncounted income above it and without the Step 7 charges in " - "income_tax_charges (the High Income Child Benefit Charge recovers " - "Child Benefit, which these schemes do not count, and the pension " - "annual allowance charge falls on contributions, so neither is tax " - "payable on income taken into account); the notional tax and " + "income_tax_charges (through the High Income Child Benefit Charge, " + "HMRC recovers Child Benefit, which these schemes do not count, and " + "the pension annual allowance charge falls on contributions, so the " + "formula treats neither as tax payable on income taken into " + "account); the notional tax and " "contributions on profit (pension_credit_self_employment_deductions " "in England and Wales, the Scottish variable in Scotland); and " "primary Class 1 contributions. Earnings are the top slice of income " @@ -136,9 +137,10 @@ def total(values): ) ) # The formula prices the English and Welsh notional tax with - # Pension Credit's variable, because SI 2012/2885 Sch 1 para 30 and - # WSI 2013/3029 Sch 1 para 24 allow the same personal reliefs (ITA - # 2007 Part 3 Chapters 2, 3 and 3A). + # Pension Credit's variable, because the Secretary of State and + # the Welsh Ministers allow the same personal reliefs (SI + # 2012/2885 Sch 1 para 30; WSI 2013/3029 Sch 1 para 24): those + # under ITA 2007 Part 3 Chapters 2, 3 and 3A. england_and_wales_notional = total( person("pension_credit_self_employment_deductions", period) ) From c10280e5e9b719e9c360b598e8f52f53415643eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:33:53 +0000 Subject: [PATCH 118/168] Drop two statements of intent and rejoin two rewrapped test comments The 15,000 profit case said its notional deduction exceeds the tax paid "as the rule intends", and invariant 3's docstring said the notional amount can take the deduction above the liability "as the law intends". No quoted provision states that intent, so both now give the bound alone. Invariant 3's docstring now matches the invariant as the pull request states it. Rejoin the Class 1 and reg 37(1) comments that the actor fixes left with short lines; no word changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHf6NVfdP4sQfNWoZ5U --- .../council_tax_reduction_tax_deduction.yaml | 22 +++++++++---------- ..._tax_reduction_tax_deduction_properties.py | 11 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index c7a0013e36..65551db1af 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -169,7 +169,7 @@ output: # SSI 2012/303 reg 38: (15,000 - 12,570) x 20% = 486 and Class 4 # (15,000 - 12,570) x 6% = 145.80. That exceeds the 461.70 + 145.80 the - # person pays, as the rule intends. + # person pays. council_tax_reduction_tax_deduction: 631.80 # 15,000 - 631.80. council_tax_reduction_applicable_income: 14_368.20 @@ -655,13 +655,12 @@ output: # Under para 19(2)(a), the council deducts the income tax and primary # Class 1 deducted from the partner's pay. T: the pension bears (20,000 - - # 12,570) x 20% = 1,486 - # and the pay the same 1,486, so 2,972. Without the pay the partner has - # no income, and the model transfers 1,260 of Marriage Allowance to the - # pensioner, whose tax falls to (20,000 - 12,570 - 1,260) x 20% = 1,234: - # T(E) = 1,234, so the pay adds 2,972 - 1,234 = 1,738. T(E, S, U) = - # 1,234. Class 1 (20,000 - 12,569.96) x 8% = 594.40. 1,738 + 1,234 + - # 594.40 = 3,566.40. + # 12,570) x 20% = 1,486 and the pay the same 1,486, so 2,972. Without the + # pay the partner has no income, and the model transfers 1,260 of + # Marriage Allowance to the pensioner, whose tax falls to (20,000 - + # 12,570 - 1,260) x 20% = 1,234: T(E) = 1,234, so the pay adds 2,972 - + # 1,234 = 1,738. T(E, S, U) = 1,234. Class 1 (20,000 - 12,569.96) x 8% = + # 594.40. 1,738 + 1,234 + 594.40 = 3,566.40. council_tax_reduction_tax_deduction: 3_566.40 # 40,000 - 3,566.40 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_913.60 @@ -692,10 +691,9 @@ marriage_allowance: [1_260, 0] # The Scottish Ministers allow "only the personal relief" (SSI 2012/319 # reg 37(1)), so the Marriage Allowance does not come off: (30,000 - - # 12,570) x 20% = 3,486. - # No Class 4 above pension age. T(E, S, U): without the profit the older - # partner has no income and the pension bears no tax, so nil. No - # employed earnings. + # 12,570) x 20% = 3,486. No Class 4 above pension age. T(E, S, U): + # without the profit the older partner has no income and the pension + # bears no tax, so nil. No employed earnings. council_tax_reduction_tax_deduction: 3_486 # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_994 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 514cf1d7bd..7b1ac5377e 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -313,12 +313,11 @@ def any_household(draw): @PROPERTY_SETTINGS @given(st.lists(any_household(), min_size=1, max_size=4)) def test_deduction_bounds(households): - """Invariant 3. The deduction is never negative. Without self-employment - income it never exceeds the applicant's and partner's income tax before - the winter fuel payment charge plus National Insurance. With it, the - notional amount the schemes set on profit can take the deduction above - that liability, as the law intends, but never above the liability plus - the notional amount.""" + """Invariant 3. The deduction never falls below zero. Without + self-employment income it never exceeds the applicant's and partner's + income tax before the winter fuel payment charge plus National Insurance. + With it, the notional amount on profit can take the deduction above that + liability, but never above the liability plus the notional amount.""" simulation = simulate(households) schemes(simulation, households) deduction = benunit_values(simulation, DEDUCTION) From 9419afbaadf833473f68c53582d83e6ccbc684c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:51:37 +0000 Subject: [PATCH 119/168] Move the VAT survey coverage to 0.69 on Microcosm a6299504 Re-derive gov.simulation.vat.survey_consumption_coverage on the certified Microcosm UK 2024-25 national cut a6299504, whose fit holds obr.vat out: 81.48 / (0.7 x (170.994 - 2.33)) = 0.690 in 2024-25. The deprecated combined factor moves to the product, 0.483, and the parameter's reference adds the OBR receipts workbook's own URL. Tests that pin the product move with it: the 2024 and 2025 cases in vat_grossing.yaml, the legacy default in test_vat_grossing_legacy.py, FACTOR in test_private_school_vat_receipts.py, whose published receipts check now uses a6299504's rounded inputs, and a stale illustrative figure in test_scenario_baseline_parameters.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHapmX36bpbh3SMixXg --- changelog.d/2633.changed.md | 1 + .../simulation/microdata_vat_coverage.yaml | 4 +- .../vat/survey_consumption_coverage.yaml | 37 ++++++++++--------- .../baseline/gov/hmrc/vat/vat_grossing.yaml | 12 +++--- .../tests/test_private_school_vat_receipts.py | 14 +++---- .../test_scenario_baseline_parameters.py | 4 +- .../tests/test_vat_grossing_legacy.py | 2 +- 7 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 changelog.d/2633.changed.md diff --git a/changelog.d/2633.changed.md b/changelog.d/2633.changed.md new file mode 100644 index 0000000000..8abad51e1f --- /dev/null +++ b/changelog.d/2633.changed.md @@ -0,0 +1 @@ +Re-derived the survey VAT consumption coverage (`gov.simulation.vat.survey_consumption_coverage`) on the certified Microcosm UK 2024-25 national cut `a6299504` and moved it from 0.67 to 0.69: on that cut, 2024-25 total `vat` comes to £176.06bn at 0.67, 3.0% above OBR receipts of £170.994bn, and £171.02bn at 0.69, 0.02% above. Moved the deprecated combined factor `gov.simulation.microdata_vat_coverage` to the new product, 0.483. diff --git a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml index 54e8d87667..1ad053af43 100644 --- a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml @@ -1,4 +1,4 @@ -description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.67 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. +description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.69 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. metadata: unit: /1 period: year @@ -8,4 +8,4 @@ metadata: - title: docs/engineering/vat-coverage.md (derivation of the two factors this combines) href: https://github.com/PolicyEngine/policyengine-uk/blob/main/docs/engineering/vat-coverage.md values: - 2010-01-01: 0.469 + 2010-01-01: 0.483 diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 121d970d74..46396cc79f 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -4,22 +4,23 @@ metadata: period: year economy: false derivation: >- - Calibrated to the certified Microcosm UK 2024-25 national release only - (policyengine/populace-uk-private, tag - microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68, revision - f9d1922c). VAT on household energy bills (domestic_energy_vat) is not - grossed up, so the coverage applies only to VAT on other recorded - consumption. On that release, pre-scaling VAT on that consumption is 0.20 x - £396.15bn = £79.23bn in 2024 (the generic reduced-rate base is empty once - domestic fuel and power is taken out), and VAT on energy bills is £1.99bn. - OBR VAT receipts for 2024-25 (outturn, March 2026 EFO table 3.8, cash - basis) are £170.994bn. Coverage = 79.23 / (0.7 x (170.994 - 1.99)) = 0.670. - Before energy bills were split out, the same calculation gave 0.672, so - the rounded value is unchanged. Across 2024-25 to 2026-27 the coverage is - 0.650 to 0.670, and the HMRC ready reckoner (£8.8bn for one point on the - standard rate in 2026-27) implies 0.688. On Enhanced FRS 2024-25 the same - calculation gives 0.927 (policyengine-uk-data 1.56.16) or 0.967 (1.58.0), - so this value puts total VAT on that dataset 38% or 44% above 2024-25 + This value matches 2024-25 VAT receipts on one dataset only: the certified + Microcosm UK 2024-25 national release (policyengine/populace-uk-private, + tag microcosm-uk-2024-25-national-20261025T091414Z-a6299504, revision + 994cac89), whose fit excludes the obr.vat receipts target. The vat formula + adds VAT on household energy bills (domestic_energy_vat) without grossing + it up, so the coverage applies only to VAT on other recorded consumption. + On that release, pre-scaling VAT on that consumption comes to 0.20 x + £407.40bn = £81.48bn in 2024 (reduced_rate_vat_consumption takes domestic + fuel and power's share of spending out of the reduced-rate share, which + leaves the generic reduced-rate base empty), and VAT on energy bills comes + to £2.33bn. OBR VAT receipts for 2024-25 (outturn, March 2026 EFO table + 3.8, cash basis) come to £170.994bn. Coverage = 81.48 / (0.7 x (170.994 - + 2.33)) = 0.690. Across 2024-25 to 2026-27 the coverage runs from 0.666 to + 0.690, and the HMRC ready reckoner (£8.8bn for one point on the standard + rate in 2026-27) implies 0.704. On Enhanced FRS 2024-25 + (policyengine-uk-data 1.58.1) the same calculation gives 0.954 for + 2024-25, so this value puts total VAT on that file 38% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists the inputs, file hashes and per-dataset results. These receipts already include private school VAT. @@ -33,7 +34,9 @@ metadata: reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts (efo-march-2026-detailed-forecast-tables-receipts.xlsx), sheet 3.8, row 13 (Value added tax, cash basis), cell D13 for 2024-25 outturn (£bn) + href: https://obr.uk/docs/d055fbf02d5b3g6jq8l2/efo-march-2026-detailed-forecast-tables-receipts.xlsx - title: HMRC, Direct effects of illustrative tax changes bulletin (June 2025) href: https://www.gov.uk/government/statistics/direct-effects-of-illustrative-tax-changes/direct-effects-of-illustrative-tax-changes-bulletin-january-2025 values: - 2010-01-01: 0.67 + 2010-01-01: 0.69 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index e7e4f54af0..2fc324c608 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -5,11 +5,11 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - # (10,000 x 20% + 1,000 x 5%) / (0.67 x 0.7) = 2,050 / 0.469 = 4,371.00, + # (10,000 x 20% + 1,000 x 5%) / (0.69 x 0.7) = 2,050 / 0.483 = 4,244.31, # less private school fees' official share of 2025-26 VAT receipts # (0.008353), which private_school_vat counts separately. - vat: 4_334.49 - baseline_vat: 4_334.49 + vat: 4_208.85 + baseline_vat: 4_208.85 - name: VAT in 2024 is grossed up by the same product as later years period: 2024 @@ -19,9 +19,9 @@ reduced_rate_vat_consumption: 1_000 output: # The deprecated combined factor has one undated value, so 2024 takes the - # two factors' product too: 2,050 / 0.469 - vat: 4_371.00 - baseline_vat: 4_371.00 + # two factors' product too: 2,050 / 0.483 + vat: 4_244.31 + baseline_vat: 4_244.31 - name: With both factors at one, VAT is the statutory liability on recorded consumption period: 2025 diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index c29f5b3d24..b6116da959 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -37,7 +37,7 @@ LEGACY = "gov.simulation.microdata_vat_coverage" FEES = "gov.simulation.private_school_vat.private_school_fees" BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" -FACTOR = 0.67 * 0.7 +FACTOR = 0.69 * 0.7 # HMRC's OBR-certified costing of VAT on private school fees (Autumn Budget # 2024 Table 5.1, £m) and OBR cash-basis VAT receipts (March 2026 EFO detailed # forecast tables, receipts, sheet 3.8, row 13, cells E13:I13, £m), by the @@ -190,13 +190,13 @@ def test_vat_reads_neither_attendance_nor_fees(): def test_published_microcosm_receipts_check_is_unchanged(): - # Rounded aggregate inputs published in docs/engineering/vat-coverage.md, - # with energy bills split out: the standard-rated base and the energy-bill - # VAT, which is added after grossing. Model year 2024 has no school charge - # and no share to reallocate. - sim = simulation(year=2024, full=396.15e9, reduced=0, energy_vat=1.99e9) + # Rounded aggregate inputs that docs/engineering/vat-coverage.md publishes + # for Microcosm a6299504, with energy bills split out: the standard-rated + # base, and the energy-bill VAT, which vat adds after grossing. Model year + # 2024 has no school charge and no share to reallocate. + sim = simulation(year=2024, full=407.40e9, reduced=0, energy_vat=2.33e9) combined = amount(sim, "vat", 2024) + amount(sim, "private_school_vat", 2024) - expected = 0.2 * 396.15e9 / FACTOR + 1.99e9 + expected = 0.2 * 407.40e9 / FACTOR + 2.33e9 assert combined == pytest.approx(expected) assert combined / 170.994e9 == pytest.approx(1, rel=0.001) diff --git a/policyengine_uk/tests/test_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py index 1e272b0810..b637a3d285 100644 --- a/policyengine_uk/tests/test_scenario_baseline_parameters.py +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -51,8 +51,8 @@ def _vat_change(sim): def test_every_reform_route_scores_vat_against_the_unreformed_baseline(kwargs): sim = Simulation(situation=SITUATION, **kwargs) # All spending here is standard-rated, so raising the rate from 20% to - # 22% raises VAT by a tenth, whatever the grossing factor (526.32 at the - # current factor). + # 22% raises VAT by a tenth, whatever the grossing factor (about 411 at + # the shipped factors). unreformed_vat = float(Simulation(situation=SITUATION).calculate("vat", 2026)[0]) assert _vat_change(sim) == pytest.approx(0.1 * unreformed_vat, abs=0.01) assert _vat_change(sim) > 0 diff --git a/policyengine_uk/tests/test_vat_grossing_legacy.py b/policyengine_uk/tests/test_vat_grossing_legacy.py index 1863aa2f5c..728f16d345 100644 --- a/policyengine_uk/tests/test_vat_grossing_legacy.py +++ b/policyengine_uk/tests/test_vat_grossing_legacy.py @@ -31,7 +31,7 @@ def test_legacy_default_is_the_product_of_the_two_factors(): year ) * p.vat.household_share_of_receipts(year) assert p.microdata_vat_coverage(year) == pytest.approx(product) - assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.469) + assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.483) def test_reform_dict_to_legacy_path_changes_vat(): From c2575c905f5faa50a86817da91e6e3b5ca5b14a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:51:37 +0000 Subject: [PATCH 120/168] Derive the VAT coverage on a6299504 in the doc and refresh its provenance Add a derivation section for the certified Microcosm cut a6299504: the cut's provenance and certification, the obr.vat exclusion, the OBR receipts workbook (URL, SHA-256, archived copy, sheet, row and cells), the chunked method, the results at 0.67 and 0.69 on the cut and on Enhanced FRS 1.58.1, the bound and a reproducing snippet at revision 994cac89. Name policyengine.py's certified cuts correctly: a6299504 today, 6cba5d71 on 20 October, and a133f37a only proposed in policyengine.py#715. Retitle the October derivation as the earlier one on f9d1922c and date its statements of the value, and point the switch plan at the new cut. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHapmX36bpbh3SMixXg --- docs/book/assumptions/mp30k-uk-switch-plan.md | 13 +- docs/engineering/vat-coverage.md | 341 +++++++++++++----- 2 files changed, 263 insertions(+), 91 deletions(-) diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md index 374e9209f3..90a16f7cb7 100644 --- a/docs/book/assumptions/mp30k-uk-switch-plan.md +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -115,14 +115,13 @@ A documented gate must pass before any environment's default changes: 1. Dataset choice stays a runtime setting: no code in this repo branches on which dataset is loaded. One parameter is tied to the dataset, though: - `gov.simulation.vat.survey_consumption_coverage` is calibrated to one + `gov.simulation.vat.survey_consumption_coverage` matches receipts on one Microcosm UK 2024-25 national cut only - (`microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68`; policyengine.py - 6.10.0 pins the later cut - `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`), puts 2024-25 - VAT 38% (policyengine-uk-data 1.56.16) or 44% (1.58.0) above receipts on - the Enhanced FRS 2024-25, and has to be re-derived when the dataset it - should match changes (`docs/engineering/vat-coverage.md`). + (`microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, which + policyengine.py 6.11.1 and 6.12.0 certify), puts 2024-25 VAT 38% above + receipts on the Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1), and has + to be re-derived when the dataset it should match changes + (`docs/engineering/vat-coverage.md`). 2. While both are in use, CI runs the dataset-dependent tests on both. 3. The default is set per environment: for direct use of this package through `POLICYENGINE_UK_DEFAULT_DATASET`; for this repository's tests diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index eb09a738c6..3781d6cdfc 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -15,7 +15,7 @@ comparison uses `vat + private_school_vat`: theoretical liability; the rest falls on government, charities, VAT-exempt businesses and new housing. This is a published quantity and does not depend on the dataset. -- `gov.simulation.vat.survey_consumption_coverage` (0.67): the share of +- `gov.simulation.vat.survey_consumption_coverage` (0.69): the share of household VAT liability that the dataset's consumption captures. This depends on the dataset's consumption imputation and must be re-derived when the default dataset changes. @@ -32,28 +32,209 @@ Enhanced FRS 2024-25 (policyengine-uk-data 1.56.16). `gov.simulation.microdata_vat_coverage` is kept as a deprecated parameter so that existing reforms to it still work. Its value is now the product of the two -factors (0.469). If a reform sets it to any other value, `vat` and +factors (0.483). If a reform sets it to any other value, `vat` and `baseline_vat` use that value in place of the two factors (`policyengine_uk/utils/vat.py`); otherwise they use the two factors. New reforms should change the two factors instead. ## Which datasets the factor applies to -The survey coverage of 0.67 is calibrated to one dataset: the certified -Microcosm UK 2024-25 national release. It does not carry over to other -datasets, because their consumption imputations capture a different share of -household VAT. On Enhanced FRS 2024-25 it puts VAT 35% to 44% above receipts, -depending on the release and year (see the tables below). Re-derive the coverage, with -the calculation below, whenever the dataset it should match changes. +The survey coverage of 0.69 matches receipts on one dataset: the certified +Microcosm UK 2024-25 national release `a6299504`. It does not carry over to +other datasets, because their consumption imputations capture a different +share of household VAT. On Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1) +it puts total `vat` 32% to 38% above receipts from 2024-25 to 2026-27 (see +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026)). Re-derive +the coverage, with the calculation below, whenever the dataset it should match +changes. + +As of November 2026, policyengine.py certifies `a6299504`: its commit +`324ecad65` certified the cut, release 6.11.1 shipped it, and release 6.12.0 +still certifies it. The coverage comes from that cut, in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026). +policyengine.py certified `6cba5d71` before it, on 20 October 2026 (commit +`a66e883c4`). policyengine.py#715 proposed `a133f37a` and closed unmerged, so +no policyengine.py release certified that cut. Before the energy split, 0.67 +put VAT 7.5% to 10.3% above receipts on `a133f37a` (see +[Later Microcosm cuts](#later-microcosm-cuts-24-october-2026)). + +## Derivation on `a6299504` (November 2026) + +The parameter takes this derivation's value. The model figures in this section +come from runs on 3 November 2026 with policyengine-uk 2.157.1 (commit +`36bbbf42f`), policyengine-core 3.33.2, NumPy 2.5.4, pandas 3.1.0 and Python +3.14, with `HUGGING_FACE_TOKEN` and `POLICYENGINE_UK_DEFAULT_DATASET` unset. +That commit ships a coverage of 0.67; the runs at 0.69 set +`gov.simulation.vat.survey_consumption_coverage` to 0.69 in every year by +reform. -The Microcosm releases certified since `f9d1922c` record about 10% more -standard-rated consumption. Before the energy split, 0.67 put VAT 7.5% to -10.3% above receipts on them (see -[Later Microcosm cuts](#later-microcosm-cuts-24-october-2026)). The coverage -has not yet been re-derived on them with energy bills split out; that is -PolicyEngine/policyengine-uk#2633. +### Inputs + +| Input | Value | Source | +|---|---|---| +| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face dataset repository `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` (revision `994cac89d902af9a985af7c1182d74717a582ce0`), SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, 342,348,841 bytes; 78,234 households and 181,842 persons, 28.87 million weighted households in 2024-25 | Private repository | +| Build | `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, at Microcosm code pin `67ee2ddf3`, with policyengine-uk 2.151.0 | PolicyEngine/microcosm | +| Certification | policyengine.py commit `324ecad65` ("Certify the Microcosm UK national a6299504 with policyengine-uk 2.151.0", 26 October 2026, through policyengine.py#720); release 6.11.1 (27 October 2026) shipped it, and release 6.12.0 (2 November 2026) still certifies it | PolicyEngine/policyengine.py | +| VAT receipts | £170.994bn (2024-25 outturn), £180.169bn (2025-26), £187.704bn (2026-27) | OBR, Economic and fiscal outlook March 2026, detailed forecast tables: receipts, sheet "3.8 Current receipts (on a cash basis)", row 13 "Value added tax", cells D13, E13 and F13 (see below) | +| Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face model repository `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.58.1 (revision `2526edbbb687a3b54dbb11272e5389e4b62d2ac2`, SHA-256 `056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784`, 128,409,762 bytes), the file `policyengine_uk/tests/certified_dataset.yaml` names | Private repository | +| Household share of VAT liabilities | 0.7 | `household_share_of_receipts` | +| Ready reckoner | £8.8bn for one point on the standard rate, 2026-27 | HMRC, Direct effects of illustrative tax changes (June 2025) | +| Statutory rates | 20% standard, 5% reduced | `gov.hmrc.vat` | + +Microcosm's fit for this cut holds `obr.vat` out under a measure exclusion +(PolicyEngine/microcosm#1278) that runs from 24 October 2026 to 24 January +2027, so the fitted weights don't target the receipts that this derivation +compares VAT with. The cut's calibration diagnostics carry one warn-only VAT +warning, `uk_consumption_drift`: the total `full_rate_vat_base` moves by more +than 2% of its design value between the design and the final weights. + +OBR publishes the receipts workbook at +. +On 3 November 2026 that URL served 187,899 bytes with SHA-256 +`f65b6cb7f96931d4abd85940794eb60f6f65afca8bbf16c74a4548bf92766a28`, last +modified on 3 March 2026, and the +[Internet Archive's copy](https://web.archive.org/web/20261007143332/https://obr.uk/docs/d055fbf02d5b3g6jq8l2/efo-march-2026-detailed-forecast-tables-receipts.xlsx) +holds the same bytes. In sheet "3.8 Current receipts (on a cash basis)", row 13 +"Value added tax" gives, in £ billion, D13 = 170.994 (2024-25 outturn), +E13 = 180.16893868541794 (2025-26) and F13 = 187.70436725810845 (2026-27). The +sheet's note in row 66 reads "All tax lines prior to accruals adjustments on +tax line are on a cash basis". + +### Method + +The coverage follows the energy-split method (see +[Energy bills are not grossed up](#energy-bills-are-not-grossed-up)): + +```text +pre-scaling non-energy VAT = 0.20 x sum(full_rate_vat_consumption) + + 0.05 x sum(reduced_rate_vat_consumption) +energy-bill VAT = sum(domestic_energy_vat) +survey coverage = pre-scaling non-energy VAT + / (0.7 x (OBR VAT receipts - energy-bill VAT)) +``` + +Every sum weights households by `household_weight`. + +A run of the whole cut in one process needed more than the 8 GiB of memory +available, so the runs split it into ten household chunks. Chunk k holds every +tenth household from the k-th, by sorted `household_id`, with its persons and +benefit units. Each chunk runs `Microsimulation` on +`extend_single_year_dataset(chunk, parameters, end_year=2026)`. Each +household's VAT depends only on its own inputs: `full_rate_vat_consumption` +multiplies the household's `consumption` by its own +`full_rate_vat_expenditure_rate`, the uprating multiplies each household's +inputs by growth factors, and `vat` divides each household's statutory +liability by one scalar factor. So weighted VAT totals over the ten chunks sum +exactly to the whole cut's. As a check, for each VAT input and `vat` in +2024-25 to 2026-27, chunk 0 matched a separate run of the same households to +the last digit, and chunk 0's two halves summed to it within 2.7e-16 of the +total. + +### Results + +At the coverage of 0.67 that `36bbbf42f` ships: + +| Fiscal year | Standard-rated base | Reduced-rated base | Pre-scaling non-energy VAT | Energy-bill VAT | OBR VAT receipts | Coverage | Total `vat` at 0.67 | Against receipts | +|---|---|---|---|---|---|---|---|---| +| 2024-25 | £407.40bn | £0.00bn | £81.48bn | £2.33bn | £170.994bn (outturn) | 0.690 | £176.06bn | +3.0% | +| 2025-26 | £422.51bn | £0.00bn | £84.50bn | £2.42bn | £180.169bn | 0.679 | £181.09bn | +0.5% | +| 2026-27 | £433.75bn | £0.00bn | £86.75bn | £1.75bn | £187.704bn | 0.666 | £185.18bn | -1.3% | + +Unrounded (£): standard-rated base 407,399,379,048, 422,514,711,572 and +433,745,364,638; energy-bill VAT 2,328,998,935, 2,415,410,320 and +1,749,888,519; pre-scaling non-energy VAT 81,479,875,810, 84,502,942,314 and +86,749,072,928; total `vat` at 0.67 176,060,075,948, 181,087,241,846 and +185,178,679,581. Coverage: 0.690124, 0.679134 and 0.666436. The run read +private school fees' share of receipts as 0, 0.008353 and 0.008311. + +### Value + +The parameter takes 0.69 (product 0.483), the 2024-25 value: as in the +[`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026), the +latest outturn year sets it. Receipts for later years come from OBR forecasts, +so the factor stays undated. The coverage falls 3.4% from 2024-25 to 2026-27. + +### Totals at 0.67 and 0.69 + +| Dataset | Fiscal year | Total `vat` at 0.67 | Against receipts | Total `vat` at 0.69 | Against receipts | +|---|---|---|---|---|---| +| Microcosm UK 2024-25 (`a6299504`) | 2024-25 | £176.06bn | +3.0% | £171.02bn | +0.02% | +| Microcosm UK 2024-25 (`a6299504`) | 2025-26 | £181.09bn | +0.5% | £175.91bn | -2.4% | +| Microcosm UK 2024-25 (`a6299504`) | 2026-27 | £185.18bn | -1.3% | £179.86bn | -4.2% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2024-25 | £242.54bn | +41.8% | £235.58bn | +37.8% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2025-26 | £249.37bn | +38.4% | £242.22bn | +34.4% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2026-27 | £255.28bn | +36.0% | £247.93bn | +32.1% | + +Unrounded Microcosm totals at 0.69 (£): 171,024,393,280, 175,908,348,963 and +179,861,903,668. The Enhanced FRS run used the whole file in one process. On +that file the same calculation gives a coverage of 0.954, 0.939 and 0.921, from +a standard-rated base of £563.06bn, £583.95bn and £599.47bn, a reduced-rated +base of £0.00bn, and energy-bill VAT of £2.43bn, £2.43bn and £1.76bn. + +Total `vat` leaves out `private_school_vat`, which the receipts include: about +£1.5bn a year from 2025-26 on HMRC's costing (see +[Private school VAT](#private-school-vat-splitting-the-existing-receipts-total)). +Model year 2024 has no school charge. + +### Bound + +On `a6299504` two routes bound the coverage, at a household share of 0.7: + +- receipts across 2024-25 to 2026-27 give 0.666 to 0.690; +- the ready reckoner, which does not use the level of receipts: one point on + the standard rate raises £4.34bn before scaling in 2026-27 (1% of + £433.75bn), so matching HMRC's £8.8bn needs a product of 0.493 (coverage + 0.704). + +So the coverage lies between 0.666 and 0.704, and 0.69 sits inside that range. +At 0.69, a run that sets the standard rate to 21% for 2026-27 alone raises +total `vat` in that year from £179.862bn to £188.767bn, by £8.906bn, exceeding +the ready reckoner by 1.2%. That rise matches 1% of the 2026-27 standard-rated +base, times one minus the school share, over the product: +£4,337,453,646 x (1 - 0.008311) / 0.483 = £8,905,599,522, within £7,934 of the +run's £8,905,591,588. + +### Reproducing + +The datasets sit in private Hugging Face repositories. Anyone with +PolicyEngine access can rerun the calculation; anyone else can check the +arithmetic from the published aggregates above. + +```python +from huggingface_hub import hf_hub_download +from policyengine_uk import Microsimulation + +path = hf_hub_download( + "policyengine/populace-uk-private", + "microcosm_uk_2024_25.h5", + repo_type="dataset", + revision="994cac89d902af9a985af7c1182d74717a582ce0", +) +sim = Microsimulation(dataset=path) +# OBR March 2026 EFO, table 3.8, cash-basis VAT receipts +receipts = {2024: 170.994e9, 2025: 180.169e9, 2026: 187.704e9} +for year, obr in receipts.items(): + p = sim.tax_benefit_system.parameters(f"{year}-01-01").gov.hmrc.vat + full = sim.calculate("full_rate_vat_consumption", year).sum() + reduced = sim.calculate("reduced_rate_vat_consumption", year).sum() + energy = sim.calculate("domestic_energy_vat", year).sum() + raw = full * p.standard_rate + reduced * p.reduced_rate + print(year, raw / 1e9, energy / 1e9, raw / (0.7 * (obr - energy))) +``` + +A run of the whole cut in one process needed more than 8 GiB of memory, so the +runs above summed ten household chunks (see [Method](#method)). + +For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository +`policyengine/policyengine-uk-data-private` at policyengine-uk-data 1.58.1 +(`revision="2526edbbb687a3b54dbb11272e5389e4b62d2ac2"`). For an earlier cut, +set `revision` to the one its inputs table gives. + +## Earlier derivation on `f9d1922c` (October 2026) -## Derivation (October 2026) +This section records the October 2026 derivation on `f9d1922c`, which set the +coverage at 0.67 before the energy split. The +[`a6299504` derivation](#derivation-on-a6299504-november-2026) replaces it. ### Inputs @@ -87,9 +268,11 @@ equal to it: combined receipts are `(1 - s) * sum(w * G) + sum(w * E) + sum(w * P0)` (see the receipt-partition derivation below), so they differ from `G + E` by `sum(w * P0) - s * sum(w * G)`. On the certified Enhanced FRS -(policyengine-uk-data 1.58.1) that difference is -£5.5m to -£142.5m a year from -2025-26 to 2031-32, at most 0.06% of VAT (Aggregate impact run -[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)). +(policyengine-uk-data 1.58.1) at 0.67 that difference came to -£5.5m to +-£142.5m a year from 2025-26 to 2031-32, at most 0.06% of VAT (Aggregate impact +run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457), +24 October 2026). ### Results @@ -105,14 +288,14 @@ derivation below), so they differ from `G + E` by | Enhanced FRS 2024-25, uk-data 1.58.0 | 2025-26 | £593.92bn | £34.54bn | £120.51bn | £180.2bn | 0.669 | 0.956 | | Enhanced FRS 2024-25, uk-data 1.58.0 | 2026-27 | £609.89bn | £35.47bn | £123.75bn | £187.7bn | 0.659 | 0.942 | -The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the +The parameter took the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. With that product, the gross amount against receipts was as below, before energy bills were split out. On the certified Enhanced FRS -(policyengine-uk-data 1.58.1), current-law `vat + private_school_vat` is -within 0.06% of the receipt-calibrated `G + E` (see above); the other rows -weren't checked against that bound: +(policyengine-uk-data 1.58.1) at 0.67, current-law `vat + private_school_vat` +came within 0.06% of the receipt-calibrated `G + E` (see above); no run +checked the other rows against that bound: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| @@ -124,51 +307,30 @@ weren't checked against that bound: Total VAT depends only on the product, so the household share being a rounded "around 70%" changes how the product is split between the two factors, -not the totals. On Microcosm the product is bounded by: +not the totals. On `f9d1922c` two routes bounded the product: - the receipts route across 2024-25 to 2026-27: 0.459 to 0.471 (coverage 0.655 to 0.672); - the ready reckoner, which does not use the level of receipts: one point on - the standard rate raises £4.24bn before scaling in 2026-27 (1% of - £423.66bn), so matching HMRC's £8.8bn needs a product of 0.481 (coverage + the standard rate raised £4.24bn before scaling in 2026-27 (1% of + £423.66bn), so matching HMRC's £8.8bn needed a product of 0.481 (coverage 0.688). -So on Microcosm the product lies between 0.459 and 0.481, and the coverage -between 0.655 and 0.688 at a household share of 0.7. The value used, 0.469 -(0.67), sits inside that range, and the totals it gives are within 2.2% of -receipts from 2024-25 to 2026-27. The implied product moves by less than 3% -over those years, so the factors are not dated by year. +So on `f9d1922c` the product lay between 0.459 and 0.481, and the coverage +between 0.655 and 0.688 at a household share of 0.7. The value then used, +0.469 (0.67), sat inside that range, and the totals it gave came within 2.2% of +receipts from 2024-25 to 2026-27. The implied product moved by less than 3% +over those years. ### Reproducing -The two datasets are in private Hugging Face repositories. Anyone with -PolicyEngine access can rerun the calculation; anyone else can check the -arithmetic from the published aggregates in the results table. - -```python -from huggingface_hub import hf_hub_download -from policyengine_uk import Microsimulation - -path = hf_hub_download( - "policyengine/populace-uk-private", - "microcosm_uk_2024_25.h5", - repo_type="dataset", - revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88", -) -sim = Microsimulation(dataset=path) -# OBR March 2026 EFO, table 3.8, cash-basis VAT receipts -receipts = {2024: 170.994e9, 2025: 180.169e9, 2026: 187.704e9} -for year, obr in receipts.items(): - p = sim.tax_benefit_system.parameters(f"{year}-01-01").gov.hmrc.vat - full = sim.calculate("full_rate_vat_consumption", year).sum() - reduced = sim.calculate("reduced_rate_vat_consumption", year).sum() - raw = full * p.standard_rate + reduced * p.reduced_rate - print(year, raw / 1e9, raw / obr, raw / obr / 0.7) -``` - -For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository -`policyengine/policyengine-uk-data-private` at the release tag (for example -`revision="1.58.0"`). +Run the snippet in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026) with +`revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88"`. The pre-split rows +above came from policyengine-uk as of #2187, which divided pre-scaling VAT, +energy bills included, by the receipts themselves (`raw / obr / 0.7`). For +Enhanced FRS 1.56.16 or 1.58.0, download `enhanced_frs_2024_25.h5` at that +release tag (for example `revision="1.58.0"`). ## Energy bills are not grossed up @@ -201,7 +363,8 @@ actually pay on it. With energy split out, the coverage is derived against receipts net of energy VAT: coverage = pre-scaling non-energy VAT / (0.7 x (receipts - energy VAT)). -On the same inputs as the derivation above: +On the same inputs as the +[`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026): | Dataset | Year | Pre-scaling non-energy VAT | Energy-bill VAT | OBR VAT receipts | Survey coverage | |---|---|---|---|---|---| @@ -213,15 +376,19 @@ On the same inputs as the derivation above: The pre-scaling non-energy VAT is the standard-rated part alone, since the generic reduced-rate base is empty once domestic fuel and power is taken out. -The Microcosm 2024-25 value rounds to the same 0.67 as the derivation above, -so the parameter is unchanged, and the bound above still holds (0.650 to 0.670 -by receipts, 0.688 by the ready reckoner). With it, total `vat` on Microcosm -is within 0.1% of 2024-25 receipts and 3.0% below in 2026-27; on Enhanced FRS -it is 38% (1.56.16) or 44% (1.58.0) above 2024-25 receipts. - -To reproduce, run the snippet above on this version and subtract -`sim.calculate("domestic_energy_vat", year).sum()` from the receipts before -dividing. +On `f9d1922c` the 2024-25 value rounded to the same 0.67 as the pre-split +derivation, so the energy split left the parameter at 0.67, and the `f9d1922c` +bound still held (0.650 to 0.670 by receipts, 0.688 by the ready reckoner). +With 0.67, total `vat` on `f9d1922c` came within 0.1% of 2024-25 receipts and +fell 3.0% below them in 2026-27; on Enhanced FRS it exceeded 2024-25 receipts +by 38% (1.56.16) or 44% (1.58.0). The +[`a6299504` derivation](#derivation-on-a6299504-november-2026) sets the +current value. + +To reproduce these rows, run the snippet in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026), which +subtracts `sim.calculate("domestic_energy_vat", year).sum()` from the receipts +before dividing, with each dataset's revision. ## Private school VAT: splitting the existing receipts total @@ -231,8 +398,8 @@ The OBR VAT total already includes VAT on private school fees. HMRC's £1,505m in 2025-26, £1,560m in 2026-27, £1,610m in 2027-28, £1,665m in 2028-29 and £1,725m in 2029-30. Adding a separate school VAT head to the gross amount above would count that receipt twice. The model instead takes -the fees' share of receipts out of the gross amount, keeping the survey -coverage at 0.67. +the fees' share of receipts out of the gross amount, which leaves the survey +coverage unchanged. For each household, let `G` be the grossed-up VAT on recorded consumption other than energy bills (the statutory liability divided by the two factors), @@ -312,24 +479,29 @@ that coincidence likelier. A household situation would also measure its own school charge against its own VAT. The official share keeps each household's VAT a function of its own consumption. -Measured on the certified Enhanced FRS (Aggregate impact run -[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)), -the official share changes current-law combined receipts by -£0.14bn in -2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in 2028-29 and --£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two overstatements +Measured on the certified Enhanced FRS at a coverage of 0.67 (Aggregate impact +run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457), +24 October 2026), the official share changes current-law combined receipts by +-£0.14bn in 2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in +2028-29 and -£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two overstatements nearly offset there: VAT is 40% above OBR receipts (£252.5bn against £180.2bn in 2025-26), and the modelled school charge is £1.95bn, against HMRC's £1.505bn, because attendance is imputed for 751,000 weighted pupils (policyengine-uk-data issue 568). The difference belongs to those two calibrations, not to the split. -The receipts check for the coverage factor is unchanged. Model year 2024 has -no school charge and a zero share, so, using the rounded 2024-25 Microcosm -inputs in the energy table above, `79.23 / (0.67 * 0.7) + 1.99 = £170.924bn`, -compared with OBR's £170.994bn: about 0.04% below receipts. The existing -differences from receipts, including Enhanced FRS's larger overstatement, still require -dataset-specific calibration. Aggregate impact CI compares the combined VAT -report group against the PR's base. +The split leaves the receipts check for the coverage factor unchanged. Model +year 2024 has no school charge and a zero share, so, using the rounded 2024-25 +inputs for `a6299504`, `81.48 / (0.69 * 0.7) + 2.33 = £171.026bn`, against +OBR's £170.994bn: 0.02% above receipts. The run at 0.69 gives £171.02bn on the +unrounded inputs (see +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026)). With 0.67, +the rounded `f9d1922c` inputs gave `79.23 / (0.67 * 0.7) + 1.99 = £170.924bn`, +about 0.04% below receipts. The existing differences from receipts, including +Enhanced FRS's larger overstatement, still require dataset-specific +calibration. Aggregate impact CI compares the combined VAT report group +against the PR's base. Model year 2024 represents 2024-25 but reads annual policy before the January 2025 commencement, so `P0` and the share are both zero for that @@ -339,8 +511,9 @@ VAT; this change does not model its separate timing. ## Later Microcosm cuts (24 October 2026) These runs predate the energy split above, so they use the formula in -[Derivation](#derivation-october-2026). They record why the coverage has to be -re-derived (PolicyEngine/policyengine-uk#2633). They do not set the parameter. +[the `f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026). +They record why the coverage has to be re-derived +(PolicyEngine/policyengine-uk#2633). They do not set the parameter. | Input | Value | |---|---| From 31306e57c94ff7c3c0800ef8ea2d6772fcfa8544 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:51:37 +0000 Subject: [PATCH 121/168] Put the VAT property docstring and added fragment in the active voice Use the proposed wording for the property module's docstring, take "times one minus" for the school share in the added fragment, and drop the fragment's claims that the cuts were certified since f9d1922c and that the value waits for re-derivation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHapmX36bpbh3SMixXg --- changelog.d/2633.added.md | 2 +- policyengine_uk/tests/test_vat_grossing_properties.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog.d/2633.added.md b/changelog.d/2633.added.md index 1ada80c7d3..abeb087d0d 100644 --- a/changelog.d/2633.added.md +++ b/changelog.d/2633.added.md @@ -1 +1 @@ -Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, less private school fees' share of VAT receipts, on a vector of households), a test that 2024 is grossed by the same product as later years, and a record of how the shipped coverage performs on the Microcosm UK cuts certified since `f9d1922c`. The coverage value is unchanged pending re-derivation on the next certified cut (#2633). +Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, times one minus private school fees' share of VAT receipts, on a vector of households), a test that the model grosses 2024 by the same product as later years, and a record of how the coverage of 0.67 performed on the later Microcosm UK cuts `6cba5d71` and `a133f37a` (#2633). diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py index 654d5bdfcb..4a9dfa5f13 100644 --- a/policyengine_uk/tests/test_vat_grossing_properties.py +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -2,9 +2,10 @@ `vat` is the statutory liability on recorded consumption divided by one grossing factor, times one minus private school fees' share of VAT receipts, -plus VAT on energy bills, which is not grossed up. The factor is the survey -coverage times the household share of receipts, unless a reform moves the -deprecated combined parameter, in which case that value is used. +plus VAT on energy bills, which the formula adds without grossing up. The +factor is the survey coverage times the household share of receipts, unless a +reform moves the deprecated combined parameter, in which case the formula uses +that value. """ from types import SimpleNamespace From 303e6585224cd6aa79e65e83059223681c1ec7e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:37:06 +0000 Subject: [PATCH 122/168] Correct the VAT doc's arithmetic check and tighten its wording The one-point check 4,337,453,646 x (1 - 0.008311) / 0.483 comes to 8,905,600,556, within 8,968 of the run, and the six-decimal 2026-27 coverage did not follow from the published inputs, so the doc keeps the three-decimal values. Date the 0.38 factor's and run 40116746457's figures, qualify the ready reckoner's 0.704 as leaving out the school share, say that baseline_vat keeps the two factors under a reform to the deprecated one, and attribute the pre-split division by receipts to the calculation rather than the model. Say that 0.69 matches 2024-25 receipts, show the 2025 YAML case's full computation, and name the cuts the added fragment's record covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHapmX36bpbh3SMixXg --- changelog.d/2633.added.md | 2 +- docs/book/assumptions/mp30k-uk-switch-plan.md | 12 +-- docs/engineering/vat-coverage.md | 87 ++++++++++--------- .../vat/survey_consumption_coverage.yaml | 16 ++-- .../baseline/gov/hmrc/vat/vat_grossing.yaml | 5 +- 5 files changed, 64 insertions(+), 58 deletions(-) diff --git a/changelog.d/2633.added.md b/changelog.d/2633.added.md index abeb087d0d..0f32cdb04c 100644 --- a/changelog.d/2633.added.md +++ b/changelog.d/2633.added.md @@ -1 +1 @@ -Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, times one minus private school fees' share of VAT receipts, on a vector of households), a test that the model grosses 2024 by the same product as later years, and a record of how the coverage of 0.67 performed on the later Microcosm UK cuts `6cba5d71` and `a133f37a` (#2633). +Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, times one minus private school fees' share of VAT receipts, on a vector of households), a test that the model grosses 2024 by the same product as later years, and a record of how the coverage of 0.67 performed on the Microcosm UK cuts `6cba5d71` and `a133f37a`, which followed `f9d1922c` (#2633). diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md index 90a16f7cb7..bec4a26a15 100644 --- a/docs/book/assumptions/mp30k-uk-switch-plan.md +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -115,13 +115,13 @@ A documented gate must pass before any environment's default changes: 1. Dataset choice stays a runtime setting: no code in this repo branches on which dataset is loaded. One parameter is tied to the dataset, though: - `gov.simulation.vat.survey_consumption_coverage` matches receipts on one - Microcosm UK 2024-25 national cut only + `gov.simulation.vat.survey_consumption_coverage` matches 2024-25 receipts + on one Microcosm UK 2024-25 national cut only (`microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, which - policyengine.py 6.11.1 and 6.12.0 certify), puts 2024-25 VAT 38% above - receipts on the Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1), and has - to be re-derived when the dataset it should match changes - (`docs/engineering/vat-coverage.md`). + policyengine.py 6.11.1 and 6.12.0 certify), and puts 2024-25 VAT 38% above + receipts on the Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1). + Re-derive it, with `docs/engineering/vat-coverage.md`, whenever the dataset + it should match changes. 2. While both are in use, CI runs the dataset-dependent tests on both. 3. The default is set per environment: for direct use of this package through `POLICYENGINE_UK_DEFAULT_DATASET`; for this repository's tests diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 3781d6cdfc..366c2542ad 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -26,24 +26,26 @@ to households in proportion to their own VAT-liable spending. These two factors replace `gov.simulation.microdata_vat_coverage`, a single undated 0.38 added in January 2023 against an older LCFS-based consumption -imputation, with no derivation recorded. Its product with today's datasets put -2024-25 VAT 24% above OBR receipts on Microcosm UK 2024-25 and 71% above on -Enhanced FRS 2024-25 (policyengine-uk-data 1.56.16). +imputation, with no derivation recorded. Its product with the October 2026 +datasets put 2024-25 VAT 24% above OBR receipts on Microcosm UK 2024-25 +(`f9d1922c`) and 71% above on Enhanced FRS 2024-25 (policyengine-uk-data +1.56.16). `gov.simulation.microdata_vat_coverage` is kept as a deprecated parameter so that existing reforms to it still work. Its value is now the product of the two -factors (0.483). If a reform sets it to any other value, `vat` and -`baseline_vat` use that value in place of the two factors -(`policyengine_uk/utils/vat.py`); otherwise they use the two factors. New -reforms should change the two factors instead. +factors (0.483). If a reform sets it to any other value, `vat` uses that value +in place of the two factors (`policyengine_uk/utils/vat.py`), and +`baseline_vat`, which reads the unreformed parameters, keeps the two factors. +New reforms should change the two factors instead. ## Which datasets the factor applies to -The survey coverage of 0.69 matches receipts on one dataset: the certified -Microcosm UK 2024-25 national release `a6299504`. It does not carry over to -other datasets, because their consumption imputations capture a different -share of household VAT. On Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1) -it puts total `vat` 32% to 38% above receipts from 2024-25 to 2026-27 (see +The survey coverage of 0.69 matches 2024-25 receipts on one dataset: the +certified Microcosm UK 2024-25 national release `a6299504`. It does not carry +over to other datasets, because their consumption imputations capture a +different share of household VAT. On Enhanced FRS 2024-25 +(policyengine-uk-data 1.58.1) it puts total `vat` 38% above receipts in +2024-25, falling to 32% in 2026-27 (see [Derivation on `a6299504`](#derivation-on-a6299504-november-2026)). Re-derive the coverage, with the calculation below, whenever the dataset it should match changes. @@ -76,17 +78,18 @@ reform. | Build | `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, at Microcosm code pin `67ee2ddf3`, with policyengine-uk 2.151.0 | PolicyEngine/microcosm | | Certification | policyengine.py commit `324ecad65` ("Certify the Microcosm UK national a6299504 with policyengine-uk 2.151.0", 26 October 2026, through policyengine.py#720); release 6.11.1 (27 October 2026) shipped it, and release 6.12.0 (2 November 2026) still certifies it | PolicyEngine/policyengine.py | | VAT receipts | £170.994bn (2024-25 outturn), £180.169bn (2025-26), £187.704bn (2026-27) | OBR, Economic and fiscal outlook March 2026, detailed forecast tables: receipts, sheet "3.8 Current receipts (on a cash basis)", row 13 "Value added tax", cells D13, E13 and F13 (see below) | -| Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face model repository `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.58.1 (revision `2526edbbb687a3b54dbb11272e5389e4b62d2ac2`, SHA-256 `056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784`, 128,409,762 bytes), the file `policyengine_uk/tests/certified_dataset.yaml` names | Private repository | +| Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face model repository `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.58.1 (revision `2526edbbb687a3b54dbb11272e5389e4b62d2ac2`, SHA-256 `056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784`, 128,409,762 bytes), the release that `policyengine_uk/tests/certified_dataset.yaml` names | Private repository | | Household share of VAT liabilities | 0.7 | `household_share_of_receipts` | | Ready reckoner | £8.8bn for one point on the standard rate, 2026-27 | HMRC, Direct effects of illustrative tax changes (June 2025) | | Statutory rates | 20% standard, 5% reduced | `gov.hmrc.vat` | Microcosm's fit for this cut holds `obr.vat` out under a measure exclusion -(PolicyEngine/microcosm#1278) that runs from 24 October 2026 to 24 January -2027, so the fitted weights don't target the receipts that this derivation -compares VAT with. The cut's calibration diagnostics carry one warn-only VAT -warning, `uk_consumption_drift`: the total `full_rate_vat_base` moves by more -than 2% of its design value between the design and the final weights. +(PolicyEngine/microcosm#1278) that a Microcosm maintainer approved on +24 October 2026 and that expires on 24 January 2027, so the fitted weights +don't target the receipts that this derivation compares VAT with. The cut's +calibration diagnostics carry one warn-only VAT warning, +`uk_consumption_drift`: the total `full_rate_vat_base` moves by more than 2% +of its design value between the design and the final weights. OBR publishes the receipts workbook at . @@ -102,8 +105,8 @@ tax line are on a cash basis". ### Method -The coverage follows the energy-split method (see -[Energy bills are not grossed up](#energy-bills-are-not-grossed-up)): +The coverage follows the energy-split method in +[the energy section](#energy-bills-are-not-grossed-up): ```text pre-scaling non-energy VAT = 0.20 x sum(full_rate_vat_consumption) @@ -144,8 +147,8 @@ Unrounded (£): standard-rated base 407,399,379,048, 422,514,711,572 and 433,745,364,638; energy-bill VAT 2,328,998,935, 2,415,410,320 and 1,749,888,519; pre-scaling non-energy VAT 81,479,875,810, 84,502,942,314 and 86,749,072,928; total `vat` at 0.67 176,060,075,948, 181,087,241,846 and -185,178,679,581. Coverage: 0.690124, 0.679134 and 0.666436. The run read -private school fees' share of receipts as 0, 0.008353 and 0.008311. +185,178,679,581. The run read private school fees' share of receipts as 0, +0.008353 and 0.008311. ### Value @@ -183,15 +186,15 @@ On `a6299504` two routes bound the coverage, at a household share of 0.7: - receipts across 2024-25 to 2026-27 give 0.666 to 0.690; - the ready reckoner, which does not use the level of receipts: one point on the standard rate raises £4.34bn before scaling in 2026-27 (1% of - £433.75bn), so matching HMRC's £8.8bn needs a product of 0.493 (coverage - 0.704). + £433.75bn), so, leaving out the school share, matching HMRC's £8.8bn needs + a product of 0.493 (coverage 0.704). So the coverage lies between 0.666 and 0.704, and 0.69 sits inside that range. At 0.69, a run that sets the standard rate to 21% for 2026-27 alone raises total `vat` in that year from £179.862bn to £188.767bn, by £8.906bn, exceeding the ready reckoner by 1.2%. That rise matches 1% of the 2026-27 standard-rated base, times one minus the school share, over the product: -£4,337,453,646 x (1 - 0.008311) / 0.483 = £8,905,599,522, within £7,934 of the +£4,337,453,646 x (1 - 0.008311) / 0.483 = £8,905,600,556, within £8,968 of the run's £8,905,591,588. ### Reproducing @@ -228,7 +231,7 @@ runs above summed ten household chunks (see [Method](#method)). For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository `policyengine/policyengine-uk-data-private` at policyengine-uk-data 1.58.1 (`revision="2526edbbb687a3b54dbb11272e5389e4b62d2ac2"`). For an earlier cut, -set `revision` to the one its inputs table gives. +set `revision` to the tag or full revision that its inputs table gives. ## Earlier derivation on `f9d1922c` (October 2026) @@ -326,11 +329,12 @@ over those years. Run the snippet in [Derivation on `a6299504`](#derivation-on-a6299504-november-2026) with -`revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88"`. The pre-split rows -above came from policyengine-uk as of #2187, which divided pre-scaling VAT, -energy bills included, by the receipts themselves (`raw / obr / 0.7`). For -Enhanced FRS 1.56.16 or 1.58.0, download `enhanced_frs_2024_25.h5` at that -release tag (for example `revision="1.58.0"`). +`revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88"`. For the pre-split rows +above, run it on policyengine-uk as of #2187, before energy bills left +`reduced_rate_vat_consumption`, and divide pre-scaling VAT by the receipts +themselves (`raw / obr / 0.7`). For Enhanced FRS 1.56.16 or 1.58.0, download +`enhanced_frs_2024_25.h5` at that release tag (for example +`revision="1.58.0"`). ## Energy bills are not grossed up @@ -386,9 +390,11 @@ by 38% (1.56.16) or 44% (1.58.0). The current value. To reproduce these rows, run the snippet in -[Derivation on `a6299504`](#derivation-on-a6299504-november-2026), which -subtracts `sim.calculate("domestic_energy_vat", year).sum()` from the receipts -before dividing, with each dataset's revision. +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026) with each +dataset's revision, and for Enhanced FRS with the repository and file that +section gives. The snippet subtracts +`sim.calculate("domestic_energy_vat", year).sum()` from the receipts before +dividing. ## Private school VAT: splitting the existing receipts total @@ -484,10 +490,10 @@ run [40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457), 24 October 2026), the official share changes current-law combined receipts by -£0.14bn in 2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in -2028-29 and -£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two overstatements -nearly offset there: VAT is 40% above OBR receipts (£252.5bn against -£180.2bn in 2025-26), and the modelled school charge is £1.95bn, against -HMRC's £1.505bn, because +2028-29 and -£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two +overstatements nearly offset in that run: VAT exceeded OBR receipts by 40% +(£252.5bn against £180.2bn in 2025-26), and the modelled school charge came to +£1.95bn, against HMRC's £1.505bn, because attendance is imputed for 751,000 weighted pupils (policyengine-uk-data issue 568). The difference belongs to those two calibrations, not to the split. @@ -511,9 +517,8 @@ VAT; this change does not model its separate timing. ## Later Microcosm cuts (24 October 2026) These runs predate the energy split above, so they use the formula in -[the `f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026). -They record why the coverage has to be re-derived -(PolicyEngine/policyengine-uk#2633). They do not set the parameter. +[the `f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026). They record why the coverage has to be +re-derived (PolicyEngine/policyengine-uk#2633). They do not set the parameter. | Input | Value | |---|---| diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 46396cc79f..6eb57aaf5d 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -16,16 +16,16 @@ metadata: leaves the generic reduced-rate base empty), and VAT on energy bills comes to £2.33bn. OBR VAT receipts for 2024-25 (outturn, March 2026 EFO table 3.8, cash basis) come to £170.994bn. Coverage = 81.48 / (0.7 x (170.994 - - 2.33)) = 0.690. Across 2024-25 to 2026-27 the coverage runs from 0.666 to - 0.690, and the HMRC ready reckoner (£8.8bn for one point on the standard - rate in 2026-27) implies 0.704. On Enhanced FRS 2024-25 - (policyengine-uk-data 1.58.1) the same calculation gives 0.954 for - 2024-25, so this value puts total VAT on that file 38% above 2024-25 + 2.33)) = 0.690. From 2024-25 to 2026-27 the coverage falls from 0.690 to + 0.666, and the HMRC ready reckoner (£8.8bn for one point on the standard + rate in 2026-27) implies 0.704, leaving out the school share. On Enhanced + FRS 2024-25 (policyengine-uk-data 1.58.1) the same calculation gives 0.954 + for 2024-25, so this value puts total VAT on that file 38% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists the inputs, file hashes and - per-dataset results. These receipts already include private school VAT. - The vat formula therefore removes school-fee VAT's share of receipts from - the grossed-up amount (not from energy-bill VAT) before gov_tax adds + per-dataset results. These receipts already include private school VAT. The + vat formula therefore removes school-fee VAT's share of receipts from the + grossed-up amount (not from energy-bill VAT) before gov_tax adds private_school_vat. At current law on Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1), the two heads sum to the receipt-calibrated VAT to within the share's accuracy (at most 0.06% below it), so no extra diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index 2fc324c608..208fed08a1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -6,8 +6,9 @@ reduced_rate_vat_consumption: 1_000 output: # (10,000 x 20% + 1,000 x 5%) / (0.69 x 0.7) = 2,050 / 0.483 = 4,244.31, - # less private school fees' official share of 2025-26 VAT receipts - # (0.008353), which private_school_vat counts separately. + # times one minus private school fees' official share of 2025-26 VAT + # receipts (0.008353), which private_school_vat counts separately: + # 2,050 / 0.483 x (1 - 0.008353) = 4,208.85. vat: 4_208.85 baseline_vat: 4_208.85 From 538c4d614aa41ed6f14c290c1713fe6aaf9b775a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:01:38 +0000 Subject: [PATCH 123/168] Pay carer benefits on hours only for care of a severely disabled person SSCBA 1992 s.70(1) and (2) give Carer's Allowance only for caring for a severely disabled person, SSI 2023/302 reg 5(1) pays Carer Support Payment only for care of a person to whom a qualifying disability benefit is normally payable, and UC Regs 2013 reg 30(1)(a) applies the Carer's Allowance conditions to the carer element. The model tested caring hours alone. Add the person input cares_for_severely_disabled_person, which defaults to true because survey caring hours don't say whom the carer cares for. The hours route in carers_allowance_weekly_by_part_of_year, carer_support_payment_pre_couple_limit, is_carer_for_benefits and the explained_by_hours test of partners_care_for_same_severely_disabled_person now needs it. Reported awards still count. Fixes #2310 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2310.fixed.md | 1 + .../carers_allowance_cared_for_person.yaml | 400 ++++++++++++++++++ ...for_severely_disabled_person_properties.py | 382 +++++++++++++++++ .../utils/carers_allowance_taper.py | 6 +- .../dwp/cares_for_severely_disabled_person.py | 59 +++ .../uc_unable_to_provide_childcare.py | 10 +- .../meets_uc_minimum_age_condition.py | 5 +- .../carer_support_payment_pre_couple_limit.py | 8 +- .../demographic/is_carer_for_benefits.py | 29 +- ..._care_for_same_severely_disabled_person.py | 31 +- 10 files changed, 904 insertions(+), 27 deletions(-) create mode 100644 changelog.d/2310.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml create mode 100644 policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py diff --git a/changelog.d/2310.fixed.md b/changelog.d/2310.fixed.md new file mode 100644 index 0000000000..4a3bda8989 --- /dev/null +++ b/changelog.d/2310.fixed.md @@ -0,0 +1 @@ +Pay Carer's Allowance and Carer Support Payment, and count a carer for the Universal Credit carer element, on caring hours only where the new input `cares_for_severely_disabled_person` says the person cared for gets a qualifying disability benefit; it defaults to true, so results change only where a calculation sets it false. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml new file mode 100644 index 0000000000..f676012ff8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml @@ -0,0 +1,400 @@ +# Carer benefits for caring for a severely disabled person. +# SSCBA 1992 s.70(1): "A person shall be entitled to a carer's allowance for +# any day on which he is engaged in caring for a severely disabled person". +# s.70(2) defines a "severely disabled person" by the benefit payable for them: +# attendance allowance, the disability living allowance care component at the +# highest or middle rate, the personal independence payment daily living +# component, armed forces independence payment, the Scottish disability +# payments it lists, or a payment that SI 1976/409 reg 3 prescribes. +# SSI 2023/302 reg 5(1): "Carer Support Payment may only be paid to an +# individual in respect of a period during which that individual provides +# regular and substantial care to a person to whom a qualifying disability +# benefit is normally payable". UC Regs 2013 reg 30(1)(a) gives the carer +# element to a person who "satisfy the conditions for entitlement to a +# carer's allowance", or would but for the earnings limit or residence in +# Scotland. +# +# cares_for_severely_disabled_person records whether the person cared for +# gets such a benefit. It defaults to true. False removes the route through +# caring hours; a reported award (carers_allowance_reported) still counts. +# +# The model reads each parameter at 30 April of the simulation year and +# counts 52 weeks or 12 months in a year. For 2026: +# - Carer's Allowance (gov.dwp.carers_allowance.rate, from 2026-04-01): +# 86.45 a week, 86.45 * 52 = 4,495.40; +# - Carer Support Payment (gov.social_security_scotland.carer_support_payment +# .rate, from 2026-04-05): 86.45 a week, 86.45 * 52 = 4,495.40; +# - Scottish Carer Supplement (gov.social_security_scotland +# .carer_support_payment.supplement, from 2026-04-05): 11.70 a week, +# 11.70 * 52 = 608.40; +# - Universal Credit carer element (gov.dwp.universal_credit.elements.carer +# .amount, from 2026-04-01): 209.34 a month, 209.34 * 12 = 2,512.08; +# - Universal Credit standard allowance for a single claimant aged 25 or over +# (gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD, from +# 2026-04-01): 424.90 a month, 424.90 * 12 = 5,098.80; +# - Pension Credit carer addition (gov.dwp.pension_credit.guarantee_credit +# .carer.addition, from 2026-04-01) and the legacy carer premium +# (gov.dwp.carer_premium.single, from 2026-04-01): 48.15 a week each, +# 48.15 * 52 = 2,503.80. +# The minimum caring hours are 35 a week for both benefits +# (gov.dwp.carers_allowance.min_hours and +# gov.social_security_scotland.carer_support_payment.min_hours), so 40 hours +# meet them. + +- name: England carer of a partner with no qualifying disability benefit gets no Carer's Allowance + # The carer cares 40 hours a week for their partner, who has no s.70(2) + # benefit, so the partner is not a severely disabled person and s.70(1) + # gives no allowance: 0 for both. Without a carer benefit or the hours + # route, the carer is not a carer for benefits either. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [0, 0] + is_entitled_to_carer_benefit: [false, false] + is_carer_for_benefits: [false, false] + +- name: England carer with the input left out gets Carer's Allowance on caring hours + # The model takes the person cared for to be severely disabled unless an + # input says otherwise: 40 hours meet the 35-hour minimum, so the carer gets + # the weekly rate all year, 86.45 * 52 = 4,495.40. The partner does not + # care: 0. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [4_495.40, 0] + is_entitled_to_carer_benefit: [true, false] + is_carer_for_benefits: [true, false] + +- name: Scotland carer of a partner with no qualifying disability benefit gets no Carer Support Payment + # Reg 5(1) pays Carer Support Payment only for care of a person to whom a + # qualifying disability benefit is normally payable, and the partner has + # none: 0. The supplement follows a payable Carer Support Payment: 0. From + # 2025 Carer Support Payment replaces Carer's Allowance in Scotland, so + # Carer's Allowance is 0 too. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [0, 0] + scottish_carer_supplement: [0, 0] + carers_allowance: [0, 0] + +- name: Scotland carer with the input left out gets Carer Support Payment and the supplement + # 40 hours meet the 35-hour minimum: 86.45 * 52 = 4,495.40 of Carer + # Support Payment and 11.70 * 52 = 608.40 of supplement. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [4_495.40, 0] + scottish_carer_supplement: [608.40, 0] + carers_allowance: [0, 0] + +- name: Universal Credit claimant caring for someone with no qualifying disability benefit has no carer element + # The claimant would not claim Carer's Allowance, so no allowance enters + # the award as income; reg 30(2) gives the carer element whether or not the + # claimant claims the allowance. The person cared for is not severely + # disabled, so the claimant fails the Carer's Allowance conditions that + # reg 30(1)(a) applies: no carer element. The award is the standard + # allowance alone, 424.90 * 12 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + would_claim_carers_allowance: false + cares_for_severely_disabled_person: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + uc_carer_element: 0 + universal_credit: 5_098.80 + +- name: Universal Credit claimant with the input left out has the carer element + # The same claimant, with the person cared for taken to be severely + # disabled: the carer element, 209.34 * 12 = 2,512.08, and an award of + # 5,098.80 + 2,512.08 = 7,610.88. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + would_claim_carers_allowance: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + uc_carer_element: 2_512.08 + universal_credit: 7_610.88 + +- name: A reported Carer's Allowance award counts whatever the input says + # The Department pays a reported award, so the input false leaves it: the + # carer gets the weekly rate all year, 86.45 * 52 = 4,495.40, not the + # reported amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [4_495.40, 0] + is_entitled_to_carer_benefit: [true, false] + is_carer_for_benefits: [true, false] + +- name: A reported award in Scotland keeps Carer Support Payment whatever the input says + # carers_allowance_reported carries a reported Carer Support Payment award + # too, which Social Security Scotland pays: 86.45 * 52 = 4,495.40 and the + # supplement, 11.70 * 52 = 608.40. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [4_495.40, 0] + scottish_carer_supplement: [608.40, 0] + +- name: Couple who both care give the allowance only to the partner who cares for a severely disabled person + # Both care 40 hours a week. The elder, the benefit unit head, cares for + # someone with no qualifying disability benefit, so only the younger meets + # the caring condition and has the allowance, 86.45 * 52 = 4,495.40. With + # no reported or supplied award, the default still treats them as caring + # for the same person, and the elder's hours explain no entitlement, so + # the couple restriction leaves the younger's award alone. One carer + # premium: 48.15 * 52 = 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + cares_for_severely_disabled_person: false + younger: + age: 38 + care_hours: 40 + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + is_claimant_or_partner: [true, true] + partners_care_for_same_severely_disabled_person: true + carers_allowance_pre_couple_limit: [0, 4_495.40] + carers_allowance: [0, 4_495.40] + is_entitled_to_carer_benefit: [false, true] + carer_premium: 2_503.80 + +- name: A supplied entitlement whose caring hours go to someone not severely disabled counts as a second award + # The elder has a reported award. The younger cares 40 hours a week for + # someone with no qualifying disability benefit, and an input supplies the + # younger's entitlement to a carer benefit. The hours can't explain that + # entitlement, so it counts as an award: two awards mean two people cared + # for, and the default is false. Each partner then has the carer premium, + # 2 * 48.15 * 52 = 5,007.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + carers_allowance_reported: 4_000 + is_entitled_to_carer_benefit: true + younger: + age: 38 + care_hours: 40 + cares_for_severely_disabled_person: false + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: false + carer_premium: 5_007.60 + +- name: The same couple with the input left out has one carer premium + # Now the younger's 40 hours, for someone the model takes to be severely + # disabled, explain the younger's entitlement, so it is not an award. One + # award leaves the default true, and caring for the same person caps the + # premium at one amount: 48.15 * 52 = 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + carers_allowance_reported: 4_000 + is_entitled_to_carer_benefit: true + younger: + age: 38 + care_hours: 40 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: true + carer_premium: 2_503.80 + +- name: Pension-age carer of someone with no qualifying disability benefit gets no Pension Credit carer addition + # A claimant aged 70 cares 40 hours a week for someone outside the + # household who has no qualifying disability benefit. With no entitlement + # to Carer's Allowance, the claimant has neither the Pension Credit carer + # addition nor the legacy carer premium: 0. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + care_hours: 40 + cares_for_severely_disabled_person: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + is_entitled_to_carer_benefit: false + carer_minimum_guarantee_addition: 0 + carer_premium: 0 + +- name: Pension-age carer with the input left out gets the Pension Credit carer addition + # The same claimant, entitled to Carer's Allowance on caring hours: 86.45 + # * 52 = 4,495.40 of allowance, and the carer addition and the carer + # premium, 48.15 * 52 = 2,503.80 each. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + care_hours: 40 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 4_495.40 + is_entitled_to_carer_benefit: true + carer_minimum_guarantee_addition: 2_503.80 + carer_premium: 2_503.80 diff --git a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py new file mode 100644 index 0000000000..6d863ed915 --- /dev/null +++ b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py @@ -0,0 +1,382 @@ +"""Carer benefits pay on caring hours only for care of a severely disabled +person. + +SSCBA 1992 s.70(1) entitles a person to Carer's Allowance for caring for a +"severely disabled person", whom s.70(2) defines by the benefit payable for +them. SSI 2023/302 reg 5(1) pays Carer Support Payment only for care of a +person "to whom a qualifying disability benefit is normally payable". UC Regs +2013 reg 30(1)(a) gives the carer element to a person who meets the Carer's +Allowance conditions, or would but for the earnings limit or living in +Scotland. The input cares_for_severely_disabled_person records whether the +person cared for gets such a benefit. It defaults to true and gates only the +route through caring hours: a reported award (carers_allowance_reported) +still counts. + +Invariants, for benefit units of one or two adults and up to one qualifying +young person, each with drawn caring hours, reported award, take-up, +earnings and overlapping benefit, in each country, in 2024 (Scotland still on +Carer's Allowance), 2026, 2030 (the year the Carer's Allowance earnings +taper starts) and 2031: + +- leaving the input out gives exactly the values that setting it true gives, + for carers_allowance, carer_support_payment, is_carer_for_benefits, + is_entitled_to_carer_benefit, uc_carer_element, + partners_care_for_same_severely_disabled_person and + carer_minimum_guarantee_addition; +- setting one person's input false never raises that person's + carers_allowance or carer_support_payment and never turns that person's + is_carer_for_benefits or is_entitled_to_carer_benefit on; it never raises + the benefit unit's uc_carer_element or carer_minimum_guarantee_addition, + and never turns partners_care_for_same_severely_disabled_person on; +- before the taper, setting every person's input false never raises any + person's carers_allowance or carer_support_payment, or the benefit unit's + amounts, and never turns any of those flags on; +- a person whose input is false and who has no reported award gets no + Carer's Allowance or Carer Support Payment, before or after the + overlapping-benefit adjustment, and is neither entitled to a carer benefit + nor a carer for benefits; +- setting the input false for the people with a reported award leaves every + person's carers_allowance and carer_support_payment as they were. + +Partners who care for the same person share one award (SSCBA 1992 +s.70(7ZA); SSI 2023/302 reg 5(3)). So when one partner's input goes false, +the model can give the award to the other partner, whose amount and +entitlement then rise, as the law has it; the second invariant therefore +compares only the person whose input changes, and the benefit unit. Under +the earnings taper the partners' awards can differ, and the restriction gives +the award to the larger: setting both inputs false can then give back a +reported award that the restriction took for the other partner's larger one, +so the third invariant holds only before the taper. The examples below run +both cases. + +The households supply none of the carer benefit amounts or entitlements that +the model also takes as inputs: a value supplied for one person fills every +person in the batched simulation. + +Each example builds every benefit unit twice in one simulation, in separate +households: once as drawn, and once with the input changed. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +INPUT = "cares_for_severely_disabled_person" +YEARS = [2024, 2026, 2030, 2031] +YEARS_BEFORE_TAPER = [2024, 2026] +COUNTRIES = ["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +AWARD = 4_000 +# Annual amounts are float32; a change smaller than half a penny is not one. +TOLERANCE = 0.005 + +PERSON_AMOUNTS = ["carers_allowance", "carer_support_payment"] +PERSON_FLAGS = ["is_carer_for_benefits", "is_entitled_to_carer_benefit"] +BENUNIT_AMOUNTS = ["uc_carer_element", "carer_minimum_guarantee_addition"] +BENUNIT_FLAGS = ["partners_care_for_same_severely_disabled_person"] +PRE_OVERLAP = ["carers_allowance_pre_overlap", "carer_support_payment_pre_overlap"] + + +@st.composite +def carer_inputs(draw, earns=True): + # The draws lean towards a claim within the earnings limit, so that most + # carers who care the hours have an award the input can take away. + return { + # Either side of the 35-hour minimum both benefits use. + "care_hours": draw(st.sampled_from([0, 20, 34, 35, 40, 40, 60])), + "carers_allowance_reported": draw(st.sampled_from([0, 0, AWARD])), + "would_claim_carers_allowance": draw(st.sampled_from([True, True, False])), + # Below the earnings limit, above it by less than the rate (so the + # taper pays some allowance), and far above it. + "employment_income": ( + draw(st.sampled_from([0, 0, 0, 8_000, 12_500, 30_000])) if earns else 0 + ), + # Above the carer rates, so an overlapping benefit can reduce the + # payment to nil and leave the entitlement. + "incapacity_benefit_reported": draw(st.sampled_from([0, 0, 6_000])), + } + + +@st.composite +def benefit_units(draw): + """One benefit unit in its own household: its members, the country, one + member whose input a test changes, and for each member the input false + or left out (None).""" + has_young_person = draw(st.booleans()) + young_person_age = draw(st.integers(16, 19)) + head_age = draw(st.integers(young_person_age + 16 if has_young_person else 18, 85)) + members = [{"age": head_age, "is_parent": has_young_person, **draw(carer_inputs())}] + if draw(st.booleans()): + # Within 15 years of the head in both directions when either could be + # under 20, and under 20 years older otherwise, so the model reads + # neither as the other's child. + oldest_partner = min(85, head_age + 19) if head_age >= 20 else head_age + 15 + youngest_partner = max(18, head_age - 15) + if has_young_person: + youngest_partner = max(youngest_partner, young_person_age + 16) + members.append( + { + "age": draw(st.integers(youngest_partner, oldest_partner)), + "is_parent": has_young_person, + **draw(carer_inputs()), + } + ) + adults = len(members) + if has_young_person: + # A qualifying young person: 16 to 19 in non-advanced education. + members.append( + { + "age": young_person_age, + "current_education": "UPPER_SECONDARY", + **draw(carer_inputs(earns=False)), + } + ) + return { + "members": members, + "adults": adults, + "country": draw(st.sampled_from(COUNTRIES)), + "chosen": draw(st.integers(0, len(members) - 1)), + "inputs": [draw(st.sampled_from([False, None])) for _ in members], + } + + +def with_input(unit, values): + """The unit with the input set to each member's value, or left out where + the value is None.""" + members = [ + member if value is None else {**member, INPUT: value} + for member, value in zip(unit["members"], values) + ] + return {**unit, "members": members} + + +def calculate(units, year, variables): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit["members"]): + name = f"p{i}_{j}" + people[name] = {key: {year: value} for key, value in inputs.items()} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = {"members": names, "country": {year: unit["country"]}} + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + values = {name: sim.calculate(name, year) for name in variables} + offsets = np.cumsum([0] + [len(unit["members"]) for unit in units]) + return values, offsets + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +UNITS = st.lists(benefit_units(), min_size=1, max_size=6) + +ADULT = { + "is_parent": False, + "carers_allowance_reported": 0, + "would_claim_carers_allowance": True, + "employment_income": 0, + "incapacity_benefit_reported": 0, +} +# Both partners care 40 hours a week. The elder, the benefit unit head, has the +# couple's one award; with the elder's input false the younger has it. +COUPLE = { + "members": [ + {**ADULT, "age": 40, "care_hours": 40}, + {**ADULT, "age": 38, "care_hours": 40}, + ], + "adults": 2, + "country": "ENGLAND", + "chosen": 0, + "inputs": [False, None], +} +# The same couple, aged 70 and 60, where an overlapping benefit takes the +# elder's award to nil: with the elder's input false, the younger is paid. +OVERLAPPED_COUPLE = { + **COUPLE, + "members": [ + {**ADULT, "age": 70, "care_hours": 40, "incapacity_benefit_reported": 6_000}, + {**ADULT, "age": 60, "care_hours": 40}, + ], +} +# Under the taper: the elder's reported award, tapered by earnings, loses to +# the younger's larger award on caring hours. With both inputs false the elder +# has it back; with only the elder's false, nothing changes. +TAPERED_COUPLE = { + **COUPLE, + "members": [ + { + **ADULT, + "age": 40, + "care_hours": 40, + "carers_allowance_reported": AWARD, + "employment_income": 12_500, + }, + {**ADULT, "age": 38, "care_hours": 40}, + ], + "inputs": [False, False], +} +SCOTTISH_COUPLE = {**COUPLE, "country": "SCOTLAND"} + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, SCOTTISH_COUPLE], year=2026) +def test_input_left_out_matches_input_true(drawn, year): + variables = PERSON_AMOUNTS + PERSON_FLAGS + ["is_claimant_or_partner"] + benunit_variables = BENUNIT_AMOUNTS + BENUNIT_FLAGS + true = [with_input(unit, [True] * len(unit["members"])) for unit in drawn] + values, offsets = calculate(drawn + true, year, variables + benunit_variables) + k = len(drawn) + n = offsets[k] + for i, unit in enumerate(drawn): + # The generator's construction, checked against the model: the + # adults are the claimant and partner, the young person is not. + flags = values["is_claimant_or_partner"][offsets[i] : offsets[i + 1]] + adults = unit["adults"] + assert flags[:adults].all() and not flags[adults:].any(), unit + for name in variables: + assert np.array_equal(values[name][:n], values[name][n:]), name + for name in benunit_variables: + assert np.array_equal(values[name][:k], values[name][k:]), name + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, OVERLAPPED_COUPLE, SCOTTISH_COUPLE], year=2026) +@example(drawn=[TAPERED_COUPLE], year=2031) +def test_one_persons_input_false_never_raises_their_award(drawn, year): + changed = [ + with_input( + unit, + [ + False if j == unit["chosen"] else None + for j in range(len(unit["members"])) + ], + ) + for unit in drawn + ] + variables = PERSON_AMOUNTS + PERSON_FLAGS + BENUNIT_AMOUNTS + BENUNIT_FLAGS + values, offsets = calculate(drawn + changed, year, variables) + k = len(drawn) + for i, unit in enumerate(drawn): + before = offsets[i] + unit["chosen"] + after = offsets[k + i] + unit["chosen"] + for name in PERSON_AMOUNTS: + assert values[name][after] <= values[name][before] + TOLERANCE, (name, unit) + for name in PERSON_FLAGS: + assert values[name][before] or not values[name][after], (name, unit) + for name in BENUNIT_AMOUNTS: + assert values[name][k + i] <= values[name][i] + TOLERANCE, (name, unit) + for name in BENUNIT_FLAGS: + assert values[name][i] or not values[name][k + i], (name, unit) + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS_BEFORE_TAPER)) +@example(drawn=[COUPLE, OVERLAPPED_COUPLE, SCOTTISH_COUPLE], year=2026) +def test_everyones_input_false_never_raises_an_award_before_the_taper(drawn, year): + false = [with_input(unit, [False] * len(unit["members"])) for unit in drawn] + variables = PERSON_AMOUNTS + PERSON_FLAGS + BENUNIT_AMOUNTS + BENUNIT_FLAGS + values, offsets = calculate(drawn + false, year, variables) + k = len(drawn) + n = offsets[k] + for name in PERSON_AMOUNTS: + assert (values[name][n:] <= values[name][:n] + TOLERANCE).all(), name + for name in PERSON_FLAGS: + assert not (values[name][n:] & ~values[name][:n]).any(), name + for name in BENUNIT_AMOUNTS: + assert (values[name][k:] <= values[name][:k] + TOLERANCE).all(), name + for name in BENUNIT_FLAGS: + assert not (values[name][k:] & ~values[name][:k]).any(), name + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, TAPERED_COUPLE, SCOTTISH_COUPLE], year=2031) +def test_input_false_without_a_reported_award_pays_no_carer_benefit(drawn, year): + units = [with_input(unit, unit["inputs"]) for unit in drawn] + variables = PERSON_AMOUNTS + PRE_OVERLAP + PERSON_FLAGS + values, offsets = calculate(units, year, variables) + for i, unit in enumerate(drawn): + for j, (member, value) in enumerate(zip(unit["members"], unit["inputs"])): + if value is False and member["carers_allowance_reported"] == 0: + for name in PERSON_AMOUNTS + PRE_OVERLAP: + assert values[name][offsets[i] + j] == 0, (name, unit) + for name in PERSON_FLAGS: + assert not values[name][offsets[i] + j], (name, unit) + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[TAPERED_COUPLE], year=2031) +@example(drawn=[{**COUPLE, "members": TAPERED_COUPLE["members"]}], year=2026) +def test_reported_award_pays_the_same_whatever_the_input(drawn, year): + toggled = [ + with_input( + unit, + [ + False if member["carers_allowance_reported"] > 0 else None + for member in unit["members"] + ], + ) + for unit in drawn + ] + values, offsets = calculate(drawn + toggled, year, PERSON_AMOUNTS) + n = offsets[len(drawn)] + for name in PERSON_AMOUNTS: + assert np.array_equal(values[name][:n], values[name][n:]), name + + +def _couple_values(units, year, variables): + values, _ = calculate(units, year, variables) + return {name: [round(float(x), 2) for x in values[name]] for name in variables} + + +def test_award_passes_to_the_partner_who_cares_for_a_severely_disabled_person(): + # The cases that the second invariant leaves out, run as they are. With + # the elder's input false the younger's allowance rises from nil to the + # full rate, 86.45 * 52 = 4,495.40: one partner's hours no longer count, + # so the other is the only one caring for a severely disabled person. + names = ["carers_allowance", "is_entitled_to_carer_benefit"] + default = _couple_values([COUPLE], 2026, names) + changed = _couple_values([with_input(COUPLE, COUPLE["inputs"])], 2026, names) + assert default == { + "carers_allowance": [4_495.4, 0], + "is_entitled_to_carer_benefit": [1, 0], + } + assert changed == { + "carers_allowance": [0, 4_495.4], + "is_entitled_to_carer_benefit": [0, 1], + } + # Where an overlapping benefit takes the elder's award to nil, the couple + # had nothing and now has the younger's full allowance. + overlapped = _couple_values([OVERLAPPED_COUPLE], 2026, names) + changed = _couple_values( + [with_input(OVERLAPPED_COUPLE, OVERLAPPED_COUPLE["inputs"])], 2026, names + ) + assert overlapped["carers_allowance"] == [0, 0] + assert changed["carers_allowance"] == [0, 4_495.4] + + +def test_under_the_taper_both_inputs_false_can_give_back_a_reported_award(): + # The case that the third invariant leaves out. In 2031 the elder's + # tapered award falls short of the younger's award on caring hours, so + # the couple restriction gives the younger the award. With both inputs + # false the younger has none, and the elder keeps the reported award. + names = ["carers_allowance_pre_couple_limit", "carers_allowance"] + default = _couple_values([TAPERED_COUPLE], 2031, names) + both_false = _couple_values( + [with_input(TAPERED_COUPLE, TAPERED_COUPLE["inputs"])], 2031, names + ) + elder_award, younger_award = default["carers_allowance_pre_couple_limit"] + assert 0 < elder_award < younger_award + assert default["carers_allowance"] == [0, younger_award] + assert both_false["carers_allowance"] == [elder_award, 0] diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index e72d9a1de5..c7ea727f3e 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -45,7 +45,11 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): csp_replaces_ca = period.start.year >= 2025 receives_ca = person("carers_allowance_reported", period) > 0 meets_age_condition = person("age", period) >= ca.min_age - meets_work_condition = person("care_hours", period) >= ca.min_hours + # SSCBA 1992 s. 70(1): the hours count only for caring for a severely + # disabled person (s. 70(2)), which the input takes to hold by default. + meets_work_condition = (person("care_hours", period) >= ca.min_hours) & person( + "cares_for_severely_disabled_person", period + ) would_claim = person("would_claim_carers_allowance", period) eligible = ( ~(in_scotland & csp_replaces_ca) diff --git a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py new file mode 100644 index 0000000000..4211410b8d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * + + +class cares_for_severely_disabled_person(Variable): + value_type = bool + entity = Person + label = "Cares for a severely disabled person" + documentation = ( + "Whether the person this person cares for, in the household or " + "outside it, gets a benefit that makes them a severely disabled " + "person for Carer's Allowance (SSCBA 1992 s.70(2), with the payments " + "that SI 1976/409 reg 3 prescribes) or a qualifying disability " + "benefit for Carer Support Payment (SSI 2023/302 regs 2 and 5(1)). " + "The Department for Work and Pensions pays Carer's Allowance only for " + "caring for a severely disabled person (s.70(1)), Social Security " + "Scotland pays Carer Support Payment only for care of a person to " + "whom a qualifying disability benefit is normally payable (reg 5(1)), " + "and the Universal Credit carer element needs the Carer's Allowance " + "conditions, apart from the earnings limit and residence in Scotland " + "(UC Regs 2013 reg 30(1)(a)). Caring hours in the survey count care " + "for anyone and don't say whom the carer cares for, and the person " + "cared for can live elsewhere, so the model doesn't derive this from " + "the household's disability benefits: it takes the person cared for " + "to qualify unless an input says otherwise, and the default is true. " + "The model reads it only on the route through caring hours " + "(care_hours at the minimum) to Carer's Allowance, Carer Support " + "Payment and is_carer_for_benefits, and so the Universal Credit " + "carer element, and in the hours that explain an entitlement in " + "partners_care_for_same_severely_disabled_person. A reported award " + "(carers_allowance_reported, the model's reported-receipt input for " + "both benefits) still counts whatever this input says, since the " + "Department or Social Security Scotland pays it. Set it to false for " + "a carer whose caring hours go to someone with none of those " + "benefits." + ) + definition_period = YEAR + default_value = True + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992 s. 70(1) and (2)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/70", + ), + dict( + title="The Social Security (Invalid Care Allowance) Regulations 1976 reg. 3", + href="https://www.legislation.gov.uk/uksi/1976/409/regulation/3", + ), + dict( + title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 2(2)", + href="https://www.legislation.gov.uk/ssi/2023/302/regulation/2", + ), + dict( + title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 5(1)", + href="https://www.legislation.gov.uk/ssi/2023/302/regulation/5", + ), + dict( + title="The Universal Credit Regulations 2013 reg. 30(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + ), + ] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py index d44a0cfef2..b73e503b6e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py @@ -21,10 +21,12 @@ class uc_unable_to_provide_childcare(Variable): "Limb (ii), defined in regulation 30, reads " "is_carer_for_benefits: receipt of Carer's Allowance or Carer Support " "Payment, or at least the Carer's Allowance qualifying hours of care " - "a week. Its hours branch does not check that the person cared for " - "is severely disabled (a Carer's Allowance condition, SSCBA 1992 " - "s. 70(1)-(2), that regulation 30(1)(a) imports) or exclude a carer " - "paid for the caring (regulation 30(3)). Limb (iii) is the input " + "a week. Its hours branch checks that the person cared for is " + "severely disabled (a Carer's Allowance condition, SSCBA 1992 " + "s. 70(1)-(2), that regulation 30(1)(a) imports) only through the " + "input cares_for_severely_disabled_person, which defaults to true, " + "and does not exclude a carer paid for the caring (regulation " + "30(3)). Limb (iii) is the input " "uc_is_temporarily_absent_from_claimant_household." ) definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py index 7675aabfec..cdc9b5c09d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py @@ -22,8 +22,9 @@ class meets_uc_minimum_age_condition(Variable): is_carer_for_benefits means receipt of Carer's Allowance or Scottish Carer Support Payment, underlying entitlement to either benefit, or at least the Carer's Allowance qualifying hours of care (currently 35 weekly). - Its hours limb does not verify the recipient's - qualifying disability benefit, and it does not exclude paid care. + Its hours limb reads the cared-for person's qualifying disability benefit + only from the input cares_for_severely_disabled_person, which defaults to + true, and it does not exclude paid care. For regulation 8(1)(d), responsibility is proxied by being a claimant in a benefit unit with a nonclaimant child under the UC child definition. A diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py index 775a09b2e4..d491929f34 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py @@ -16,6 +16,7 @@ class carer_support_payment_pre_couple_limit(Variable): "https://www.legislation.gov.uk/asp/2018/9/part/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/3", "https://www.legislation.gov.uk/ssi/2023/302/regulation/4", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/5", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", "https://www.legislation.gov.uk/ssi/2023/302/regulation/14", "https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4", @@ -29,7 +30,12 @@ def formula(person, period, parameters): csp = parameters(period).gov.social_security_scotland.carer_support_payment meets_age_condition = person("age", period) >= csp.min_age weekly_care_hours = person("care_hours", period) - meets_hours = weekly_care_hours >= csp.min_hours + # Reg 5(1): only care of a person to whom a qualifying disability + # benefit is normally payable counts, which the input takes to hold + # by default. + meets_hours = (weekly_care_hours >= csp.min_hours) & person( + "cares_for_severely_disabled_person", period + ) receives_ca = person("carers_allowance_reported", period) > 0 would_claim = person("would_claim_carers_allowance", period) earnings = person("carers_allowance_earnings", period) diff --git a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py index f86e87520d..3c66fdeda7 100644 --- a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py @@ -9,28 +9,39 @@ class is_carer_for_benefits(Variable): documentation = ( "Receives or is entitled to a carer benefit, or provides at least the " - "Carer's Allowance qualifying hours of care a week. The Universal " + "Carer's Allowance qualifying hours of care a week for a severely " + "disabled person. The Universal " "Credit carer element does not require a Carer's Allowance claim. " "Entitlement-based additions and premiums use " "is_entitled_to_carer_benefit; receipt-based rules use " "receives_carer_benefit. A " "carer whose allowance is reduced to nil by an overlapping benefit, " - "such as State Pension, stays entitled. The hours limb tests " - "hours alone, as carers_allowance_pre_overlap does: it does not check that the " - "person cared for receives a qualifying disability benefit (part of " - "the Carer's Allowance conditions referenced by regulation 30), so a " - "survey hours field counts caring for " - "anyone and a dataset's take-up input carries that simplification. " + "such as State Pension, stays entitled. The hours limb, as in " + "carers_allowance_pre_overlap, counts the hours only where the input " + "cares_for_severely_disabled_person holds: the person cared for gets " + "a benefit that makes them a severely disabled person for Carer's " + "Allowance (SSCBA 1992 s.70(1) and (2)), one of the Carer's Allowance " + "conditions that regulation 30(1)(a) applies. The input defaults to " + "true, because caring hours in the survey don't say whom the carer " + "cares for, so at the default a survey hours field counts caring for " + "anyone and a dataset's take-up input carries that simplification. A " + "household calculation sets it false for a carer whose hours go to " + "someone with no such benefit. " "A reported Carer's Allowance award the person would claim also " "counts, so a carer whose earnings put them over the Carer's Allowance " "earnings limit keeps the Universal Credit carer element, which does " "not apply that limit (regulation 30(1)(a)(i))." ) - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/30" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", + ) def formula(person, period, parameters): min_hours = parameters(period).gov.dwp.carers_allowance.min_hours - meets_hours = person("care_hours", period) >= min_hours + meets_hours = (person("care_hours", period) >= min_hours) & person( + "cares_for_severely_disabled_person", period + ) # A reported Carer's Allowance award the person would claim is # evidence of the caring itself, where no hours are recorded. It still # counts when the earnings limit (which the Universal Credit carer diff --git a/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py b/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py index 89d137939c..10a6775f8f 100644 --- a/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py +++ b/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py @@ -19,19 +19,23 @@ class partners_care_for_same_severely_disabled_person(Variable): "Carer's Allowance or Carer Support Payment award " "(carers_allowance_reported, the model's reported-receipt input for " "both), or an entitlement that caring hours do not explain (fewer " - "than the qualifying hours, or no claim), which must have been " + "than the qualifying hours, care of someone who isn't severely " + "disabled (cares_for_severely_disabled_person false), or no claim), " + "which must have been " "supplied directly (for example as carers_allowance, " "carers_allowance_pre_overlap or is_entitled_to_carer_benefit; supply " "it for every member, since members left out take the default). The " "qualifying hours are Carer Support Payment's in Scotland from 2025 " "and Carer's Allowance's otherwise. Two awards mean two different " "people cared for, while caring hours cannot show who is cared for, " - "so the entitlement of someone who cares the qualifying hours and " - "would claim is not read as an award. " + "so the default doesn't read as an award the entitlement of someone " + "who cares the qualifying hours for a severely disabled person and " + "would claim. " "With more than two members supplied as claimant or partner, " "two awards make the default false for all of them. The model's " "Carer's Allowance and Carer Support Payment candidate amounts follow " - "caring hours or a reported award. The default uses those candidates " + "a reported award or caring hours for a severely disabled person. " + "The default uses those candidates " "and directly supplied awards before the same-person couple limit, " "so it does not depend on the final awards it restricts." ) @@ -48,11 +52,13 @@ def formula(benunit, period, parameters): entitled = unlimited_carer_benefit_entitlement(benunit.members, period) reported_award = benunit.members("carers_allowance_reported", period) > 0 # The model makes someone entitled to a carer benefit only on a - # reported award or the qualifying hours, each for someone who would - # claim, so an entitlement the hours do not explain comes from a - # reported award or was supplied directly (an amount in payment, a - # pre-overlap amount or the entitlement itself). The hours are those - # of the benefit that applies, as in carers_allowance_pre_overlap. + # reported award or the qualifying hours of care for a severely + # disabled person (cares_for_severely_disabled_person), each for + # someone who would claim, so an entitlement the hours do not explain + # comes from a reported award or an input that supplies it directly + # (an amount in payment, a pre-overlap amount or the entitlement + # itself). The hours are those of the benefit that applies, as in + # carers_allowance_pre_overlap. gov = parameters(period).gov in_scotland = ( benunit.members.household("country", period).decode_to_str() == "SCOTLAND" @@ -64,7 +70,12 @@ def formula(benunit, period, parameters): gov.dwp.carers_allowance.min_hours, ) hours = benunit.members("care_hours", period) + cares_for_severely_disabled = benunit.members( + "cares_for_severely_disabled_person", period + ) would_claim = benunit.members("would_claim_carers_allowance", period) - explained_by_hours = (hours >= min_hours) & would_claim + explained_by_hours = ( + (hours >= min_hours) & cares_for_severely_disabled & would_claim + ) award = reported_award | ~explained_by_hours return benunit.sum(claimant_or_partner & entitled & award) < 2 From aad18681987c92d42adf6e6e207a0500b326cd4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:25:28 +0000 Subject: [PATCH 124/168] Pay the Scottish Carer Supplement for each Carer Support award week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scottish_carer_supplement paid 52 weekly rates for any positive carer_support_payment. It now pays the weekly supplement rate for min(52, max(carer_support_payment_pre_overlap, carer_support_payment) / weekly Carer Support Payment rate) award weeks where carer_support_payment is positive (SSI 2023/302 reg 14A), so 13 weeks of Carer Support Payment after a move to England (reg 42) bring 13 weekly rates of supplement, £152.10 in 2026-27, rather than £608.40. The formula holds 52 weekly rates in the amount's float32 dtype, so where the model computes both amounts the supplement equals main's bit for bit, and it guards the division against a zero rate. The documentation names the four cases where the count differs from the law. YAML cases worked from reg 16(1) and (1A) and Hypothesis properties cover the bounds, payment, differential, monotonicity, whole weeks, full year, zero rate and the disable_simulated_benefits path. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2791.fixed.md | 1 + .../scottish_carer_supplement.yaml | 140 ++++++ ...carer_supplement_award_weeks_properties.py | 454 ++++++++++++++++++ .../scottish_carer_supplement.py | 65 ++- 4 files changed, 650 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2791.fixed.md create mode 100644 policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py diff --git a/changelog.d/2791.fixed.md b/changelog.d/2791.fixed.md new file mode 100644 index 0000000000..2b44e7be4a --- /dev/null +++ b/changelog.d/2791.fixed.md @@ -0,0 +1 @@ +Pay the Scottish Carer Supplement for each award week in which Carer Support Payment is payable, up to 52 a year, counting the weeks from the larger of the payment and the entitlement before the overlapping-benefit reduction, so a carer paid 13 weeks of Carer Support Payment after moving to England gets £152.10 of supplement in 2026-27 rather than £608.40. diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml index 967e7ef79c..af6556f492 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml @@ -56,3 +56,143 @@ output: scottish_carer_supplement: 0 carer_support_payment: 0 + +# Award weeks. SSI 2023/302 reg 14A: "An individual is entitled to Scottish +# Carer Supplement for each award week in which Carer Support Payment is +# payable to the individual." In reg 2(2), "award week" means "a period of 7 +# days beginning on a Sunday and ending on a Saturday". The Scottish Ministers +# set the weekly rates in reg 16(1) and (1A): Carer Support Payment 86.45 and +# Scottish Carer Supplement 11.70. Under reg 42(1) and (5), a carer who moves +# to another part of the United Kingdom keeps Carer Support for 13 weeks; +# under reg 42(1A), where the cared-for person dies in that time, for 12 award +# weeks after the first day of the award week following the one in which they +# died, up to 25 weeks in all. The formula counts the weeks as the larger of +# carer_support_payment_pre_overlap and carer_support_payment over the weekly +# Carer Support Payment rate, up to 52, and pays the weekly supplement rate for +# each where carer_support_payment is positive, and nothing where it is £0. + +- name: Carer in England with 13 weeks of Carer Support Payment gets 13 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # Reg 42(1) and (5): 13 weeks after the move, 13 x 86.45 = 1,123.85. + carer_support_payment: 1123.85 + output: + carer_support_payment_pre_overlap: 0 + # 1,123.85 / 86.45 = 13 award weeks; 13 x 11.70 = 152.10. + scottish_carer_supplement: 152.10 + +- name: Carer in England whose cared-for person dies in week 13 after the move gets 25 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # Reg 42(1A): the cared-for person dies in week 13, and the carer keeps + # Carer Support for the 12 award weeks from the start of week 14, 25 weeks + # in all. 25 x 86.45 = 2,161.25. + carer_support_payment: 2161.25 + output: + carer_support_payment_pre_overlap: 0 + # 2,161.25 / 86.45 = 25 award weeks; 25 x 11.70 = 292.50. + scottish_carer_supplement: 292.50 + +- name: One week of Carer Support Payment gives one week of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + carer_support_payment: 86.45 + output: + # 86.45 / 86.45 = 1 award week; 1 x 11.70 = 11.70. + scottish_carer_supplement: 11.70 + +- name: Carer Support Payment worth more than 52 weeks gives 52 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # 60 x 86.45 = 5,187. + carer_support_payment: 5187 + output: + # The model counts 52 award weeks in a year: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer in Scotland with no caring inputs and 13 weeks of Carer Support Payment gets 13 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: SCOTLAND + # No caring hours and no reported Carer's Allowance, so the inputs give + # no entitlement before the overlapping-benefit reduction. + carer_support_payment: 1123.85 + output: + carer_support_payment_pre_overlap: 0 + # 13 x 11.70 = 152.10. + scottish_carer_supplement: 152.10 + +- name: Carer in Scotland the model finds eligible keeps 52 weeks of supplement with a supplied part-year payment + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + care_hours: 40 + country: SCOTLAND + carer_support_payment: 1123.85 + output: + # Age 35 and 40 hours of care a week describe a carer entitled all year: + # 52 x 86.45 = 4,495.40 before the overlapping-benefit reduction. + carer_support_payment_pre_overlap: 4495.40 + # The formula counts 52 award weeks from the larger amount: 52 x 11.70 = + # 608.40. For a carer with 13 weeks of Carer Support Payment, Social + # Security Scotland would pay 13 x 11.70 = 152.10; see the variable's + # documentation. + scottish_carer_supplement: 608.40 + +- name: Carer whose State Pension reduces Carer Support Payment and leaves some keeps 52 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 2_000 + care_hours: 35 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 4495.40 + # Reg 16(2)(a): 2,000 a year, 38.46 a week, falls short of the 86.45 + # weekly rate, so Carer Support Payment stays payable in every award + # week: 4,495.40 - 2,000 = 2,495.40. + carer_support_payment: 2495.40 + # Reg 14A: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer with a supplied payment that State Pension reduced keeps 52 weeks of supplement with the amount before the reduction + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + country: SCOTLAND + # A carer entitled all year, 52 x 86.45 = 4,495.40, whose State Pension + # of 2,000 a year leaves 2,495.40, with both amounts supplied. + carer_support_payment_pre_overlap: 4495.40 + carer_support_payment: 2495.40 + output: + # Reg 16(2)(a) leaves a payment in every award week: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer in England with 13 weeks of Carer Support Payment gets 13 weeks of supplement in 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # 13 x 89.50 = 1,163.50, at the 2027 rate above. + carer_support_payment: 1163.50 + output: + # 1,163.50 / 89.50 = 13 award weeks; 13 x 12.10 = 157.30. + scottish_carer_supplement: 157.30 diff --git a/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py new file mode 100644 index 0000000000..f00a7ba080 --- /dev/null +++ b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py @@ -0,0 +1,454 @@ +"""The Scottish Carer Supplement for each award week in which Carer Support +Payment is payable. + +Regulation 14A of the Carer's Assistance (Carer Support Payment) (Scotland) +Regulations 2023 (SSI 2023/302): "An individual is entitled to Scottish Carer +Supplement for each award week in which Carer Support Payment is payable to +the individual." The Scottish Ministers set the weekly rates of the two +components in regulation 16(1) and (1A). The model holds annual amounts, so +scottish_carer_supplement counts the award weeks as + + min(52, max(carer_support_payment_pre_overlap, carer_support_payment) / rate) + +where carer_support_payment is positive, and pays the weekly supplement rate +for each of them. It measures the amount against 52 weekly rates as the engine +stores them, so a full year's entitlement counts exactly 52 weeks. + +Invariants, for single people aged 14 to 90 in each UK country, in each year +from 2024 to 2030: + +1. Bounds: the supplement lies between 0 and 52 weekly supplement rates, as + the engine stores that amount. +2. The supplement is positive exactly where carer_support_payment is + positive, in years whose supplement rate is positive. +3. Differential: the supplement equals the rule rebuilt here from the drawn + inputs, with the rates read from the parameters. +4. Monotonic: raising a supplied carer_support_payment never lowers the + supplement. +5. Whole weeks: a supplied payment of k weekly rates, for k from 1 to 52, + gives k weekly supplements, and a payment of 52 weekly rates or more + gives 52. +6. Full year where the model computes the payment and the amount before the + overlapping-benefit reduction: with neither carer_support_payment nor + carer_support_payment_pre_overlap supplied, the supplement equals 52 + weekly supplement rates, as the engine stores that amount, where + carer_support_payment is positive, and nothing elsewhere. +7. A reform that sets the weekly Carer Support Payment rate to zero leaves no + supplement and raises no floating-point error. + +Each example puts every drawn person in one simulation, with the same inputs +in each year, and checks every year. The reference rebuilds the entitlement +before the overlapping-benefit reduction as 52 weekly rates for a carer in +Scotland, from 2025, who reaches the minimum age, cares for the minimum hours +or reports Carer's Allowance, and would claim; the drawn people have no +earnings. A State Pension supplied directly overlaps in full. The engine +stores amounts in float32, so the reference rounds the amounts it compares to +float32. The differential and whole-week comparisons allow a tenth of a penny +for the rounding, which comes to under £0.0001 at these sizes; the other +invariants hold exactly. + +A last test checks gov.contrib.policyengine.disable_simulated_benefits, which +makes a Scottish carer's reported Carer's Allowance their +carer_support_payment and leaves the amount before the reduction to the model. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario + +CSP = "gov.social_security_scotland.carer_support_payment" +WEEKS = 52 +# carer_support_payment_pre_couple_limit pays Carer Support Payment from 2025. +CSP_FROM = 2025 +YEARS = [2024, 2025, 2026, 2027, 2028, 2029, 2030] +# The years with a positive supplement rate. +SUPPLEMENT_YEARS = [2026, 2027, 2028, 2029, 2030] +COUNTRIES = ["ENGLAND", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +TOLERANCE = 0.001 +# A State Pension equal to a year of Carer Support Payment in each year: under +# reg 16(2)(b), the payment given is then £0. +YEAR_OF_CSP = "a year of Carer Support Payment" +PROPERTY_SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False).map( + lambda x: round(x, 2) + ) + + +# Up to about 72 weeks at the 2030 rate, small amounts of a week or two, and +# none. +PAYMENT = st.one_of(st.just(0.0), money(200), money(7_000)) +# Below the 2025 to 2030 annual rates (4,331.60 to 5,018), above them, and +# equal to them. +STATE_PENSION = st.one_of( + st.just(0.0), money(4_300), money(12_000), st.just(YEAR_OF_CSP) +) + + +def parameters(year): + return system.parameters(year).gov.social_security_scotland.carer_support_payment + + +def stored(amount): + """The amount as the engine stores it.""" + return float(np.float32(amount)) + + +@st.composite +def batches(draw, supplied): + """Single people aged 14 to 90, each in their own household, about half in + Scotland. + + An input overrides its variable's formula for the whole population, so a + batch supplies carer_support_payment for everyone (supplied) or for no + one, and supplies carer_support_payment_pre_overlap for everyone or for no + one. + """ + give_pre_overlap = draw(st.sampled_from([False, False, True])) + people = [] + for _ in range(draw(st.integers(2, 16))): + person = { + "country": draw(st.sampled_from(["SCOTLAND"] * 3 + COUNTRIES[:3])), + "age": draw(st.integers(14, 90)), + "care_hours": draw(st.sampled_from([0, 20, 34, 35, 40, 60])), + "carers_allowance_reported": draw(st.sampled_from([0, 0, 0, 3_000])), + "would_claim_carers_allowance": draw(st.sampled_from([True, True, False])), + } + if supplied: + person["carer_support_payment"] = draw(PAYMENT) + else: + person["state_pension"] = draw(STATE_PENSION) + if give_pre_overlap: + person["carer_support_payment_pre_overlap"] = draw(PAYMENT) + people.append(person) + return people + + +def inputs(person, year): + """The person's inputs in the year, with the sentinels replaced.""" + values = {key: value for key, value in person.items() if key != "country"} + if values.get("state_pension") == YEAR_OF_CSP: + values["state_pension"] = WEEKS * parameters(year).rate + if "weeks_paid" in values: + weeks_paid = values.pop("weeks_paid") + values["carer_support_payment"] = weeks_paid * parameters(year).rate + return values + + +def build(people, years=YEARS, reform=None): + """One simulation holding every person, with inputs in each year.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, person in enumerate(people): + by_year = {year: inputs(person, year) for year in years} + situation["people"][f"p{i}"] = { + name: {year: by_year[year][name] for year in years} + for name in by_year[years[0]] + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = { + "members": [f"p{i}"], + "country": {year: person["country"] for year in years}, + } + return Simulation(situation=situation, reform=reform) + + +def calc(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +def scheme(sim, year): + parameters = sim.tax_benefit_system.parameters(year) + return parameters.gov.social_security_scotland.carer_support_payment + + +def rates(sim, year): + csp = scheme(sim, year) + return float(csp.rate), float(csp.supplement) + + +def reference(sim, year, people): + """The supplement rebuilt from the drawn inputs and the parameters.""" + csp = scheme(sim, year) + rate, supplement = rates(sim, year) + expected = [] + for person in people: + values = inputs(person, year) + if "carer_support_payment_pre_overlap" in values: + pre_overlap = stored(values["carer_support_payment_pre_overlap"]) + else: + eligible = ( + person["country"] == "SCOTLAND" + and year >= CSP_FROM + and person["age"] >= csp.min_age + and ( + person["care_hours"] >= csp.min_hours + or person["carers_allowance_reported"] > 0 + ) + and person["would_claim_carers_allowance"] + ) + pre_overlap = stored(WEEKS * rate) if eligible else 0.0 + if "carer_support_payment" in values: + payable = stored(values["carer_support_payment"]) + else: + # Reg 16(2): the overlapping benefit reduces the payment, to £0 + # where the benefit equals or exceeds the payment. + payable = max(pre_overlap - stored(values["state_pension"]), 0.0) + weeks = min(WEEKS, max(pre_overlap, payable) / rate) if rate > 0 else 0.0 + expected.append(supplement * weeks if payable > 0 else 0.0) + return np.array(expected) + + +def check_bounds_and_payment(sim, year): + """Invariants 1 and 2.""" + supplement = calc(sim, "scottish_carer_supplement", year) + payable = calc(sim, "carer_support_payment", year) + rate, weekly_supplement = rates(sim, year) + assert (supplement >= 0).all(), year + assert (supplement <= np.float32(WEEKS * weekly_supplement)).all(), year + if weekly_supplement > 0 and rate > 0: + np.testing.assert_array_equal(supplement > 0, payable > 0, err_msg=str(year)) + else: + np.testing.assert_array_equal(supplement, 0, err_msg=str(year)) + return supplement + + +def carer(country="ENGLAND", **values): + """A 35-year-old with no caring inputs, who would claim.""" + return { + "country": country, + "age": 35, + "care_hours": 0, + "carers_allowance_reported": 0, + "would_claim_carers_allowance": True, + **values, + } + + +def england(payment): + return carer(carer_support_payment=payment) + + +def scottish_carer(payment, care_hours=40): + return carer("SCOTLAND", care_hours=care_hours, carer_support_payment=payment) + + +def pensioner(state_pension, country="SCOTLAND"): + return { + **carer(country, care_hours=35, state_pension=state_pension), + "age": 70, + } + + +@PROPERTY_SETTINGS +@given(people=batches(supplied=True)) +# 13 and 25 weeks of the 2026 rate after a move to England (reg 42), one week, +# 60 weeks, a carer in Scotland with no caring inputs, and one the model finds +# eligible. +@example( + people=[ + england(1_123.85), + england(2_161.25), + england(86.45), + england(5_187), + scottish_carer(1_123.85, care_hours=0), + scottish_carer(1_123.85), + scottish_carer(0), + ] +) +# Both amounts supplied: a payment that State Pension reduced, none, and one +# week with no entitlement before the reduction. +@example( + people=[ + carer( + "SCOTLAND", + carer_support_payment_pre_overlap=4_495.40, + carer_support_payment=2_495.40, + ), + carer(carer_support_payment_pre_overlap=1_123.85, carer_support_payment=0), + carer(carer_support_payment_pre_overlap=0, carer_support_payment=86.45), + ] +) +def test_supplied_payment_bounds_and_reference(people): + sim = build(people) + for year in YEARS: + supplement = check_bounds_and_payment(sim, year) + # 3. Differential. + np.testing.assert_allclose( + supplement, + reference(sim, year, people), + atol=TOLERANCE, + err_msg=str(year), + ) + + +@st.composite +def raised_batches(draw): + people = draw(batches(supplied=True)) + raised = [ + {**person, "carer_support_payment": person["carer_support_payment"] + extra} + for person, extra in zip(people, [draw(PAYMENT) for _ in people]) + ] + return people, raised + + +@PROPERTY_SETTINGS +@given(batch=raised_batches()) +def test_supplement_is_monotonic_in_a_supplied_payment(batch): + people, raised = batch + # One simulation: the drawn people, then the same people paid more. + sim = build(people + raised) + for year in YEARS: + supplement = calc(sim, "scottish_carer_supplement", year) + low, high = supplement[: len(people)], supplement[len(people) :] + # 4. Exactly, since the formula rounds monotonically at each step. + assert (high >= low).all(), year + + +def test_whole_weeks(): + """5. Each payment is a number of the year's weekly rates.""" + weeks = list(range(1, WEEKS + 1)) + beyond = [52, 52.001, 53, 60, 104, 1_000] + # Carers in Scotland with no caring inputs have no entitlement before the + # overlapping-benefit reduction either. + people = [ + carer(COUNTRIES[i % len(COUNTRIES)], weeks_paid=k) + for i, k in enumerate(weeks + beyond) + ] + sim = build(people, SUPPLEMENT_YEARS) + for year in SUPPLEMENT_YEARS: + _, weekly_supplement = rates(sim, year) + expected = [k * weekly_supplement for k in weeks] + expected += [WEEKS * weekly_supplement] * len(beyond) + np.testing.assert_allclose( + calc(sim, "scottish_carer_supplement", year), + expected, + atol=TOLERANCE, + err_msg=str(year), + ) + + +@PROPERTY_SETTINGS +@given(people=batches(supplied=False)) +# State Pension that reduces the payment and leaves some, that takes it to +# £0, and that equals it (reg 16(2)(b)); a carer outside Scotland. +@example( + people=[ + pensioner(2_000), + pensioner(6_000), + pensioner(YEAR_OF_CSP), + pensioner(0), + pensioner(0, country="ENGLAND"), + ] +) +def test_computed_payment_bounds_and_reference(people): + sim = build(people) + for year in YEARS: + supplement = check_bounds_and_payment(sim, year) + # 3. Differential. + np.testing.assert_allclose( + supplement, + reference(sim, year, people), + atol=TOLERANCE, + err_msg=str(year), + ) + if "carer_support_payment_pre_overlap" in people[0]: + continue + # 6. 52 weekly supplements, as the engine stores them, wherever the + # model pays Carer Support Payment. + payable = calc(sim, "carer_support_payment", year) + _, weekly_supplement = rates(sim, year) + full_year = (payable > 0) * weekly_supplement * WEEKS + np.testing.assert_array_equal( + supplement, full_year.astype(np.float32), err_msg=str(year) + ) + + +@pytest.mark.parametrize("supplied", [True, False]) +def test_zero_rate_gives_no_supplement(supplied): + """7. The formula guards the division by the weekly rate.""" + zero_rate = {f"{CSP}.rate": {"2024-01-01.2100-12-31": 0}} + if supplied: + people = [england(1_123.85), scottish_carer(1_123.85), england(0)] + else: + people = [pensioner(0), pensioner(2_000), pensioner(0, country="ENGLAND")] + sim = build(people, SUPPLEMENT_YEARS, reform=zero_rate) + for year in SUPPLEMENT_YEARS: + assert rates(sim, year)[0] == 0 + # Calculate the inputs to the formula first, so that only the formula + # runs with floating-point errors raised. + calc(sim, "carer_support_payment", year) + calc(sim, "carer_support_payment_pre_overlap", year) + with np.errstate(all="raise"): + supplement = calc(sim, "scottish_carer_supplement", year) + np.testing.assert_array_equal(supplement, 0, err_msg=str(year)) + + +REFORM_ON = Scenario( + applied_before_data_load=True, + parameter_changes={"gov.contrib.policyengine.disable_simulated_benefits": True}, +) + + +def test_disable_simulated_benefits_pays_the_weeks_the_report_implies(): + """Two carers in Scotland, aged 40 and caring 40 hours a week, each report + £4,000 of Carer's Allowance in 2025. The reform makes each report their + carer_support_payment in every year, without uprating it. The model finds + the carer who would claim eligible, so they keep 52 weeks; it finds the + one who would not claim ineligible, so they get the weeks the report + implies at each year's weekly rate.""" + years = range(2025, 2028) + people = { + name: { + "age": {year: 40 for year in years}, + "care_hours": {year: 40 for year in years}, + "would_claim_carers_allowance": {year: claims for year in years}, + "carers_allowance_reported": {2025: 4_000}, + } + for name, claims in [("claims", True), ("would_not_claim", False)] + } + situation = { + "people": people, + "benunits": {f"{name}_unit": {"members": [name]} for name in people}, + "households": { + f"{name}_home": { + "members": [name], + "region": {year: "SCOTLAND" for year in years}, + } + for name in people + }, + } + sim = Simulation(situation=situation, scenario=REFORM_ON) + expected = { + # 52 x 86.45 = 4,495.40 before the reduction. 52 x 11.70 = 608.40; + # 4,000 / 86.45 = 46.27 weeks, x 11.70 = 541.35. + 2026: ([4_495.40, 0], [608.40, 4_000 / 86.45 * 11.70]), + # 52 x 89.50 = 4,654 before the reduction. 52 x 12.10 = 629.20; + # 4,000 / 89.50 = 44.69 weeks, x 12.10 = 540.78. + 2027: ([4_654, 0], [629.20, 4_000 / 89.50 * 12.10]), + } + for year, (pre_overlap, supplement) in expected.items(): + np.testing.assert_allclose(calc(sim, "carer_support_payment", year), 4_000) + np.testing.assert_allclose( + calc(sim, "carer_support_payment_pre_overlap", year), + pre_overlap, + atol=TOLERANCE, + err_msg=str(year), + ) + np.testing.assert_allclose( + calc(sim, "scottish_carer_supplement", year), + supplement, + atol=TOLERANCE, + err_msg=str(year), + ) diff --git a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py index 7dae35df45..ce9f7bdd34 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py +++ b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py @@ -6,20 +6,53 @@ class scottish_carer_supplement(Variable): entity = Person label = "Scottish Carer Supplement" documentation = ( - "Paid for each week in which Carer Support Payment is payable, from 15 " - "March 2026. Where an overlapping benefit such as State Pension " - "reduces Carer Support Payment to £0, nothing is payable and no " - "supplement is paid; where it reduces the payment but leaves some, " - "the supplement is paid in full. It is a component of Carer Support " - "separate from the Carer Support Payment component. Pension Credit, " - "Housing Benefit and Scottish Council Tax Reduction, at pension age " - "and working age, do not count it as income; it is taxable." + "From 15 March 2026, Social Security Scotland pays the Scottish Carer " + "Supplement for each award week in which Carer Support Payment is " + "payable. The formula counts those weeks from annual amounts: it " + "divides the larger of carer_support_payment_pre_overlap and " + "carer_support_payment by the weekly Carer Support Payment rate, keeps" + " any part week and caps the count at the model's 52 weeks a year. It " + "pays the weekly supplement rate for each of those weeks, and nothing " + "where carer_support_payment is £0, as where an overlapping benefit " + "such as State Pension takes the payment to £0. Where the model " + "computes the amount before the overlapping-benefit reduction, that " + "amount comes to 52 weekly rates or nothing, so a carer whose " + "overlapping benefit reduces the payment but leaves some keeps all 52 " + "weeks. Where a user supplies the payment and the inputs give no " + "amount before the reduction, the formula divides the payment by the " + "weekly rate to count the weeks; a carer who moves to England, for " + "example, keeps Carer Support for up to 13 weeks, or up to 25 in all " + "where the cared-for person dies in that time. The count differs from " + "the law in four cases. Where an overlapping benefit or earnings vary " + "within the year, the formula counts 52 weeks or none from the annual " + "amounts, while Social Security Scotland pays the supplement only for " + "the award weeks in which the payment stays payable. Where a user " + "supplies a part-year payment and also sets inputs that make the " + "person eligible in Scotland, the formula counts 52 weeks, since the " + "model reads those inputs as a whole year. Where a user supplies a " + "payment that an overlapping benefit has reduced, the formula counts " + "fewer weeks than the award weeks in which that payment is payable, " + "unless the inputs also give the amount before the reduction. Under " + "gov.contrib.policyengine.disable_simulated_benefits, a Scottish " + "carer's reported Carer's Allowance becomes carer_support_payment " + "while the amount before the reduction stays the model's, so the " + "formula counts 52 weeks for a reported carer the model finds " + "eligible, even where the report covers only part of the year, and the" + " weeks the report implies at each year's weekly rate for any other " + "reported carer; the reform doesn't uprate the report, so a report of " + "a full year counts fewer weeks in later years. The supplement is a " + "component of Carer Support separate from the Carer Support Payment " + "component. Pension Credit, Housing Benefit and Scottish Council Tax " + "Reduction, at pension age and working age, do not count it as income;" + " it is taxable." ) definition_period = YEAR unit = GBP reference = [ + "https://www.legislation.gov.uk/ssi/2023/302/regulation/2", "https://www.legislation.gov.uk/ssi/2023/302/regulation/14A", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/42", "https://www.legislation.gov.uk/uksi/2026/246/article/17/made", "https://www.legislation.gov.uk/uksi/2026/246/article/20/made", "https://www.legislation.gov.uk/uksi/2026/246/article/21/made", @@ -29,6 +62,18 @@ class scottish_carer_supplement(Variable): ] def formula(person, period, parameters): - receives_csp = person("carer_support_payment", period) > 0 csp = parameters(period).gov.social_security_scotland.carer_support_payment - return receives_csp * csp.supplement * WEEKS_IN_YEAR + payable = person("carer_support_payment", period) + # The model computes the entitlement before the overlapping-benefit + # reduction as 52 weekly rates or nothing; a supplied payment with no + # such entitlement gives its own weeks. + amount = max_(person("carer_support_payment_pre_overlap", period), payable) + # 52 weekly rates in the amount's dtype, the value the model stores for + # a full year's entitlement, so that a full year counts exactly 52 + # weeks. The share of the year is the award weeks over 52. + full_year = np.asarray(csp.rate * WEEKS_IN_YEAR, dtype=amount.dtype) + has_rate = full_year > 0 + share = where(has_rate, amount / where(has_rate, full_year, 1), 0) + # The model counts 52 award weeks in a year. + year_of_supplement = csp.supplement * WEEKS_IN_YEAR + return where(payable > 0, year_of_supplement * min_(share, 1), 0) From cdf713a080bd7518c948efe0351bc2b15b7639ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:48:42 +0000 Subject: [PATCH 125/168] Name the actors, fix docs the input makes wrong, test the reported limb Name DWP and Social Security Scotland, not the provisions, as the actors, and attribute the carer element to UC Regs 2013 reg 29 with reg 30(1) defining the caring responsibilities. Say that the model applies the condition in Northern Ireland too. Fix the sentences that said the care-hours interface does not check the person cared for (disability-legacy-benefits.md) and the test comments that listed the reasons caring hours fail to explain an entitlement (carer_premium.yaml) or said is_carer_for_benefits reads only hours. Add a YAML case for a reported award above the earnings limit, which keeps is_carer_for_benefits and the carer element with the input false, and drop an assertion of the same-person default from the couple case, where it changes nothing. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/disability-legacy-benefits.md | 3 +- .../finance/benefit/carer_premium.yaml | 5 +- .../uc_childcare_work_condition_reg_32.yaml | 3 +- .../carers_allowance_cared_for_person.yaml | 101 ++++++++++++------ .../utils/carers_allowance_taper.py | 3 +- .../dwp/cares_for_severely_disabled_person.py | 44 +++++--- .../carer_support_payment_pre_couple_limit.py | 4 +- .../demographic/is_carer_for_benefits.py | 10 +- 8 files changed, 116 insertions(+), 57 deletions(-) diff --git a/docs/book/programs/gov/dwp/disability-legacy-benefits.md b/docs/book/programs/gov/dwp/disability-legacy-benefits.md index dc63b96fca..3496743b36 100644 --- a/docs/book/programs/gov/dwp/disability-legacy-benefits.md +++ b/docs/book/programs/gov/dwp/disability-legacy-benefits.md @@ -94,7 +94,8 @@ annual netting does not represent benefit spells at different weekly rates. The focused household tests retain live UC eligibility, disability/carer elements and final awards. Legacy receipt and disability-assessment inputs are assumptions, not a re-derivation of IB/SDA entitlement. The care-hours interface -does not identify the person cared for or check their qualifying benefit. No +does not identify the person cared for, and reads their qualifying benefit only +from the input `cares_for_severely_disabled_person`, which defaults to true. No private-data impact or Axiom parity has been measured. ## References diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml index 63eb07b0a2..851d941389 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml @@ -19,8 +19,9 @@ # partners are treated as caring for the same person unless both are entitled # with an award: a reported Carer's Allowance or Carer Support Payment award # (carers_allowance_reported carries both), or an entitlement that caring -# hours do not explain (fewer than 35 a week, or no claim), which was supplied -# directly. Most cases below with two premiums therefore give both partners a +# hours do not explain (fewer than 35 a week, care of someone who isn't +# severely disabled (cares_for_severely_disabled_person false), or no claim), +# which was supplied directly. Most cases below with two premiums therefore give both partners a # reported award. - name: Carer premium for a single claimant entitled to Carer's Allowance diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index 6add9f640f..0aee9af7f4 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -128,7 +128,8 @@ # allowance, so is a "severely disabled person" (SSCBA 1992 s. 70(2)). # Sara meets the Carer's Allowance conditions that reg. 30(1)(a) # imports, so "both Ben and Sara satisfy the paid work test" (ADM F7013). - # The model's is_carer_for_benefits reads only Sara's 40 care hours; it + # The model's is_carer_for_benefits reads Sara's 40 care hours and her + # cares_for_severely_disabled_person, left at its default of true; it # does not check her mother's attendance allowance. is_carer_for_benefits: [false, true, false, false] uc_childcare_work_condition: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml index f676012ff8..f79eee0ebf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml @@ -1,5 +1,5 @@ # Carer benefits for caring for a severely disabled person. -# SSCBA 1992 s.70(1): "A person shall be entitled to a carer's allowance for +# SSCBA 1992 s.70(1): "A person shall be entitled to a carer’s allowance for # any day on which he is engaged in caring for a severely disabled person". # s.70(2) defines a "severely disabled person" by the benefit payable for them: # attendance allowance, the disability living allowance care component at the @@ -9,16 +9,18 @@ # SSI 2023/302 reg 5(1): "Carer Support Payment may only be paid to an # individual in respect of a period during which that individual provides # regular and substantial care to a person to whom a qualifying disability -# benefit is normally payable". UC Regs 2013 reg 30(1)(a) gives the carer -# element to a person who "satisfy the conditions for entitlement to a -# carer's allowance", or would but for the earnings limit or residence in -# Scotland. +# benefit is normally payable". DWP includes the Universal Credit carer +# element (UC Regs 2013 reg 29) for a claimant with regular and substantial +# caring responsibilities for a severely disabled person, which a claimant +# has if "they satisfy the conditions for entitlement to a carer’s +# allowance", or would but for the earnings limit or residence in Scotland +# (reg 30(1)(a)), whether or not they claim the allowance (reg 30(2)). # # cares_for_severely_disabled_person records whether the person cared for # gets such a benefit. It defaults to true. False removes the route through # caring hours; a reported award (carers_allowance_reported) still counts. # -# The model reads each parameter at 30 April of the simulation year and +# The model reads these parameters at 30 April of the simulation year and # counts 52 weeks or 12 months in a year. For 2026: # - Carer's Allowance (gov.dwp.carers_allowance.rate, from 2026-04-01): # 86.45 a week, 86.45 * 52 = 4,495.40; @@ -43,8 +45,8 @@ - name: England carer of a partner with no qualifying disability benefit gets no Carer's Allowance # The carer cares 40 hours a week for their partner, who has no s.70(2) - # benefit, so the partner is not a severely disabled person and s.70(1) - # gives no allowance: 0 for both. Without a carer benefit or the hours + # benefit, so the partner is not a severely disabled person and DWP pays + # no allowance (s.70(1)): 0 for both. Without a carer benefit or the hours # route, the carer is not a carer for benefits either. period: 2026 absolute_error_margin: 0.01 @@ -95,11 +97,11 @@ is_carer_for_benefits: [true, false] - name: Scotland carer of a partner with no qualifying disability benefit gets no Carer Support Payment - # Reg 5(1) pays Carer Support Payment only for care of a person to whom a - # qualifying disability benefit is normally payable, and the partner has - # none: 0. The supplement follows a payable Carer Support Payment: 0. From - # 2025 Carer Support Payment replaces Carer's Allowance in Scotland, so - # Carer's Allowance is 0 too. + # Social Security Scotland pays Carer Support Payment only for care of a + # person to whom a qualifying disability benefit is normally payable (reg + # 5(1)), and the partner has none: 0. The model pays the supplement only + # with a payable Carer Support Payment: 0. From 2025 Carer Support Payment + # replaces Carer's Allowance in Scotland, so Carer's Allowance is 0 too. period: 2026 absolute_error_margin: 0.01 input: @@ -148,11 +150,10 @@ - name: Universal Credit claimant caring for someone with no qualifying disability benefit has no carer element # The claimant would not claim Carer's Allowance, so no allowance enters - # the award as income; reg 30(2) gives the carer element whether or not the - # claimant claims the allowance. The person cared for is not severely - # disabled, so the claimant fails the Carer's Allowance conditions that - # reg 30(1)(a) applies: no carer element. The award is the standard - # allowance alone, 424.90 * 12 = 5,098.80. + # the award as income, and the carer element needs no claim (reg 30(2)). + # The person cared for is not severely disabled, so the claimant fails the + # Carer's Allowance conditions in reg 30(1)(a): no carer element. The award + # is the standard allowance alone, 424.90 * 12 = 5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -199,9 +200,9 @@ universal_credit: 7_610.88 - name: A reported Carer's Allowance award counts whatever the input says - # The Department pays a reported award, so the input false leaves it: the - # carer gets the weekly rate all year, 86.45 * 52 = 4,495.40, not the - # reported amount. + # The Department for Work and Pensions pays a reported award, so the input + # false leaves it: the carer gets the weekly rate all year, 86.45 * 52 = + # 4,495.40, not the reported amount. period: 2026 absolute_error_margin: 0.01 input: @@ -251,14 +252,53 @@ carer_support_payment: [4_495.40, 0] scottish_carer_supplement: [608.40, 0] +- name: A reported award above the earnings limit keeps the carer element whatever the input says + # The claimant reports a Carer's Allowance award but earns 20,000 a year. + # Income tax takes 20% (gov.hmrc.income_tax.rates.uk) of 20,000 less the + # 12,570 personal allowance, 0.2 * 7,430 = 1,486, and employee National + # Insurance takes 8% (gov.hmrc.national_insurance.class_1.rates.employee + # .main) above the primary threshold of 241.73 a week (12,569.96 a year), + # 0.08 * 7,430.04 = 594.40. Net earnings come to (20,000 - 1,486 - 594.40) + # / 52 = 344.61 a week, above the 204 earnings limit + # (gov.dwp.carers_allowance.earnings_limit, from 2026-04-06), so the + # claimant is gainfully employed and DWP pays no allowance (s.70(1)(b)): + # 0. The reported award still counts as caring for a severely disabled + # person whatever the input says, and a claimant over the earnings limit + # still has the caring responsibilities the carer element needs (reg + # 30(1)(a)(i)), so the claimant is a carer for benefits and the element is + # 209.34 * 12 = 2,512.08. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + employment_income: 20_000 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + is_entitled_to_carer_benefit: false + is_carer_for_benefits: true + uc_carer_element: 2_512.08 + - name: Couple who both care give the allowance only to the partner who cares for a severely disabled person # Both care 40 hours a week. The elder, the benefit unit head, cares for # someone with no qualifying disability benefit, so only the younger meets # the caring condition and has the allowance, 86.45 * 52 = 4,495.40. With - # no reported or supplied award, the default still treats them as caring - # for the same person, and the elder's hours explain no entitlement, so - # the couple restriction leaves the younger's award alone. One carer - # premium: 48.15 * 52 = 2,503.80. + # the input left out the elder would have the couple's one award. With one + # partner entitled, the couple restriction has no choice to make, whatever + # partners_care_for_same_severely_disabled_person says. One carer premium: + # 48.15 * 52 = 2,503.80. period: 2026 absolute_error_margin: 0.01 input: @@ -280,7 +320,6 @@ country: ENGLAND output: is_claimant_or_partner: [true, true] - partners_care_for_same_severely_disabled_person: true carers_allowance_pre_couple_limit: [0, 4_495.40] carers_allowance: [0, 4_495.40] is_entitled_to_carer_benefit: [false, true] @@ -320,10 +359,12 @@ carer_premium: 5_007.60 - name: The same couple with the input left out has one carer premium - # Now the younger's 40 hours, for someone the model takes to be severely - # disabled, explain the younger's entitlement, so it is not an award. One - # award leaves the default true, and caring for the same person caps the - # premium at one amount: 48.15 * 52 = 2,503.80. + # The model's same-person default reads an entitlement that caring hours + # explain as no award, since hours can't show whom a carer cares for. Now + # the younger's 40 hours, for someone the model takes to be severely + # disabled, explain the younger's entitlement. One award leaves the + # default true, and caring for the same person caps the premium at one + # amount: 48.15 * 52 = 2,503.80. period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index c7ea727f3e..ef7d5ecd7c 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -46,7 +46,8 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): receives_ca = person("carers_allowance_reported", period) > 0 meets_age_condition = person("age", period) >= ca.min_age # SSCBA 1992 s. 70(1): the hours count only for caring for a severely - # disabled person (s. 70(2)), which the input takes to hold by default. + # disabled person (s. 70(2)), which the model takes to hold unless the + # input says otherwise. meets_work_condition = (person("care_hours", period) >= ca.min_hours) & person( "cares_for_severely_disabled_person", period ) diff --git a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py index 4211410b8d..fba05bc9a4 100644 --- a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py +++ b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py @@ -12,24 +12,30 @@ class cares_for_severely_disabled_person(Variable): "that SI 1976/409 reg 3 prescribes) or a qualifying disability " "benefit for Carer Support Payment (SSI 2023/302 regs 2 and 5(1)). " "The Department for Work and Pensions pays Carer's Allowance only for " - "caring for a severely disabled person (s.70(1)), Social Security " + "caring for a severely disabled person (s.70(1)), and the model " + "applies the same condition in Northern Ireland. Social Security " "Scotland pays Carer Support Payment only for care of a person to " - "whom a qualifying disability benefit is normally payable (reg 5(1)), " - "and the Universal Credit carer element needs the Carer's Allowance " - "conditions, apart from the earnings limit and residence in Scotland " - "(UC Regs 2013 reg 30(1)(a)). Caring hours in the survey count care " - "for anyone and don't say whom the carer cares for, and the person " - "cared for can live elsewhere, so the model doesn't derive this from " - "the household's disability benefits: it takes the person cared for " - "to qualify unless an input says otherwise, and the default is true. " - "The model reads it only on the route through caring hours " - "(care_hours at the minimum) to Carer's Allowance, Carer Support " - "Payment and is_carer_for_benefits, and so the Universal Credit " - "carer element, and in the hours that explain an entitlement in " - "partners_care_for_same_severely_disabled_person. A reported award " + "whom a qualifying disability benefit is normally payable (reg 5(1)). " + "DWP includes the Universal Credit carer element (UC Regs 2013 reg " + "29) for a claimant with regular and substantial caring " + "responsibilities for a severely disabled person: one who meets the " + "Carer's Allowance conditions, or would but for the earnings limit or " + "residence in Scotland, or who has an entitlement to Carer Support " + "Payment (reg 30(1)). Caring hours in the survey " + "count care for anyone and don't say whom the carer cares for, and " + "the person cared for can live elsewhere, so the model doesn't derive " + "this from the household's disability benefits: it takes the person " + "cared for to qualify unless this input says otherwise, and the " + "default is true. The model reads it only where care_hours reach the " + "minimum: on the route through caring hours to Carer's Allowance, " + "Carer Support Payment and is_carer_for_benefits (and so the " + "Universal Credit carer element), and in " + "partners_care_for_same_severely_disabled_person, which counts a " + "supplied entitlement as a separate award unless caring hours for a " + "severely disabled person explain it. A reported award " "(carers_allowance_reported, the model's reported-receipt input for " "both benefits) still counts whatever this input says, since the " - "Department or Social Security Scotland pays it. Set it to false for " + "department or Social Security Scotland pays it. Set it to false for " "a carer whose caring hours go to someone with none of those " "benefits." ) @@ -40,6 +46,10 @@ class cares_for_severely_disabled_person(Variable): title="Social Security Contributions and Benefits Act 1992 s. 70(1) and (2)", href="https://www.legislation.gov.uk/ukpga/1992/4/section/70", ), + dict( + title="Social Security Contributions and Benefits (Northern Ireland) Act 1992 s. 70", + href="https://www.legislation.gov.uk/ukpga/1992/7/section/70", + ), dict( title="The Social Security (Invalid Care Allowance) Regulations 1976 reg. 3", href="https://www.legislation.gov.uk/uksi/1976/409/regulation/3", @@ -52,6 +62,10 @@ class cares_for_severely_disabled_person(Variable): title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 5(1)", href="https://www.legislation.gov.uk/ssi/2023/302/regulation/5", ), + dict( + title="The Universal Credit Regulations 2013 reg. 29", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + ), dict( title="The Universal Credit Regulations 2013 reg. 30(1)(a)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/30", diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py index d491929f34..80aa3b657c 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py @@ -31,8 +31,8 @@ def formula(person, period, parameters): meets_age_condition = person("age", period) >= csp.min_age weekly_care_hours = person("care_hours", period) # Reg 5(1): only care of a person to whom a qualifying disability - # benefit is normally payable counts, which the input takes to hold - # by default. + # benefit is normally payable counts, which the model takes to hold + # unless the input says otherwise. meets_hours = (weekly_care_hours >= csp.min_hours) & person( "cares_for_severely_disabled_person", period ) diff --git a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py index 3c66fdeda7..7a01840354 100644 --- a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py @@ -21,11 +21,11 @@ class is_carer_for_benefits(Variable): "cares_for_severely_disabled_person holds: the person cared for gets " "a benefit that makes them a severely disabled person for Carer's " "Allowance (SSCBA 1992 s.70(1) and (2)), one of the Carer's Allowance " - "conditions that regulation 30(1)(a) applies. The input defaults to " - "true, because caring hours in the survey don't say whom the carer " - "cares for, so at the default a survey hours field counts caring for " - "anyone and a dataset's take-up input carries that simplification. A " - "household calculation sets it false for a carer whose hours go to " + "conditions in regulation 30(1)(a). The input defaults to true, " + "because caring hours in the survey don't say whom the carer cares " + "for, so at the default a survey hours field counts caring for anyone " + "and a dataset's take-up input carries that simplification. Set it to " + "false in a household calculation for a carer whose hours go to " "someone with no such benefit. " "A reported Carer's Allowance award the person would claim also " "counts, so a carer whose earnings put them over the Carer's Allowance " From b39075893365a6b6d52fb4bcc03ef52160d42375 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:56:09 +0000 Subject: [PATCH 126/168] Test that a supplied entitlement counts as an award once hours stop counting Without a supplied entitlement the input can't change partners_care_for_same_severely_disabled_person, so the properties gave no evidence about explained_by_hours. Add a property over couples whose entitlements an input supplies, true or false, for every person: the flag is false exactly where both partners are entitled and have an award that caring hours for a severely disabled person don't explain. State the scope of the other invariants, name the actors in the module docstring, and raise the tapered earner to 13,500 so the 2031 taper still pays a partial award if the national living wage projection moves. Co-Authored-By: Claude Opus 5.5 --- .../finance/benefit/carer_premium.yaml | 2 +- ...for_severely_disabled_person_properties.py | 220 +++++++++++++----- 2 files changed, 166 insertions(+), 56 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml index 851d941389..4d508cec72 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml @@ -21,7 +21,7 @@ # (carers_allowance_reported carries both), or an entitlement that caring # hours do not explain (fewer than 35 a week, care of someone who isn't # severely disabled (cares_for_severely_disabled_person false), or no claim), -# which was supplied directly. Most cases below with two premiums therefore give both partners a +# which an input supplied directly. Most cases below with two premiums therefore give both partners a # reported award. - name: Carer premium for a single claimant entitled to Carer's Allowance diff --git a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py index 6d863ed915..d7668af6e9 100644 --- a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py +++ b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py @@ -1,60 +1,80 @@ """Carer benefits pay on caring hours only for care of a severely disabled person. -SSCBA 1992 s.70(1) entitles a person to Carer's Allowance for caring for a -"severely disabled person", whom s.70(2) defines by the benefit payable for -them. SSI 2023/302 reg 5(1) pays Carer Support Payment only for care of a -person "to whom a qualifying disability benefit is normally payable". UC Regs -2013 reg 30(1)(a) gives the carer element to a person who meets the Carer's +The Department for Work and Pensions pays Carer's Allowance only for caring +for a "severely disabled person" (SSCBA 1992 s.70(1)), defined by the benefit +payable for them (s.70(2)). Social Security Scotland pays Carer Support +Payment only for care of a person "to whom a qualifying disability benefit is +normally payable" (SSI 2023/302 reg 5(1)). DWP includes the Universal Credit +carer element (UC Regs 2013 reg 29) for a claimant who meets the Carer's Allowance conditions, or would but for the earnings limit or living in -Scotland. The input cares_for_severely_disabled_person records whether the -person cared for gets such a benefit. It defaults to true and gates only the -route through caring hours: a reported award (carers_allowance_reported) -still counts. +Scotland (reg 30(1)(a)). The input cares_for_severely_disabled_person records +whether the person cared for gets such a benefit. It defaults to true, and +the model reads it only on the route through caring hours: a reported award +(carers_allowance_reported) still counts. Invariants, for benefit units of one or two adults and up to one qualifying young person, each with drawn caring hours, reported award, take-up, earnings and overlapping benefit, in each country, in 2024 (Scotland still on Carer's Allowance), 2026, 2030 (the year the Carer's Allowance earnings -taper starts) and 2031: - -- leaving the input out gives exactly the values that setting it true gives, - for carers_allowance, carer_support_payment, is_carer_for_benefits, - is_entitled_to_carer_benefit, uc_carer_element, - partners_care_for_same_severely_disabled_person and - carer_minimum_guarantee_addition; -- setting one person's input false never raises that person's - carers_allowance or carer_support_payment and never turns that person's - is_carer_for_benefits or is_entitled_to_carer_benefit on; it never raises - the benefit unit's uc_carer_element or carer_minimum_guarantee_addition, - and never turns partners_care_for_same_severely_disabled_person on; -- before the taper, setting every person's input false never raises any - person's carers_allowance or carer_support_payment, or the benefit unit's - amounts, and never turns any of those flags on; -- a person whose input is false and who has no reported award gets no - Carer's Allowance or Carer Support Payment, before or after the - overlapping-benefit adjustment, and is neither entitled to a carer benefit - nor a carer for benefits; -- setting the input false for the people with a reported award leaves every - person's carers_allowance and carer_support_payment as they were. - -Partners who care for the same person share one award (SSCBA 1992 -s.70(7ZA); SSI 2023/302 reg 5(3)). So when one partner's input goes false, -the model can give the award to the other partner, whose amount and -entitlement then rise, as the law has it; the second invariant therefore +taper starts) and 2031, where no input supplies a carer benefit amount or +entitlement: + +1. leaving the input out gives exactly the values that setting it true + gives, for carers_allowance, carer_support_payment, is_carer_for_benefits, + is_entitled_to_carer_benefit, uc_carer_element, + partners_care_for_same_severely_disabled_person and + carer_minimum_guarantee_addition; +2. setting one person's input false never raises that person's + carers_allowance or carer_support_payment and never turns that person's + is_carer_for_benefits or is_entitled_to_carer_benefit on; it never raises + the benefit unit's uc_carer_element or carer_minimum_guarantee_addition, + and never turns partners_care_for_same_severely_disabled_person on; +3. before the taper, setting every person's input false never raises any + person's carers_allowance or carer_support_payment, or the benefit unit's + amounts, and never turns any of those flags on; +4. a person whose input is false and who has no reported award gets no + Carer's Allowance or Carer Support Payment, before or after the + overlapping-benefit adjustment, and is neither entitled to a carer benefit + nor a carer for benefits; +5. setting the input false for the people with a reported award leaves every + person's carers_allowance and carer_support_payment as they were. + +And for couples where an input supplies each partner's entitlement to a +carer benefit, true or false: + +6. partners_care_for_same_severely_disabled_person is false exactly where + both partners have an entitlement and an award: a reported award, or an + entitlement that caring hours for a severely disabled person don't explain + (fewer than the minimum hours, the input false, or no claim). Leaving the + input out gives what setting it true gives, and setting inputs false never + turns the flag on. + +Only one of two partners who care for the same person can have the award +(SSCBA 1992 s.70(7ZA); SSI 2023/302 reg 5(3)). So when one partner's input +goes false, the model can give the award to the other partner, whose amount +and entitlement then rise, as the law has it; the second invariant therefore compares only the person whose input changes, and the benefit unit. Under -the earnings taper the partners' awards can differ, and the restriction gives -the award to the larger: setting both inputs false can then give back a -reported award that the restriction took for the other partner's larger one, -so the third invariant holds only before the taper. The examples below run -both cases. - -The households supply none of the carer benefit amounts or entitlements that -the model also takes as inputs: a value supplied for one person fills every -person in the batched simulation. - -Each example builds every benefit unit twice in one simulation, in separate -households: once as drawn, and once with the input changed. +the earnings taper the partners' awards can differ, and the model gives the +award to the partner with the larger one: setting both inputs false can then +give the other partner back a reported award, so the third invariant holds +only before the taper. Two fixed tests at the end run both cases. + +The first five invariants need households where no input supplies a carer +benefit entitlement or amount. Without one, the input can't change +partners_care_for_same_severely_disabled_person, so the sixth invariant +supplies the entitlements. A supplied entitlement that caring hours no longer +explain counts as a second award: it can turn that flag false and give the +couple a second carer premium or Universal Credit carer element, as the YAML +case "A supplied entitlement whose caring hours go to someone not severely +disabled counts as a second award" shows. Batching needs the same care: +supplying a value for one person makes it an input for every person in the +simulation, so the others take its default, not the formula. The sixth +invariant's households supply the entitlement for every person. + +Tests 1, 2, 3, 5 and 6 build each benefit unit more than once in one +simulation, in separate households: as drawn, and with the input changed. +Test 4 builds each unit once, with each member's input false or left out. """ import numpy as np @@ -87,10 +107,12 @@ def carer_inputs(draw, earns=True): "care_hours": draw(st.sampled_from([0, 20, 34, 35, 40, 40, 60])), "carers_allowance_reported": draw(st.sampled_from([0, 0, AWARD])), "would_claim_carers_allowance": draw(st.sampled_from([True, True, False])), - # Below the earnings limit, above it by less than the rate (so the - # taper pays some allowance), and far above it. + # From 2026: below the earnings limit, above it by less than the rate + # (13,500 exceeds it by 50.60 a week in 2026 and 21.70 in 2031, so the + # taper pays some allowance), and far above it. The lower 2024 limit + # puts 8,000 and 13,500 above it, by 2.84 and 103.60 a week. "employment_income": ( - draw(st.sampled_from([0, 0, 0, 8_000, 12_500, 30_000])) if earns else 0 + draw(st.sampled_from([0, 0, 0, 8_000, 13_500, 30_000])) if earns else 0 ), # Above the carer rates, so an overlapping benefit can reduce the # payment to nil and leave the entitlement. @@ -151,7 +173,33 @@ def with_input(unit, values): return {**unit, "members": members} -def calculate(units, year, variables): +@st.composite +def couples_with_supplied_entitlements(draw): + """A couple in its own household, where an input supplies each partner's + entitlement to a carer benefit, true or false, and each partner's input + is false or left out (None).""" + head_age = draw(st.integers(18, 85)) + oldest_partner = min(85, head_age + 19) if head_age >= 20 else head_age + 15 + ages = [head_age, draw(st.integers(max(18, head_age - 15), oldest_partner))] + members = [ + { + "age": age, + "is_parent": False, + "is_entitled_to_carer_benefit": draw(st.booleans()), + **draw(carer_inputs()), + } + for age in ages + ] + return { + "members": members, + "adults": 2, + "country": draw(st.sampled_from(COUNTRIES)), + "chosen": draw(st.integers(0, 1)), + "inputs": [draw(st.sampled_from([False, None])) for _ in members], + } + + +def simulate(units, year): people, benunits, households = {}, {}, {} for i, unit in enumerate(units): names = [] @@ -164,8 +212,13 @@ def calculate(units, year, variables): sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) - values = {name: sim.calculate(name, year) for name in variables} offsets = np.cumsum([0] + [len(unit["members"]) for unit in units]) + return sim, offsets + + +def calculate(units, year, variables): + sim, offsets = simulate(units, year) + values = {name: sim.calculate(name, year) for name in variables} return values, offsets @@ -217,13 +270,32 @@ def calculate(units, year, variables): "age": 40, "care_hours": 40, "carers_allowance_reported": AWARD, - "employment_income": 12_500, + "employment_income": 13_500, }, {**ADULT, "age": 38, "care_hours": 40}, ], "inputs": [False, False], } SCOTTISH_COUPLE = {**COUPLE, "country": "SCOTLAND"} +# The elder has a reported award; the younger cares 40 hours a week for +# someone not severely disabled. Each has a supplied entitlement, so the +# younger's counts as a second award once the input says the hours don't +# explain it. +SUPPLIED_COUPLE = { + **COUPLE, + "members": [ + { + **ADULT, + "age": 40, + "care_hours": 40, + "carers_allowance_reported": AWARD, + "is_entitled_to_carer_benefit": True, + }, + {**ADULT, "age": 38, "care_hours": 40, "is_entitled_to_carer_benefit": True}, + ], + "chosen": 1, + "inputs": [None, False], +} @SETTINGS @@ -335,6 +407,44 @@ def test_reported_award_pays_the_same_whatever_the_input(drawn, year): assert np.array_equal(values[name][:n], values[name][n:]), name +@SETTINGS +@given( + st.lists(couples_with_supplied_entitlements(), min_size=1, max_size=6), + st.sampled_from(YEARS), +) +@example(drawn=[SUPPLIED_COUPLE, {**SUPPLIED_COUPLE, "country": "SCOTLAND"}], year=2026) +def test_a_supplied_entitlement_counts_as_an_award_unless_hours_explain_it(drawn, year): + true = [with_input(unit, [True, True]) for unit in drawn] + changed = [with_input(unit, unit["inputs"]) for unit in drawn] + sim, offsets = simulate(drawn + true + changed, year) + same = sim.calculate("partners_care_for_same_severely_disabled_person", year) + claimant_or_partner = sim.calculate("is_claimant_or_partner", year) + gov = sim.tax_benefit_system.parameters(year).gov + k = len(drawn) + assert claimant_or_partner.all() + for i, unit in enumerate(drawn): + assert same[k + i] == same[i], unit + assert same[i] or not same[2 * k + i], unit + # The minimum hours of the benefit that applies: Carer Support + # Payment's in Scotland from 2025, Carer's Allowance's otherwise. + if unit["country"] == "SCOTLAND" and year >= 2025: + min_hours = gov.social_security_scotland.carer_support_payment.min_hours + else: + min_hours = gov.dwp.carers_allowance.min_hours + awards = 0 + for member, value in zip(unit["members"], unit["inputs"]): + explained = ( + member["care_hours"] >= min_hours + and value is not False + and member["would_claim_carers_allowance"] + ) + reported = member["carers_allowance_reported"] > 0 + awards += member["is_entitled_to_carer_benefit"] and ( + reported or not explained + ) + assert same[2 * k + i] == (awards < 2), unit + + def _couple_values(units, year, variables): values, _ = calculate(units, year, variables) return {name: [round(float(x), 2) for x in values[name]] for name in variables} @@ -369,8 +479,8 @@ def test_award_passes_to_the_partner_who_cares_for_a_severely_disabled_person(): def test_under_the_taper_both_inputs_false_can_give_back_a_reported_award(): # The case that the third invariant leaves out. In 2031 the elder's # tapered award falls short of the younger's award on caring hours, so - # the couple restriction gives the younger the award. With both inputs - # false the younger has none, and the elder keeps the reported award. + # the model gives the younger the couple's award. With both inputs false + # the younger has none, and the elder keeps the reported award. names = ["carers_allowance_pre_couple_limit", "carers_allowance"] default = _couple_values([TAPERED_COUPLE], 2031, names) both_false = _couple_values( From af0b602ca3170417bf306e162adcd130f94f60f6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 07:05:54 -0500 Subject: [PATCH 127/168] Say why the cut's own year sets the VAT coverage, and date later rows main's October 2026 EFO gives 2025-26 outturn, so "the latest outturn year" no longer names 2024-25. The doc now gives the reason that holds under both EFOs, and says that the 2025-26 and 2026-27 rows come from policyengine-uk 2.157.1 on the March 2026 EFO. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 8a63f02c15..b64edecc23 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -152,10 +152,18 @@ Unrounded (£): standard-rated base 407,399,379,048, 422,514,711,572 and ### Value -The parameter takes 0.69 (product 0.483), the 2024-25 value: as in the -[`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026), the -latest outturn year sets it. Receipts for later years come from OBR forecasts, -so the factor stays undated. The coverage falls 3.4% from 2024-25 to 2026-27. +The parameter takes 0.69 (product 0.483). The 2024-25 value sets it, as in +the [`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026): +2024-25 is the cut's own year, which `apply_uprating` leaves as the file gives +it, and OBR's figure for it is outturn. Later years add the model's uprating +and, beyond outturn, OBR's forecasts, so the factor stays undated. On the +March 2026 EFO's receipts the coverage falls 3.4% from 2024-25 to 2026-27. + +The runs here predate the October 2026 EFO that `main` now uses. That EFO +gives 2025-26 outturn of £179.861bn and forecasts £186.154bn for 2026-27, and +`main` uprates consumption with its October forecasts. The 2025-26 and 2026-27 +rows here record policyengine-uk 2.157.1, which used the March 2026 EFO for +both. ### Totals at 0.67 and 0.69 From b60b686e9e5d107a1ff10938d6b29a1fe79820e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:03:01 +0000 Subject: [PATCH 128/168] Rewrap the carer premium test header Co-Authored-By: Claude Opus 5.5 --- .../tests/policy/baseline/finance/benefit/carer_premium.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml index 4d508cec72..0873c9466c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml @@ -21,8 +21,8 @@ # (carers_allowance_reported carries both), or an entitlement that caring # hours do not explain (fewer than 35 a week, care of someone who isn't # severely disabled (cares_for_severely_disabled_person false), or no claim), -# which an input supplied directly. Most cases below with two premiums therefore give both partners a -# reported award. +# which an input supplied directly. Most cases below with two premiums +# therefore give both partners a reported award. - name: Carer premium for a single claimant entitled to Carer's Allowance period: 2026 From c79f7e6092102043b8fd3838cc3bd5f7ac57d3ab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 08:40:18 -0500 Subject: [PATCH 129/168] Cite reg 2 for the award week in the supplement tests SSI 2023/302 defines "award week" in regulation 2, which has no numbered paragraphs. Co-Authored-By: Claude Opus 5.5 --- .../gov/social_security_scotland/scottish_carer_supplement.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml index af6556f492..e460f3d35c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml @@ -59,7 +59,7 @@ # Award weeks. SSI 2023/302 reg 14A: "An individual is entitled to Scottish # Carer Supplement for each award week in which Carer Support Payment is -# payable to the individual." In reg 2(2), "award week" means "a period of 7 +# payable to the individual." In reg 2, "award week" means "a period of 7 # days beginning on a Sunday and ending on a Saturday". The Scottish Ministers # set the weekly rates in reg 16(1) and (1A): Carer Support Payment 86.45 and # Scottish Carer Supplement 11.70. Under reg 42(1) and (5), a carer who moves From 1e1d87e4fd3d9394f0299716b924e5d92179811e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:44:04 +0000 Subject: [PATCH 130/168] Check the reported limb of is_carer_for_benefits in the properties Setting the input false for people with a reported award now also leaves is_carer_for_benefits as it was, except for a reported recipient who would not claim. A mutant that gates the reported limb with the input passed every property before; this assertion fails on it. Co-Authored-By: Claude Opus 5.5 --- ...for_severely_disabled_person_properties.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py index d7668af6e9..2a5ba00304 100644 --- a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py +++ b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py @@ -38,7 +38,9 @@ overlapping-benefit adjustment, and is neither entitled to a carer benefit nor a carer for benefits; 5. setting the input false for the people with a reported award leaves every - person's carers_allowance and carer_support_payment as they were. + person's carers_allowance and carer_support_payment as they were, and + every person's is_carer_for_benefits too, except for a reported recipient + who would not claim, whose route through caring hours the input closes. And for couples where an input supplies each partner's entitlement to a carer benefit, true or false: @@ -401,10 +403,24 @@ def test_reported_award_pays_the_same_whatever_the_input(drawn, year): ) for unit in drawn ] - values, offsets = calculate(drawn + toggled, year, PERSON_AMOUNTS) + variables = PERSON_AMOUNTS + ["is_carer_for_benefits"] + values, offsets = calculate(drawn + toggled, year, variables) n = offsets[len(drawn)] for name in PERSON_AMOUNTS: assert np.array_equal(values[name][:n], values[name][n:]), name + # A reported award the person would claim keeps them a carer for benefits + # whatever the input says, as the reported limb of is_carer_for_benefits + # has it; a reported recipient who would not claim relies on the hours. + claims = np.array( + [ + member["carers_allowance_reported"] == 0 + or member["would_claim_carers_allowance"] + for unit in drawn + for member in unit["members"] + ] + ) + carer = values["is_carer_for_benefits"] + assert np.array_equal(carer[:n][claims], carer[n:][claims]) @SETTINGS From 892911c810cc99c40759c850e27027f7405eb03b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:05:35 +0000 Subject: [PATCH 131/168] Exclude own Income Support recipients from the Child Benefit QYP test Child Benefit (General) Regulations 2006 reg. 8(2)(a) lists Income Support among the benefits whose receipt in a young person's own right ends the qualifying young person status, on every route. The test now reads receives_income_support_in_own_right: the person's own reported Income Support while Income Support is in payment and no reform neutralises income_support. It reads the report, not is_on_income_support, because the family's Income Support reads this test and the calculated award would make a cycle. The variable's documentation names the readers at this head: the Child Benefit test, and through it the Housing Benefit and legacy young person; Pension Credit reads is_on_income_support instead. It names the active parameter as one of the ways a reform ends the exclusion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- .../cb-qyp-school-leaver-routes.fixed.md | 2 +- ...alifying_young_person_for_child_benefit.py | 11 ++++-- .../receives_income_support_in_own_right.py | 39 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 policyengine_uk/variables/household/income/receives_income_support_in_own_right.py diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md index e1566ac616..7dcf048000 100644 --- a/changelog.d/cb-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance is not one (reg. 8). +Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7), which the Housing Benefit, legacy benefit and Council Tax Reduction young persons follow: a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, two new inputs place the 20-week extension period and the terminal date, and Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance, or their own Income Support while DWP pays it, is not one, on any route (reg. 8). diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 5d5b157ff4..63ff9225a5 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -21,11 +21,12 @@ class is_qualifying_young_person_for_child_benefit(Variable): "starts before one starts before the other. Interruptions (reg. 6) " "are not modelled. Reg. 8 reads the own-right benefits input, which " "covers Universal Credit, Employment and Support Allowance and " - "Jobseeker's Allowance, and the person's own reported Universal " + "Jobseeker's Allowance, the person's own reported Universal " "Credit, Employment and Support Allowance of either kind and " "income-based Jobseeker's Allowance, the awards reg. 8(2)(b), (e) " - "and (f) list. It does not read the Income Support, incapacity " - "benefit and tax credits that reg. 8 also lists." + "and (f) list, and receives_income_support_in_own_right (reg. " + "8(2)(a)). It does not read the incapacity benefit and tax credits " + "that reg. 8 also lists." ) definition_period = YEAR reference = [ @@ -80,4 +81,8 @@ def formula(person, period, parameters): | before_terminal_date ) & ~receives_own_benefits + # Reg. 8(2)(a). The reported award, not is_on_income_support: + # the family's Income Support reads this test, so the calculated + # award would make a cycle. + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py new file mode 100644 index 0000000000..87c46dbac2 --- /dev/null +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class receives_income_support_in_own_right(Variable): + value_type = bool + entity = Person + label = "Receives Income Support in own right" + documentation = ( + "Whether this person receives Income Support on a claim of their own, " + "as the Child Benefit qualifying young person test reads it (Child " + "Benefit (General) Regulations 2006 reg. 8(2)(a)); through that test " + "it also ends the Housing Benefit and legacy young person. It is the " + "person's own reported Income Support (income_support_reported) " + "while Income Support is in payment (gov.dwp.income_support.active) " + "and no reform neutralises income_support; set it directly otherwise. " + "For a member who is not the claimant or partner, " + "is_on_income_support reads the same report. For a claimant or " + "partner, is_on_income_support reads the benefit unit's calculated " + "award, for whichever of the claimant and partner receives it, but " + "that award depends on who the family's Child Benefit young persons " + "are, so this variable cannot read it without a cycle. A reform that " + "removes Income Support must set gov.dwp.income_support.active to " + "false, neutralise income_support, or set reported awards to zero, " + "for the Child Benefit status to follow; replacing its formula with " + "zero leaves this variable true. Pension Credit (SPC Regs 2002 reg. " + "4A(5)) reads is_on_income_support instead, so it follows such a " + "reform for a claimant or partner. Universal Credit's list (UC Regs " + "2013 reg. 5(5)) does not include Income Support." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/8" + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + # A reform that removes Income Support leaves no award to receive. + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized + return active & (not removed) & (person("income_support_reported", period) > 0) From d15f64d324b9013ab5cd682aaad12cef7fc0234b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:24 +0000 Subject: [PATCH 132/168] Add Child Benefit cases for Income Support in the young person's own right Cases 19 to 26 of child_benefit_school_leaver_routes.yaml, each through child_benefit and household_net_income: - 19 and 20: in 2018, a 16-year-old school leaver who cares for a parent on PIP and reports their own Income Support is not a qualifying young person (reg. 8(2)(a)): Child Benefit 0 and household net income 6,188.30, against 1,076.40 and 7,264.70 without the report. - 21 to 23: the same bar on the extension period (reg. 5), the terminal date (reg. 7) and the education route (reg. 3). - 24: a reform that neutralises income_support lifts it. - 25: in 2026-27, when gov.dwp.income_support.active is false, a report does not count. - 26: a lone parent's own Income Support claim leaves their 16-year-old a qualifying young person. The 2018 cases set the cost-of-living payment to zero. A small reform module in the tests lets the YAML case neutralise income_support. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- .../tests/income_support_reforms.py | 10 + .../child_benefit_school_leaver_routes.yaml | 310 +++++++++++++++++- 2 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 policyengine_uk/tests/income_support_reforms.py diff --git a/policyengine_uk/tests/income_support_reforms.py b/policyengine_uk/tests/income_support_reforms.py new file mode 100644 index 0000000000..1692439402 --- /dev/null +++ b/policyengine_uk/tests/income_support_reforms.py @@ -0,0 +1,10 @@ +"""Reforms that policy YAML tests apply by dotted path (the `reforms` key).""" + +from policyengine_core.reforms import Reform + + +class neutralise_income_support(Reform): + """A reform that removes Income Support by neutralising income_support.""" + + def apply(self): + self.neutralize_variable("income_support") diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index 385a88be11..e71577b661 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -5,7 +5,8 @@ # reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative # work). Reg. 2(2): a qualifying young person until the last applicable # condition ceases to be met. Reg. 8: not while receiving a benefit in their -# own right. +# own right, on any route; Cases 19 to 26 cover Income Support (reg. +# 8(2)(a)). # # Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for # each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the @@ -440,3 +441,310 @@ is_child_or_young_person_for_legacy_benefits: [false, false] child_benefit_entitlement: 0 is_lone_parent: false + +# Cases 19 to 26: Income Support in the young person's own right (reg. +# 8(2)(a)). receives_income_support_in_own_right reads the person's own +# reported Income Support while DWP pays Income Support +# (gov.dwp.income_support.active) and no reform neutralises income_support. +# A year's period reads that parameter's value on 30 April, so it is true +# for 2025-26 and false from 2026-27 (DWP made the last payments on 31 March +# 2026). +# +# 2018-19 rates: Child Benefit £20.70 a week for the eldest child and £13.70 +# for each other child; PIP daily living standard rate £57.30; Carer's +# Allowance £64.60; Income Support personal allowance for a lone parent aged +# 18 or over £73.10; colour TV licence £150.50. The 2018 cases set the +# cost-of-living payment, which DWP first paid in 2022, to zero. In Cases 19 +# to 22, 24 and 25 a parent aged 40 on PIP daily living lives with a young +# person who has left education and cares for them 35 hours a week. No +# family claims tax credits or Universal Credit, so Child Benefit is the only +# award the young person's status changes. + +- name: Case 19, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. + is_before_first_september_following_16th_birthday: [false, true] + is_on_income_support: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit: 0 + # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; + # less TV licence 150.50: 6,188.30. The model calculates Income Support + # only for the claimant's family, so the 16-year-old's own 3,000 is in no + # income here. + household_net_income: 6_188.30 + +- name: Case 20, the same family without the 16-year-old's Income Support gets Child Benefit for them. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 20.70 x 52. + child_benefit: 1_076.40 + # Case 19's 6,188.30 + 1,076.40 = 7,264.70. + household_net_income: 7_264.70 + +- name: Case 21, a 17-year-old in an extension period who receives Income Support in their own right is not a qualifying young person (reg. 5, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 17 + date_of_birth: 20010115 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + # Born 15 January 2001: the reg. 4 period ended on 31 August 2017. + is_before_first_september_following_16th_birthday: [false, false] + meets_qualifying_young_person_extension_period_condition_for_child_benefit: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # As Case 19: 2,979.60 + 3,359.20 - 150.50 = 6,188.30. + household_net_income: 6_188.30 + +- name: Case 22, an 18-year-old before the terminal date who receives Income Support in their own right is not a qualifying young person (reg. 7, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 18 + date_of_birth: 20000115 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + is_before_first_september_following_16th_birthday: [false, false] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # As Case 19: 2,979.60 + 3,359.20 - 150.50 = 6,188.30. + household_net_income: 6_188.30 + +- name: Case 23, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 3, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + student: + age: 17 + date_of_birth: 20010115 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + households: + household: + members: [parent, student] + cost_of_living_support_payment: 0 + output: + # Full-time non-advanced education begun before 19 (reg. 3). + meets_qualifying_young_person_education_or_training_condition_for_child_benefit: [false, true] + meets_qualifying_young_person_entry_condition_for_child_benefit: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # PIP 57.30 x 52 = 2,979.60, less TV licence 150.50: 2,829.10. + household_net_income: 2_829.10 + +- name: Case 24, under a reform that neutralises income_support, Case 19's report does not end the status. + period: 2018 + absolute_error_margin: 0.01 + reforms: + - policyengine_uk.tests.income_support_reforms.neutralise_income_support + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + is_on_income_support: [false, false] + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 20.70 x 52. + child_benefit: 1_076.40 + # As Case 20: 6,188.30 + 1,076.40 = 7,264.70. + household_net_income: 7_264.70 + +- name: Case 25, in 2026-27, after DWP stopped paying Income Support, a report of it does not end the status. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20100115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + # Born 15 January 2010: the reg. 4 period runs to 31 August 2026. + is_before_first_september_following_16th_birthday: [false, true] + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 27.05 x 52. + child_benefit: 1_406.60 + # PIP 76.70 x 52 = 3,988.40; Carer's Allowance 86.45 x 52 = 4,495.40; + # Child Benefit 1,406.60; Household Energy Payment 100; less TV licence + # 180: 9,810.40. + household_net_income: 9_810.40 + +- name: Case 26, a 16-year-old school leaver whose lone parent claims Income Support for the family, and who has none of their own, stays a qualifying young person. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + child: + age: 4 + is_claimant_or_partner: false + school_leaver: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, child, school_leaver] + would_claim_uc: false + # The family takes up no free childcare, so the 4-year-old's + # universal hours add nothing to household income. + would_claim_universal_childcare: false + households: + household: + members: [parent, child, school_leaver] + cost_of_living_support_payment: 0 + output: + is_on_income_support: [true, false, false] + receives_income_support_in_own_right: [true, false, false] + is_qualifying_young_person_for_child_benefit: [false, false, true] + is_child_or_young_person_for_legacy_benefits: [false, true, true] + # A lone parent of a child under 5 with no income: the personal + # allowance, 73.10 x 52 = 3,801.20. + income_support: 3_801.20 + # (20.70 + 13.70) x 52 = 1,788.80. + child_benefit: 1_788.80 + # 3,801.20 + 1,788.80, less TV licence 150.50: 5,439.50. + household_net_income: 5_439.50 From db3cdae7597c064326b7e0499303f01735479c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 133/168] Hold the Child Benefit charge and personal allowance steps in invariant 5 The model raises CB_HITC by whole percentage points of the Child Benefit and rounds the tapered personal allowance up to whole pounds, so a small rise in pay can raise the tax the earnings add by more than the rise. Invariant 5 now holds CB_HITC and personal_allowance beside the Marriage Allowance and personal savings allowance cliffs, records an event for each kind it crosses, draws council tax of up to 30,000, and always runs three families: a lone parent across a step of the charge, a single person across a step of the personal allowance, and a single person in the starter band. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- ..._tax_reduction_tax_deduction_properties.py | 130 +++++++++++++++--- 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 7b1ac5377e..0f358576ce 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -22,7 +22,7 @@ """ import numpy as np -from hypothesis import HealthCheck, event, given, settings +from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -72,9 +72,9 @@ def maybe(high): def simulate(households, reform=None): """One simulation of households. Each household is a dict with country, benunits (a list of lists of person input dicts; the first benunit is - the Council Tax Reduction applicant's) and, optionally, uc and + the Council Tax Reduction applicant's) and, optionally, uc, child_benefit (whether the applicant's benefit unit claims Child - Benefit).""" + Benefit) and council_tax (2,000 where the household gives none).""" situation = {"people": {}, "benunits": {}, "households": {}} for h, household in enumerate(households): names = [] @@ -98,7 +98,7 @@ def simulate(households, reform=None): "members": names, "country": {YEAR: country}, "region": {YEAR: REGIONS[country]}, - "council_tax": {YEAR: 2_000.0}, + "council_tax": {YEAR: household.get("council_tax", 2_000.0)}, "savings": {YEAR: 0.0}, } return Simulation(situation=situation, reform=reform) @@ -367,31 +367,110 @@ def test_scottish_working_age_deducts_nothing_without_earnings(households): assert np.all(deduction == 0), (households, deduction) +@st.composite +def household_with_council_tax(draw, **kwargs): + """A household from household(), with council tax of up to 30,000, so that + families keep a reduction across the whole range of pay.""" + return {**draw(household(**kwargs)), "council_tax": draw(money(30_000))} + + +# The Hypothesis event invariant 5 records for each cliff or step it holds. +CROSSES = { + "marriage_allowance": "crosses the Marriage Allowance cliff", + "savings_allowance": "crosses a personal savings allowance cliff", + "CB_HITC": "crosses a High Income Child Benefit Charge step", + "personal_allowance": "crosses a personal allowance step", +} +# Invariant 5 runs these three families every time. A lone parent with two +# children whose pay of 10,000 rises by 1 takes adjusted net income from +# 64,999.50 to 65,000.50, across a step of the High Income Child Benefit +# Charge (560 to 584). +LONE_PARENT_AT_A_CHARGE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[ + [ + dict( + age=40, + employment_income=10_000.0, + savings_interest_income=40_000.0, + other_investment_income=14_999.50, + ), + dict(age=4), + dict(age=7), + ] + ], +) +# A single person with pay of 90,000 and savings interest of 20,001.75, whose +# pay rises by 0.50, takes adjusted net income across 110,002, where the +# tapered personal allowance falls from 7,570 to 7,569. +SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=30_000.0, + benunits=[ + [dict(age=40, employment_income=90_000.0, savings_interest_income=20_001.75)] + ], +) +# A single person whose pay rises from 13,000 to 14,000, within the Scottish +# starter band: the deduction rises by 190 of income tax and 80 of Class 1. +SINGLE_PERSON_IN_THE_STARTER_BAND = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[[dict(age=40, employment_income=13_000.0)]], +) + + @PROPERTY_SETTINGS @given( st.lists( - household(countries=("SCOTLAND",), pensioner=False), min_size=1, max_size=4 + household_with_council_tax(countries=("SCOTLAND",), pensioner=False), + min_size=1, + max_size=4, ), money(20_000), st.booleans(), ) +@example(households=[LONE_PARENT_AT_A_CHARGE_STEP], rise=1.0, partner=False) +@example( + households=[SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP], rise=0.5, partner=False +) +@example(households=[SINGLE_PERSON_IN_THE_STARTER_BAND], rise=1_000.0, partner=False) def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): """Invariant 5. At working age in Scotland without Universal Credit, raising either member's employment income never raises Council Tax - Reduction once the income tax system's cliffs are held still. Two cliffs - raise the tax the earnings add by more than the rise in pay, and option - (a) passes both through to the deduction: losing Marriage Allowance, and - the personal savings allowance falling as the person enters a higher - band. A Scottish couple, one partner with no income and council tax of - 15,000, whose pay rises from 43,661 to 43,663 loses Marriage Allowance: - the deduction rises by 265.39 and the reduction by 52.68 (the YAML cases - pin both). The test therefore holds marriage_allowance and - savings_allowance at their values before the rise in two more + Reduction once the test holds the income tax system's cliffs and steps + still. Option (a) passes each of them through to the deduction, and each + can raise the tax the earnings add by more than the rise in pay: + - Marriage Allowance: an earner who enters the higher band loses the + allowance, which the model takes off taxable income. A Scottish couple, + one partner with no income and council tax of 15,000, whose pay rises + from 43,661 to 43,663 loses 1,260 of it: the deduction rises by 265.39 + and the reduction by 52.68 (the YAML cases pin both). + - The personal savings allowance: it falls from 1,000 to 500 as a person + enters the higher band, and to nil in the additional band, so a pound + of pay can bring 500 of savings income into tax at 40%, adding 200. + - The High Income Child Benefit Charge: the model charges a whole + percentage point of the Child Benefit for each whole 200 of adjusted + net income above 60,000, so a pound of pay that crosses a step raises + the charge by a percent of the Child Benefit, 23 or 24 pounds for two + children. + - The personal allowance: the model rounds the tapered allowance up to a + whole pound, so a rise in pay of less than 2 can lower the allowance by + a pound, and the tax then rises on the rise plus that pound. + The test holds marriage_allowance, savings_allowance, CB_HITC and + personal_allowance at their values before the rise in two more simulations, one of the families before the rise and one of the raised families, and asserts, for every family, that the reduction in the second does not exceed the reduction in the first. Because income_tax_with_inputs_zeroed keeps stored inputs on its branches, the - branch without the pay then reads the same held values on both sides.""" + branch without the pay then reads the same held values on both sides. The + test draws council tax of up to 30,000, and always runs three families: + a lone parent who crosses a step of the charge, a single person who + crosses a step of the personal allowance, and a single person in the + starter band.""" raised = [] for h in households: adults = [dict(a) for a in h["benunits"][0]] @@ -403,10 +482,15 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p before = simulate(households) after = simulate(raised) # income_tax_with_inputs_zeroed keeps stored inputs on its branches, so - # hold the cliffs on both sides. + # hold the cliffs and steps on both sides. held_before = simulate(households) held = simulate(raised) - cliffs = ["marriage_allowance", "savings_allowance"] + cliffs = [ + "marriage_allowance", + "savings_allowance", + "CB_HITC", + "personal_allowance", + ] for variable in cliffs: values = np.asarray(before.calculate(variable, YEAR)) held_before.set_input(variable, YEAR, values) @@ -416,13 +500,17 @@ def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, p held_before.calculate("council_tax_reduction", YEAR) ) reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) - cliff = np.zeros(len(households), dtype=bool) + # The model moves each of these in steps, so any change crosses one. + crossed_any = np.zeros(len(households), dtype=bool) for variable in cliffs: - cliff |= ~np.isclose( + crossed = ~np.isclose( applicant_total(before, variable), applicant_total(after, variable) ) - for crossed in cliff: - event("crosses a tax cliff" if crossed else "no tax cliff") + crossed_any |= crossed + if crossed.any(): + event(CROSSES[variable]) + if not crossed_any.all(): + event("crosses no cliff or step") assert np.all(reduction_held <= reduction_held_before + 0.01), ( households, rise, From 534028ce5caf8e978e0aeee4053d7d334b289088 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 134/168] Pin a step of the Child Benefit charge at working age in Scotland Two cases put a Scottish lone parent with two children on either side of the 25% step of the charge, at pay of 64,999 and 65,000. The pound of pay raises the charge from 560 to 584, the deduction by 24.44 and the reduction by 4.69. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- .../council_tax_reduction_tax_deduction.yaml | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 65551db1af..05d1b9cb5f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -791,3 +791,82 @@ council_tax_reduction_applicable_income: 34_855.05 # 15,000 - 20% x (34,855.05 - 7,807.80): the reduction rises by 52.68. council_tax_reduction: 9_590.55 + +- name: Scotland's working-age scheme passes a Child Benefit charge step through at pay of 64,999 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 64_999 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 64,999 - 12,570 = 52,429: 3,967 x 19% = 753.73, 12,989 x 20% = + # 2,597.80, 14,136 x 21% = 2,968.56 and 21,337 x 42% = 8,961.54, so + # 15,281.63. Child Benefit (27.05 + 17.90) x 52 = 2,337.40, 2,337 in whole + # pounds. Adjusted net income exceeds 60,000 by 4,999, or 24 whole steps + # of 200, so the charge takes 24% of 2,337 = 560.88, 560 in whole pounds. + # T = 15,841.63. Without the pay, nil. Class 1: 37,700 x 8% = 3,016 up to + # the upper earnings limit of 966.73 a week (50,269.96 a year), and + # (64,999 - 50,269.96) x 2% = 294.58, so 3,310.58. 15,841.63 + 3,310.58 = + # 19,152.21. + CB_HITC: [560, 0, 0] + council_tax_reduction_tax_deduction: 19_152.21 + # The model counts the pay and the Child Benefit: 64,999 + 2,337.40 - + # 19,152.21. + council_tax_reduction_applicable_income: 48_184.19 + # 15,000 - 20% x (48,184.19 - the lone parent's allowance 95.55 x 52 = + # 4,968.60). + council_tax_reduction: 6_356.88 + +- name: Scotland's working-age scheme passes a Child Benefit charge step through at pay of 65,000 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 65_000 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 52,430: one more pound at 42%, so 15,282.05. Adjusted net income + # exceeds 60,000 by 5,000, or 25 steps of 200, so the charge takes 25% of + # 2,337 = 584.25, 584 in whole pounds. T = 15,866.05. Class 1 3,016 + + # (65,000 - 50,269.96) x 2% = 294.60, so 3,310.60. 15,866.05 + 3,310.60 = + # 19,176.65: a pound of pay raises the deduction by 24.44, and the charge + # gives 24 of that. + CB_HITC: [584, 0, 0] + council_tax_reduction_tax_deduction: 19_176.65 + # 65,000 + 2,337.40 - 19,176.65. + council_tax_reduction_applicable_income: 48_160.75 + # 15,000 - 20% x (48,160.75 - 4,968.60): the reduction rises by 4.69. + council_tax_reduction: 6_361.57 From 678a9975d81ac725762bc2f4c15ded82308997b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:11:37 +0000 Subject: [PATCH 135/168] Name who acts in the older notes on the deduction's rules The tax deduction YAML header, the two property modules' docstrings, the Scottish notional deduction's documentation and the pension-age carer case now name the Scottish Ministers, the Secretary of State, the Welsh Ministers, Parliament or the council as the actor, and keep each citation. The edits sit outside every test function, so no test's Hypothesis seed changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJWZ6tzriTP45hUip8p --- ...l_tax_reduction_carer_support_payment.yaml | 24 +++++++------ .../council_tax_reduction_tax_deduction.yaml | 35 ++++++++++--------- ...l_tax_reduction_carer_income_properties.py | 21 +++++------ ..._tax_reduction_tax_deduction_properties.py | 13 +++---- ...ion_scotland_self_employment_deductions.py | 23 ++++++------ 5 files changed, 62 insertions(+), 54 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index c4c5f44b96..37cc2e7206 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -10,13 +10,14 @@ # Neither counts the Scottish Carer Supplement, paid with CSP from 15 March # 2026: reg 57(1) does not list it and reg 27(1)(j)(xxib) excepts it. In law # the supplement's tax does not reach Council Tax Reduction either: at working -# age SSI 2021/249 deducts tax only from earnings, and at pension age SSI -# 2012/319 reg 31(12) disregards the tax payable on income taken into -# account, which the supplement is not. The model deducts only the tax each -# scheme allows (council_tax_reduction_tax_deduction). At working age that is -# the tax earnings add, and at pension age the tax on the counted income with -# the supplement above it, so without earnings the supplement does not reach -# the model's Council Tax Reduction income. +# age the Scottish Ministers let the council deduct tax only from earnings (SSI +# 2021/249), and at pension age the council disregards the tax payable on +# income taken into account (SSI 2012/319 reg 31(12)), which the supplement is +# not. The model deducts only the tax each scheme allows +# (council_tax_reduction_tax_deduction). At working age that is the tax +# earnings add, and at pension age the tax on the counted income with the +# supplement above it, so without earnings the supplement does not reach the +# model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -277,10 +278,11 @@ # first 3,967 at the Scottish starter rate of 19% and 2,566.80 at 20%. # The supplement adds 608.40 x 20% = 121.68 of that tax. income_tax: 3_967 * 0.19 + 2_566.80 * 0.20 - # SSI 2012/319 reg 31(12) disregards only the tax on income taken into - # account. The supplement is not, so its tax, as the highest part of - # income, is not deducted: 14,000 + 4,495.40 - 12,570 = 5,925.40 taxable, - # 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so 1,145.41. + # Under SSI 2012/319 reg 31(12), the council disregards only the tax on + # income taken into account. The supplement is not, so the formula leaves + # out its tax, as the highest part of income: 14,000 + 4,495.40 - 12,570 = + # 5,925.40 taxable, 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so + # 1,145.41. council_tax_reduction_tax_deduction: 3_967 * 0.19 + 1_958.40 * 0.20 # 14,000 + 4,495.40 - 1,145.41: neither the supplement nor its tax counts. council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_145.41 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 05d1b9cb5f..d15515a265 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -1,20 +1,23 @@ # Income tax and National Insurance deducted from Council Tax Reduction income. -# - Scotland, working age (SSI 2021/249): reg 50(3)(a) reads "Earnings do not -# include— (a) any amount deducted from them by way of income tax or -# primary Class 1 contributions under the 1992 Act". The Scottish Ministers -# set the same rule with Universal Credit (regs 49(6)(b) and 51(2) step 3) -# and gave no tax deduction for unearned income (regs 57 to 65). Without -# Universal Credit, reg 51(9) applies SSI 2012/303 regs 37 and 38, which -# price the tax and contributions on self-employed profit notionally: the -# basic rate on chargeable income less only the personal allowance, and -# Class 4 at the main rate between the lower and upper limits. -# - Pension age: SSI 2012/319 reg 31(12), "In the case of any income taken -# into account for the purpose of calculating a person's income, any amount -# payable by way of tax must be disregarded"; SI 2012/2885 Sch 1 para 17(13) -# and WSI 2013/3029 Sch 1 para 11(13) read the same. Reg 33(2)(a), para -# 19(2)(a) and para 13(2)(a) deduct the income tax and Class 1 deducted from -# earnings, and reg 37, para 30 and para 24 price self-employed profit -# notionally, as at working age. +# - Scotland, working age (SSI 2021/249): in reg 50(3)(a), the Scottish +# Ministers provide that "Earnings do not include— (a) any amount deducted +# from them by way of income tax or primary Class 1 contributions under the +# 1992 Act". They set the same rule with Universal Credit (regs 49(6)(b) and +# 51(2) step 3) and gave no tax deduction for unearned income (regs 57 to +# 65). Without Universal Credit, in reg 51(9) they apply SSI 2012/303 regs 37 +# and 38, under which the council prices the tax and contributions on +# self-employed profit notionally: the basic rate on chargeable income less +# only the personal allowance, and Class 4 at the main rate between the +# lower and upper limits. +# - Pension age: in SSI 2012/319 reg 31(12), the Scottish Ministers provide +# that "In the case of any income taken into account for the purpose of +# calculating a person's income, any amount payable by way of tax must be +# disregarded"; the Secretary of State (SI 2012/2885 Sch 1 para 17(13)) and +# the Welsh Ministers (WSI 2013/3029 Sch 1 para 11(13)) make the same rule. +# Under reg 33(2)(a), para 19(2)(a) and para 13(2)(a), the council deducts +# the income tax and Class 1 deducted from earnings, and under reg 37, para +# 30 and para 24 it prices self-employed profit notionally, as at working +# age. # No provision says how to split one income tax liability. The model follows # the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 # (option (a)): the tax on earnings is the extra tax the earnings cause, with diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index e8b5775481..ade0b6792c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -13,16 +13,17 @@ Neither counts the Scottish Carer Supplement, paid with Carer Support Payment from 15 March 2026: reg 57(1) is a closed list that leaves it out, and reg 27(1)(j)(xxib) excepts it. Nor does the tax on the supplement count: at -working age SSI 2021/249 deducts income tax only from earnings (Part 6 -Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax -payable on income taken into account, which the supplement is not. The -supplement is taxable (SI 2026/93). The model deducts the tax and National -Insurance each scheme allows (council_tax_reduction_tax_deduction). At working -age that includes the extra tax earnings cause, with the earnings as the top -slice of income (the choice settled for Pension Credit on -PolicyEngine/policyengine-uk#2019, option (a)), so the supplement beneath the -earnings can raise the tax the earnings add. At pension age the formula -deducts the tax on the counted income with the supplement above it. +working age the Scottish Ministers let the council deduct income tax only from +earnings (SSI 2021/249 Part 6 Chapter 3), and at pension age the council +disregards the tax payable on income taken into account (SSI 2012/319 reg +31(12)), which the supplement is not. The supplement is taxable (SI 2026/93). +The model deducts the tax and National Insurance each scheme allows +(council_tax_reduction_tax_deduction). At working age that includes the extra +tax earnings cause, with the earnings as the top slice of income (the choice +settled for Pension Credit on PolicyEngine/policyengine-uk#2019, option (a)), +so the supplement beneath the earnings can raise the tax the earnings add. At +pension age the formula deducts the tax on the counted income with the +supplement above it. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 0f358576ce..fa30fa08de 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -1,11 +1,12 @@ """Income tax and National Insurance deducted from Council Tax Reduction income. -Scotland's working-age scheme (the Council Tax Reduction (Scotland) Regulations -2021, SSI 2021/249) takes income tax and primary Class 1 contributions off -earnings only (regs 49(6)(b), 50(3)(a) and 51(2)); its unearned income rules -(regs 57 to 65) deduct no tax. Without Universal Credit, reg 51(9) applies the -notional tax and contributions of SSI 2012/303 reg 38 to self-employed profit. -The pensioner schemes disregard tax only on income taken into account and on +In Scotland's working-age scheme (the Council Tax Reduction (Scotland) +Regulations 2021, SSI 2021/249), the Scottish Ministers let the council take +income tax and primary Class 1 contributions off earnings only (regs 49(6)(b), +50(3)(a) and 51(2)), and give no tax deduction against unearned income (regs 57 +to 65). Without Universal Credit, in reg 51(9) they apply the notional tax and +contributions of SSI 2012/303 reg 38 to self-employed profit. In the pensioner +schemes, the council disregards tax only on income taken into account and on earnings: SSI 2012/319 regs 31(12), 33(2)(a) and 37 in Scotland, SI 2012/2885 Sch 1 paras 17(13), 19(2)(a) and 30 in England, and WSI 2013/3029 Sch 1 paras 11(13), 13(2)(a) and 24 in Wales. diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 7f69c57f22..59d3918bb9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -10,12 +10,13 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "Council Tax Reduction" ) documentation = ( - "The income tax and social security contributions that Scotland's " - "Council Tax Reduction schemes deduct from self-employed earnings in " - "place of those actually payable: SSI 2012/303 regs 37 and 38, which " - "SSI 2021/249 reg 51(9) applies at working age where nobody in the " - "family has Universal Credit, and SSI 2012/319 regs 36 and 37 at " - "pension age. The formula charges notional income tax at the basic " + "The income tax and social security contributions that a Scottish " + "council deducts from self-employed earnings for Council Tax " + "Reduction in place of those actually payable: under SSI 2012/303 " + "regs 37 and 38, which the Scottish Ministers apply in SSI 2021/249 " + "reg 51(9) at working age where nobody in the family has Universal " + "Credit, and under SSI 2012/319 regs 36 and 37 at pension age. The " + "formula charges notional income tax at the basic " "rate (the formula reads the Scottish basic rate for a Scottish " "taxpayer; both rates are 20%) on the profit " "less only the personal allowance; Class 4 contributions at the main " @@ -29,11 +30,11 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "reduces 'the allowance under subsection (1)'. At pension age, they " "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " "Parliament omitted s. 36 (Finance Act " - "2014), and s. 37 gives the section 35 amount only to a person born " - "before 6 April 1938; read literally, the reference gives nobody " - "younger any relief. The formula reads it as the same tapered s. 35 " - "allowance for every applicant. A " - "child minder's chargeable income is one-third of the earnings; the " + "2014), and in s. 37 gives the section 35 amount only to a person " + "born before 6 April 1938; a council that read the reference " + "literally would allow nobody younger any relief. The formula reads " + "it as the same tapered s. 35 allowance for every applicant. A child " + "minder's chargeable income comes to one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." ) From bec8d91bb71090aed45676678e99fe10f958420f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:16:09 +0000 Subject: [PATCH 136/168] Test the Child Benefit Income Support gate on drawn households test_own_income_support_ends_the_status_on_every_route draws families in 2016, 2019 and 2024: parents who may claim Income Support as carers or lone parents, young people and children with every education value, the three leaver facts, hours, the other own-award reports and their own Income Support. A person who reports their own Income Support is never a Child Benefit qualifying young person (invariant 6). Without a report, setting receives_income_support_in_own_right false as an input leaves the status unchanged, and with it set false the status is the one without the report (invariant 7). Every draw also holds YAML Case 19's family, so each example has a status to lose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ...benefit_school_leaver_routes_properties.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py index a29e48450f..6d4dcc2747 100644 --- a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py +++ b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py @@ -30,6 +30,12 @@ work. They stay a qualifying young person, and a legacy young person. 5. Child Benefit entitlement is the eldest payable member at the eldest rate and each other at the additional rate, for 52 weeks. +6. Reg. 8(2)(a): in a year when DWP pays Income Support, a person who + reports Income Support of their own is never a qualifying young person, + on any route, whoever else in the family claims it. +7. Without a report, setting receives_income_support_in_own_right false as + an input leaves the status unchanged; with it set false, a report + changes nothing, so the status is the one without the report. """ from datetime import date, timedelta @@ -325,3 +331,103 @@ def test_child_benefit_entitlement_counts_payable_members(units, year): else rates.amount.eldest + (number - 1) * rates.amount.additional ) np.testing.assert_allclose(actual, weekly * 52, rtol=1e-6) + + +# --- Invariants 6 and 7: Income Support in the young person's own right --- + +INCOME_SUPPORT = "receives_income_support_in_own_right" +OWN_REPORTS = ( + "universal_credit_reported", + "esa_income_reported", + "esa_contrib_reported", + "jsa_income_reported", + "jsa_contrib_reported", +) + + +def family_member(age, **inputs): + defaults = { + "income_support_reported": 0, + "care_hours": 0, + ROUTE: False, + **{report: 0 for report in OWN_REPORTS}, + } + return person(age, **{**defaults, **inputs}) + + +young_or_child = st.builds( + family_member, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + is_apprentice=st.booleans(), + receives_benefits_in_own_right=st.sampled_from([False, False, False, True]), + weekly_hours=st.sampled_from([0, 23, 24, 40]), + care_hours=st.sampled_from([0, 35]), + income_support_reported=st.sampled_from([0, 0, 1, 3_000]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, + **{report: st.sampled_from([0, 0, 0, 2_000]) for report in OWN_REPORTS}, +) +# A parent who can claim Income Support for the family: a carer, or a lone +# parent of a child under 5 when the draw gives them one. +parent = st.builds( + family_member, + st.integers(25, 60), + is_claimant_or_partner=st.just(True), + care_hours=st.sampled_from([0, 35]), + income_support_reported=st.sampled_from([0, 3_000]), +) + + +@st.composite +def families(draw): + if draw(st.booleans()): + parents = draw(st.lists(parent, min_size=1, max_size=2)) + return parents + draw(st.lists(young_or_child, min_size=1, max_size=4)) + # A young person who claims for their own benefit unit. + young = draw(young_or_child.filter(lambda p: p["age"] >= 16)) + others = draw(st.lists(young_or_child, max_size=2)) + return [dict(young, is_claimant_or_partner=True)] + others + + +def case_19_family(): + # child_benefit_school_leaver_routes.yaml Case 19: a 16-year-old school + # leaver, in the reg. 4 period, who cares for a parent and reports their + # own Income Support. + return [ + family_member(40, is_claimant_or_partner=True), + family_member( + 16, care_hours=35, income_support_reported=3_000, **{ROUTE: True} + ), + ] + + +@SETTINGS +@given( + st.lists(families(), min_size=1, max_size=6), st.sampled_from([2016, 2019, 2024]) +) +def test_own_income_support_ends_the_status_on_every_route(units, year): + # Every draw also holds Case 19's family, so there is a status to lose. + units = units + [case_19_family()] + reported = np.array( + [p["income_support_reported"] > 0 for unit in units for p in unit] + ) + sim = simulate(units, year) + # The family's Income Support reads this test through the legacy young + # person; calculating it first shows the test does not read it back. + sim.calculate("income_support", year) + status = sim.calculate(QYP, year) + # Invariant 6, in 2016, 2019 and 2024, when DWP paid Income Support. + assert not status[reported].any() + released = simulate( + [[dict(p, **{INCOME_SUPPORT: False}) for p in unit] for unit in units], year + ).calculate(QYP, year) + unreported = simulate( + [[dict(p, income_support_reported=0) for p in unit] for unit in units], year + ).calculate(QYP, year) + # Invariant 7. + np.testing.assert_array_equal(released[~reported], status[~reported]) + np.testing.assert_array_equal(released, unreported) + # Case 19's 16-year-old has the status without the report, and loses it + # to the report alone. + assert released[-1] and not status[-1] From 3d5ef701ee7cbeb2b1182984b345bef89d43ed99 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:35:42 +0000 Subject: [PATCH 137/168] Read the Child Benefit reg. 8 reports in the Housing Benefit cap oracle Housing Benefit's young person is the Child Benefit qualifying young person (HB Regs 2006 reg. 19), and the Child Benefit test reads the person's own reported Universal Credit, ESA and income-based JSA (reg. 8(2)(b), (e), (f)). reference_specified_benefit and reference_cap still counted such a young person for Housing Benefit. On a claimant aged 40 with one dependant and a non-dependant aged 30 in 2025, the old oracle disagreed with the model in 10 of 24 scenarios (8 Housing Benefit exemptions and 2 caps for a 17-year-old, 22,020 against 14,753); the new one agrees in all 24. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ...nefit_cap_specified_benefits_properties.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index aab13ec717..5872996bd9 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -284,6 +284,15 @@ def own_benefits(m): return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) +def child_benefit_own_benefits(m): + # Child Benefit (General) Regulations 2006 reg 8(2)(b), (e), (f). + return any( + m.get(report, 0) > 0 + for report in OWN_RIGHT_REPORTS + if report != "jsa_contrib_reported" + ) + + def reference_specified_benefit(members, benunit_inputs, scheme): """UC Regs 2013 reg 83(1) or HB Regs 2006 reg 75F(1), read directly, with roles known by construction.""" @@ -296,13 +305,19 @@ def reference_specified_benefit(members, benunit_inputs, scheme): young_roles = ("young_person", "sixteen") # The UC predicate supplements its qualifying-young-person test with a # separate age-16 approximation, which reads only the explicit own-right - # flag. Reported awards remove older UC young persons, but do not change - # that approximation or the Housing Benefit test. + # flag, so reported awards remove only older UC young persons. The Child + # Benefit test, which Housing Benefit follows, removes anyone who reports + # their own Universal Credit, ESA or income-based JSA (Child Benefit + # (General) Regulations 2006 reg 8(2)(b), (e), (f)). young_persons = [ m for m in members if m["_role"] in young_roles - and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) + and ( + (m["age"] == 16 or not own_benefits(m)) + if scheme == "uc" + else not child_benefit_own_benefits(m) + ) ] children = [m for m in members if m["_role"] == "child"] # No capital, so the couple's income-related award is their reports, @@ -411,15 +426,14 @@ def reference_cap(members, scheme): # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit reg 4 # and its school-leaver terminal dates, which annual ages cannot place); it does # not yet apply reg 5(5) to one receiving benefits in their own right. - # Older UC young persons cease to count when they report their own award; - # Housing Benefit continues to read the existing Child Benefit test. + # Older young persons cease to count when they report their own award: + # for UC any award (reg 5(5)), for Housing Benefit the awards the Child + # Benefit test reads (Child Benefit (General) Regulations 2006 reg 8). claimants = sum(m["_role"] == "claimant" for m in members) + reports = own_benefits if scheme == "uc" else child_benefit_own_benefits dependants = any( m["_role"] in ("child", "sixteen", "sixteen_own_right") - or ( - m["_role"] == "young_person" - and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) - ) + or (m["_role"] == "young_person" and (m["age"] == 16 or not reports(m))) for m in members ) return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP From 3eb64dfc4b5af21a910eaa3b80b57a62938cf8ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:36:24 +0000 Subject: [PATCH 138/168] Test the reg. 8 reports on the education route and for contributory ESA Case 27: a 17-year-old at school who reports their own contributory ESA is not a Child Benefit qualifying young person (reg. 3 with reg. 8(2)(e)). Case 28: a 16-year-old school leaver who reports contribution-based JSA stays one, since reg. 8(2)(b) lists income-based JSA only. The test's documentation now says that a positive report for the year ends the status for the whole year, where reg. 8(2) tests receipt in a week. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- .../child_benefit_school_leaver_routes.yaml | 48 ++++++++++++++++++- ...alifying_young_person_for_child_benefit.py | 3 +- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml index e71577b661..571728fc4d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -5,8 +5,9 @@ # reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative # work). Reg. 2(2): a qualifying young person until the last applicable # condition ceases to be met. Reg. 8: not while receiving a benefit in their -# own right, on any route; Cases 19 to 26 cover Income Support (reg. -# 8(2)(a)). +# own right, on any route. Cases 17, 18, 27 and 28 cover reported Universal +# Credit, ESA and JSA awards (reg. 8(2)(b), (e) and (f)), and Cases 19 to 26 +# Income Support (reg. 8(2)(a)). # # Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for # each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the @@ -748,3 +749,46 @@ child_benefit: 1_788.80 # 3,801.20 + 1,788.80, less TV licence 150.50: 5,439.50. household_net_income: 5_439.50 + +- name: Case 27, a 17-year-old at school who reports their own contributory ESA is not a qualifying young person (reg. 3 with reg. 8(2)(e)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + esa_contrib_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 28, a 16-year-old school leaver who reports contribution-based JSA stays a qualifying young person (reg. 8(2)(b) lists income-based JSA only). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # 27.05 x 52 = 1,406.60. + child_benefit_entitlement: 1_406.60 diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 63ff9225a5..efc736e199 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -26,7 +26,8 @@ class is_qualifying_young_person_for_child_benefit(Variable): "income-based Jobseeker's Allowance, the awards reg. 8(2)(b), (e) " "and (f) list, and receives_income_support_in_own_right (reg. " "8(2)(a)). It does not read the incapacity benefit and tax credits " - "that reg. 8 also lists." + "that reg. 8 also lists. A positive report for the year ends the " + "status for the whole year." ) definition_period = YEAR reference = [ From 817ee7d45352d43c54e9229dc3a78d47b720d008 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:41:12 +0000 Subject: [PATCH 139/168] Say how the CTC test and the Income Support variable read receipt is_on_income_support gives the benefit unit's calculated award to whichever of the claimant and partner receives it, so the CTC test's documentation says so, and names gov.dwp.income_support.active among the ways a reform ends another member's exclusion. receives_income_support_in_own_right's documentation names Child Tax Credit beside Pension Credit as a reader of is_on_income_support. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ...fying_young_person_for_child_tax_credit.py | 23 ++++++++++--------- .../receives_income_support_in_own_right.py | 7 +++--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index c54648254a..53d39b176e 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -28,17 +28,18 @@ class is_qualifying_young_person_for_child_tax_credit(Variable): "has not ceased it, nor has an apprentice still on a course; the " "employment exclusion is part of the first condition only. Reg. " "5(4)(c) bars every route for a person who receives Income Support, " - "and the formula reads is_on_income_support for it: the award the " - "model calculates for a claimant or partner, and their own reported " - "award for any other member while Income Support is in payment and " - "no reform neutralises it. A reform that replaces Income Support's " - "formula with zero ends a claimant's or partner's receipt; for " - "another member it must neutralise income_support or set their " - "reported award to zero. The own-right benefits input covers " - "Universal Credit, Employment and Support Allowance and Jobseeker's " - "Allowance of either kind, where reg. 5(4)(c) lists only Universal " - "Credit and the income-related and income-based allowances. " - "Interruptions (reg. 5(7)) are not modelled." + "and the formula reads is_on_income_support for it: the benefit " + "unit's calculated award, for whichever of the claimant and partner " + "receives it, and their own reported award for any other member " + "while Income Support is in payment and no reform neutralises it. A " + "reform that replaces Income Support's formula with zero ends a " + "claimant's or partner's receipt; for another member it must set " + "gov.dwp.income_support.active to false, neutralise income_support " + "or set their reported award to zero. The own-right benefits input " + "covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance of either kind, where reg. 5(4)(c) lists only " + "Universal Credit and the income-related and income-based " + "allowances. Interruptions (reg. 5(7)) are not modelled." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py index 87c46dbac2..98b89e96a2 100644 --- a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -23,9 +23,10 @@ class receives_income_support_in_own_right(Variable): "false, neutralise income_support, or set reported awards to zero, " "for the Child Benefit status to follow; replacing its formula with " "zero leaves this variable true. Pension Credit (SPC Regs 2002 reg. " - "4A(5)) reads is_on_income_support instead, so it follows such a " - "reform for a claimant or partner. Universal Credit's list (UC Regs " - "2013 reg. 5(5)) does not include Income Support." + "4A(5)) and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)) read " + "is_on_income_support instead, so they follow such a reform for a " + "claimant or partner. Universal Credit's list (UC Regs 2013 reg. " + "5(5)) does not include Income Support." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/8" From 77f6cdc5a5a19100c1af9317a88d0393b89a9cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:41:12 +0000 Subject: [PATCH 140/168] Pin the cost-of-living payment in CTC Cases 18 and 19 The cost-of-living payment parameters start in 2022, and parameter processing extends their first values back to 2015, so the 2018 cases took 150 of disability payment that DWP never paid. Both cases now set the payment to zero. Case 18's 16-year-old, who reports their own Income Support, is no longer a Child Benefit qualifying young person either (Child Benefit reg. 8(2)(a)): Child Benefit 0 and household net income 6,188.30. Case 19: 6,188.30 + Child Benefit 1,076.40 + CTC 3,325 = 10,589.70. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ...child_tax_credit_school_leaver_routes.yaml | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml index 1ad7341981..5b31eb0678 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -420,10 +420,9 @@ # keeps for the 16th-birthday period. 2018-19 rates: child element £2,780 and # family element £545 a year (child_element.yaml, family_element.yaml); PIP # daily living standard rate £57.30 a week; Carer's Allowance £64.60 a week; -# Child Benefit eldest rate £20.70 a week; colour TV licence £150.50. The -# disability cost-of-living payment parameter starts at £150 on 1 January -# 2022, and the model applies that first value to earlier years, so the -# household receives £150 for the parent's PIP. +# Child Benefit eldest rate £20.70 a week; colour TV licence £150.50. Cases +# 18 and 19 set the cost-of-living payment, which DWP first paid in 2022, to +# zero. - name: Case 18, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4(2), reg. 5(4)(c)). period: 2018 @@ -450,6 +449,8 @@ households: household: members: [parent, carer] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 output: # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. is_before_first_september_following_16th_birthday: [false, true] @@ -459,10 +460,14 @@ is_qualifying_young_person_for_child_tax_credit: [false, false] # No child or qualifying young person, so no CTC. child_tax_credit: 0 + # Child Benefit reg. 8(2)(a) ends their Child Benefit status too. + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; - # Child Benefit 20.70 x 52 = 1,076.40; cost-of-living payment 150; less - # TV licence 150.50: 7,414.70. - household_net_income: 7_414.70 + # less TV licence 150.50: 6,188.30. The model calculates Income Support + # only for the claimant's family, so the 16-year-old's own 3,000 is in no + # income here. + household_net_income: 6_188.30 - name: Case 19, the same family without Income Support for the 16-year-old gets CTC until 31 August 2018 (reg. 4(1)). period: 2018 @@ -488,14 +493,18 @@ households: household: members: [parent, carer] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 output: is_before_first_september_following_16th_birthday: [false, true] is_on_income_support: [false, false] is_qualifying_young_person_for_child_tax_credit: [false, true] # No income: child element 2,780 + family element 545 = 3,325. child_tax_credit: 3_325 - # Case 18's 7,414.70 + 3,325 = 10,739.70. - household_net_income: 10_739.70 + # 20.70 x 52. + child_benefit: 1_076.40 + # Case 18's 6,188.30 + Child Benefit 1,076.40 + 3,325 = 10,589.70. + household_net_income: 10_589.70 - name: Case 20, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 5(3)(a), 5(4)(c)). period: 2018 From c21e35b266503874201caa5c0db3b26a2a19a1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:44:59 +0000 Subject: [PATCH 141/168] Pin the cost-of-living payment in Income Support Case 8 The cost-of-living payment parameters start in 2022, and parameter processing extends their first values back to 2015, so the 2019 case took 1,100 that DWP never paid. Case 8 now sets the payment to zero: household net income 5,000 + 3,052.40 + 3,697 + 3,010.80 + 200 - 154.50 = 14,805.70. The header also says that is_on_income_support gives the benefit unit's calculated award to whichever of the claimant and partner receives it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ...ualifying_young_person_income_support.yaml | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml index 43b55a36b7..605023af0d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml @@ -5,11 +5,11 @@ # names Universal Credit, Employment and Support Allowance and Jobseeker's # Allowance only. # -# Pension Credit and Child Tax Credit read is_on_income_support: the award the -# model calculates for a claimant or partner, and the person's own report for -# any other member. Child Benefit reads receives_income_support_in_own_right, -# the person's own report, because the family's Income Support reads the -# Child Benefit test. +# Pension Credit and Child Tax Credit read is_on_income_support: the benefit +# unit's calculated award, for whichever of the claimant and partner receives +# it, and the person's own report for any other member. Child Benefit reads +# receives_income_support_in_own_right, the person's own report, because the +# family's Income Support reads the Child Benefit test. # # Income Support stopped paying on 31 March 2026 # (gov/dwp/income_support/active.yaml), so reported receipt counts to 2025-26. @@ -184,10 +184,7 @@ # Case 8, 2019-20 amounts: standard minimum guarantee £167.25 a week; severe # disability addition £65.85 a week; PIP daily living standard rate £58.70 a # week; Income Support personal allowance for a single person aged 18 to 24 -# £57.90 a week; winter fuel payment £200; colour TV licence £154.50. The -# cost-of-living payment parameters start on 1 January 2022 (means-tested -# £650, pensioner £300, disability £150), and the model applies those first -# values to earlier years, so the household receives £1,100. +# £57.90 a week; winter fuel payment £200; colour TV licence £154.50. - name: Case 8, a student-carer on Income Support of their own is not the pensioner's qualifying young person, so the pensioner gets no severe disability addition. period: 2019 @@ -217,6 +214,8 @@ households: household: members: [pensioner, student] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 output: # The student claims Income Support for their own benefit unit: no # income, so the award is the personal allowance, 57.90 x 52 = 3,010.80. @@ -229,6 +228,6 @@ # 167.25 x 52 = 8,697, less the State Pension 5,000. pension_credit: [3_697, 0] # State Pension 5,000 + PIP 58.70 x 52 = 3,052.40 + Pension Credit 3,697 - # + Income Support 3,010.80 + cost-of-living payments 1,100 + winter fuel - # payment 200, less TV licence 154.50: 15,905.70. - household_net_income: 15_905.70 + # + Income Support 3,010.80 + winter fuel payment 200, less TV licence + # 154.50: 14,805.70. The cost-of-living payment is set to zero. + household_net_income: 14_805.70 From 05095a161297d533a14da0e2b393008d5c089521 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:44:59 +0000 Subject: [PATCH 142/168] Subtract the cost-of-living payment in the reproducer, and test neutralisation on the grid test_a_reform_paying_no_income_support_restores_the_severe_disability_addition now asserts household net income less the cost-of-living payment, 15,219.10 under both reforms, so it holds whether or not the 2022 payment amounts reach 2019. test_income_support_ends_three_statuses_and_not_universal_credit also checks that under a reform that neutralises income_support every programme's status, for a member who is not the claimant or partner, is the status without the report. The docstring names the active parameter among the ways a reform removes a report, and says how is_on_income_support treats a claimant or partner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- ..._young_person_income_support_properties.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py index 517afa354e..f4b3255333 100644 --- a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -7,9 +7,10 @@ reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) does not include Income Support. -Pension Credit and Child Tax Credit read is_on_income_support: the award the -model calculates for a claimant or partner, and the person's own report for -any other member while Income Support is in payment and not neutralised. +Pension Credit and Child Tax Credit read is_on_income_support: the benefit +unit's calculated award, for whichever of the claimant and partner receives +it, and the person's own report for any other member while Income Support is +in payment and not neutralised. Child Benefit reads receives_income_support_in_own_right, the person's own report on the same terms, because the family's Income Support reads the Child Benefit test and the calculated award would make a cycle. @@ -31,9 +32,9 @@ Under a reform that replaces income_support's formula with one returning zero, no Pension Credit or Child Tax Credit status of a claimant or partner depends on it; another member's report and the Child Benefit - reader still exclude, as their documentation says, because only - neutralising Income Support, or setting the report to zero, removes a - report. + reader still exclude, as their documentation says, because only setting + gov.dwp.income_support.active to false, neutralising Income Support, or + setting the report to zero, removes a report. 4. Universal Credit status never depends on reported Income Support, in the baseline or under either reform. 5. Setting a young person's reported Income Support to zero never lowers @@ -174,6 +175,15 @@ def as_members(rows, **inputs): def test_income_support_ends_three_statuses_and_not_universal_credit(): untouched = simulate(as_members(GRID)) reporting = simulate(as_members(GRID, income_support_reported=3_000)) + neutralised = simulate( + as_members(GRID, income_support_reported=3_000), reform="neutralised" + ) + for programme in EXCLUDING + ("universal_credit",): + np.testing.assert_array_equal( + status(neutralised, programme)[1::2], + status(untouched, programme)[1::2], + err_msg=programme, + ) for programme in EXCLUDING: before = status(untouched, programme)[1::2] # Not vacuous: there is a status to lose. @@ -342,9 +352,8 @@ def test_a_reform_paying_no_income_support_restores_the_severe_disability_additi # young person, disregarded under Schedule I para. 2(2)(f), so the # pensioner gets the severe disability addition 65.85 x 52 = 3,424.20 as # well: 7,121.20. Household net income: State Pension 5,000 + PIP 58.70 x - # 52 = 3,052.40 + Pension Credit 7,121.20 + the cost-of-living payments - # the model applies before 2022 (650 + 300 + 150) + winter fuel payment - # 200, less TV licence 154.50: 16,319.10. + # 52 = 3,052.40 + Pension Credit 7,121.20 + winter fuel payment 200, less + # TV licence 154.50: 15,219.10, before the cost-of-living payments. year = 2019 for reform in ("zero formula", "neutralised"): sim = simulate(pensioner_and_student_carer(), year, reform) @@ -358,10 +367,13 @@ def test_a_reform_paying_no_income_support_restores_the_severe_disability_additi sim.calculate("pension_credit", year), [7_121.20, 0], atol=0.01 ) np.testing.assert_allclose( - sim.calculate("household_net_income", year), [16_319.10], atol=0.01 + sim.calculate("household_net_income", year) + - sim.calculate("cost_of_living_support_payment", year), + [15_219.10], + atol=0.01, ) - # Child Benefit reads the report: only neutralising Income Support ends - # its exclusion. + # Child Benefit reads the report: of these two reforms, only neutralising + # Income Support ends its exclusion. zero = simulate(pensioner_and_student_carer(), year, "zero formula") assert zero.calculate(INCOME_SUPPORT, year)[1] assert not status(zero, "child_benefit", year)[1] From 812131cdb8314010828d62db8347c2985b1bada0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:45:00 +0000 Subject: [PATCH 143/168] Describe what this branch adds in its changelog fragment The Child Benefit gate now comes from #2299, the CTC bar from #2301 and the Pension Credit reader from #2279, so the fragment describes the tests this branch adds across the three programmes and Universal Credit. A reform that pays no Income Support ends the Pension Credit and CTC exclusion for a claimant or partner only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJYMi6pBmPsiyFLGCQK --- changelog.d/qyp-income-support-exclusion.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md index cbc3f67373..5ceecdaf61 100644 --- a/changelog.d/qyp-income-support-exclusion.fixed.md +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -1 +1 @@ -Stopped counting a young person who receives Income Support in their own right as a qualifying young person for Pension Credit, Child Benefit (and so Housing Benefit and the legacy benefits) and Child Tax Credit (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c)). Pension Credit and Child Tax Credit read the Income Support the model calculates for a claimant or partner, so a reform that pays no Income Support ends their exclusion; Child Benefit reads the young person's own report while Income Support is in payment. +Tested that Pension Credit, Child Benefit and Child Tax Credit each stop counting a young person who receives Income Support in their own right as a qualifying young person, and Universal Credit does not (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c); UC Regs 2013 reg. 5(5)), in the baseline and under reforms that remove Income Support. Pension Credit and Child Tax Credit read the Income Support the model calculates for a claimant or partner, so a reform that pays no Income Support ends the exclusion for a claimant or partner; Child Benefit reads the young person's own report while Income Support is in payment. From b6b1ce7cb3b179870ee6f6b840a1f75aef35b63e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:58:21 +0000 Subject: [PATCH 144/168] Read the carer's actual Universal Credit award in the severe disability carer test has_uc_award_with_carer_element now reads the award before the benefit cap (universal_credit_pre_benefit_cap), so a carer whose award a non-dependant deduction, the Tax-Free Childcare choice or the taper with a lower work allowance takes to nil has no award. The non-dependant deduction's Pension Credit branch keeps that read from cycling; the benefit cap, which reads Child Tax Credit and so Pension Credit, would not, so the test reads the award before the cap. Remove uc_award_upper_bound and its tests, the three expected-failure markers and their allowlist entries, add the executed English case and a Northern Ireland case, pin the branch's armed forces independence payment case, and cite Northern Ireland's counterparts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FQc4koGSzCUYko19B --- .github/test_outcome_allowlist.toml | 18 - changelog.d/2120.fixed.md | 2 +- .../uc_award_includes_zero_value_award.yaml | 18 - .../uc_carer_element_carer_test.yaml | 144 +++++- .../test_uc_carer_element_award_properties.py | 482 +++++------------- ...e_disability_minimum_guarantee_addition.py | 11 +- .../has_uc_award_with_carer_element.py | 77 ++- .../universal_credit/uc_award_upper_bound.py | 112 ---- ...is_cared_for_by_carer_benefit_recipient.py | 29 +- .../pension_age_severe_disability_premium.py | 7 +- .../demographic/severe_disability_premium.py | 13 +- 11 files changed, 342 insertions(+), 571 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 7279d3d3b5..e3b947e134 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -25,24 +25,6 @@ outcome = "xfailed" reason = "#1947" why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a strict xfail until it is fixed." -[[allow]] -nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award" -outcome = "xfailed" -reason = "#2795" -why = "Known bug #2795: a carer whose award a non-dependant deduction takes to nil still bars the severe disability premium. A strict xfail pins it until the model pays the premium." - -[[allow]] -nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_a_family_that_takes_tax_free_childcare_instead_has_no_award" -outcome = "xfailed" -reason = "#2795" -why = "Known bug #2795: a carer whose family takes Tax-Free Childcare instead of Universal Credit still bars the severe disability premium. A strict xfail pins it until the model pays the premium." - -[[allow]] -nodeid = "policyengine_uk/tests/test_uc_carer_element_award_properties.py::test_small_housing_costs_with_a_work_allowance_leave_no_award" -outcome = "xfailed" -reason = "#2795" -why = "Known bug #2795: a carer with a work allowance and a small housing costs element whose award comes to nil still bars the severe disability premium. A strict xfail pins it until the model pays the premium." - [[allow]] nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available" outcome = "skipped" diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md index b3214856e0..a5b29b8b7d 100644 --- a/changelog.d/2120.fixed.md +++ b/changelog.d/2120.fixed.md @@ -1 +1 @@ -The model now counts a carer's Universal Credit award with the carer element when deciding the severe disability premium and the Pension Credit severe disability addition. In Scotland's working-age council tax reduction, it counts such awards for the carer premium from 1 April 2022. Until 31 March 2025 it also counts an award that DWP tapers to nil because of the carer's earnings, under its reading of Scotland's definition of an award "with a value of zero" (#2120, #2124). +The model now counts a carer's Universal Credit award with the carer element, where the award comes to at least one penny, when deciding the severe disability premium and the Pension Credit severe disability addition, and in Scotland's working-age council tax reduction it counts such an award for the carer premium from 1 April 2022 (#2120, #2124). diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml deleted file mode 100644 index 3c25bfbf63..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/uc_award_includes_zero_value_award.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: Whether Scottish councils count a Universal Credit award with a value of zero when deciding the working-age Council Tax Reduction carer premium. The model reads that definition as reaching an award that DWP tapers to nil because of the carer's earnings; the sources leave that reading open. -values: - 2013-04-01: false - 2022-04-01: true - 2025-04-01: false -metadata: - unit: bool - period: year - label: Scottish working-age Council Tax Reduction counts a Universal Credit award with a value of zero - reference: - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 4 ("award of universal credit" includes a universal credit award with a value of zero), as in force to 31 March 2025 - href: https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2024-04-30 - - title: The Council Tax Reduction (Scotland) Amendment Regulations 2022 (SSI 2022/52) reg. 3 (inserts that definition from 31 March 2022) - href: https://www.legislation.gov.uk/ssi/2022/52/regulation/3/made - - title: The Council Tax Reduction (Miscellaneous Amendment) (Scotland) Regulations 2025 (SSI 2025/24) regs. 1 and 8 (substitute "means an award of universal credit made under Part 1 of the 2012 Act" from 1 April 2025) - href: https://www.legislation.gov.uk/ssi/2025/24/regulation/8/made - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 10A (awards reduced to nil by a sanction, and reclaims under the Claims and Payments Regulations 2013 reg. 32A, from 1 April 2025) - href: https://www.legislation.gov.uk/ssi/2021/249/regulation/10A diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml index 61976b053c..6e62b31464 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -195,12 +195,10 @@ # Earnings of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 # and National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, # and with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. - # That exceeds the maximum amount even with no non-dependant deduction - # (uc_award_upper_bound; the disabled person's family, which does not - # claim, has a bound of its standard allowance, 424.90 x 12 = 5,098.80), so - # the award comes to nil, which leaves no award (Welfare Reform Act 2012 - # s.5(1)(b); UC Regs 2013 reg 17). No one has an award in respect of caring - # for the disabled person, who keeps the premium, 86.05 x 52 = 4,474.60. + # That exceeds the maximum amount, so the award comes to nil, which leaves + # no award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). No one + # has an award in respect of caring for the disabled person, who keeps the + # premium, 86.05 x 52 = 4,474.60. period: 2026 absolute_error_margin: 0.01 input: @@ -226,8 +224,8 @@ country: ENGLAND output: uc_earned_income: [0, 21_519.60] + universal_credit_pre_benefit_cap: [0, 0] universal_credit: [0, 0] - uc_award_upper_bound: [5_098.80, 0] receives_uc_carer_element: [false, false] has_uc_award_with_carer_element: [false, false] is_cared_for_by_carer_benefit_recipient: [false, false] @@ -238,9 +236,8 @@ # £6,000 a year. The housing costs element raises the maximum amount to # 7,610.88 + 6,000 = 13,610.88, and the taper of 11,835.78 leaves an award # of 1,775.10. The disabled person, on the PIP daily living component, - # brings no non-dependant deduction (UC Regs 2013 Sch 4 para 16(2)(e)), so - # the award equals its bound. It includes the carer element and bars the - # premium. + # brings no non-dependant deduction (UC Regs 2013 Sch 4 para 16(2)(e)). + # The award includes the carer element and bars the premium. period: 2026 absolute_error_margin: 0.01 input: @@ -270,7 +267,7 @@ output: uc_housing_costs_element: [6_000, 0] universal_credit: [1_775.10, 0] - uc_award_upper_bound: [1_775.10, 5_098.80] + universal_credit_pre_benefit_cap: [1_775.10, 0] has_uc_award_with_carer_element: [true, false] is_cared_for_by_carer_benefit_recipient: [false, true] severe_disability_premium: [0, 0] @@ -307,7 +304,6 @@ output: uc_earned_income: [0, 17_919.60] universal_credit: [0, 0] - uc_award_upper_bound: [0, 0] has_uc_award_with_carer_element: [false, false] is_cared_for_by_carer_benefit_recipient: [false, false] severe_disability_minimum_guarantee_addition: [4_474.60, 0] @@ -383,3 +379,127 @@ has_uc_award_with_carer_element: [false, false] is_cared_for_by_carer_benefit_recipient: [false, false] severe_disability_premium: [4_474.60, 0] + +- name: A blind carer whose award comes to nil with a small housing costs element does not bar a pensioner's addition + # The household head, aged 35 and blind, has a limited capability for work + # determination, cares 35 hours a week for a pensioner aged 75 in another + # benefit unit, claims no Carer's Allowance, earns £25,000 and pays the + # council £600 a year in rent. The pensioner receives Attendance Allowance + # at the higher rate, 114.60 x 52 = 5,959.20, and £10,000 of State Pension. + # + # The carer's Universal Credit. Income tax (25,000 - 12,570) x 0.20 = + # 2,486.00 and National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave + # earned income of 21,519.60. The award includes the carer element: the + # carer has no limited capability for work-related activity (UC Regs 2013 + # reg 29(4)). The pensioner brings no non-dependant deduction (Sch 4 para + # 16(2)(d)). The maximum amount comes to (424.90 + 209.34) x 12 + 600 = + # 8,210.88. With a housing costs element, DWP applies the lower work + # allowance, 427 x 12 = 5,124.00 (reg 22(2)(b)), and the taper takes + # 0.55 x (21,519.60 - 5,124.00) = 9,017.58, more than the maximum amount. + # The model caps the reduction at the maximum amount, so the award comes + # to nil and the carer has no award (Welfare Reform Act 2012 s.5(1)(b); + # reg 17). + # + # The pensioner's addition. She receives Attendance Allowance (SPC Regs + # 2002 Sch I para 1(1)(a)(i)); DWP ignores the blind carer as a resident + # (para 2(2)(b)); and no one has an award including the carer element in + # respect of caring for her (para 1(1)(a)(iii) and (4)). So DWP includes + # the addition, 86.05 x 52 = 4,474.60. + # + # Pension Credit. The minimum guarantee comes to 238.00 x 52 + 4,474.60 = + # 16,850.60. Her income for Pension Credit is the State Pension, 10,000 + # (the model counts no Attendance Allowance), so guarantee credit comes to + # 16,850.60 - 10,000 = 6,850.60. Her qualifying income, 10,000 / 52 = + # 192.31 a week, falls short of the savings credit threshold of 208.07, so + # savings credit comes to nil. + # + # Household net income. Earnings 25,000, plus State Pension 10,000, + # Attendance Allowance 5,959.20, Pension Credit 6,850.60, Winter Fuel + # Payment 200 (aged under 80, lower rate) and the Household Energy Payment + # 100 (England, the model's default band D), less income tax 2,486.00 and + # National Insurance 994.40: 44,629.40. Were the carer counted, the + # addition would fall to nil and Pension Credit to 16,850.60 - 4,474.60 - + # 10,000 = 2,376.00, and household net income to 40,154.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 35 + is_blind: true + uc_limited_capability_for_work: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + is_household_head: true + pensioner: + age: 75 + aa_category: HIGHER + state_pension: 10_000 + benunits: + carer_unit: + members: [carer] + pensioner_unit: + members: [pensioner] + households: + household: + members: [carer, pensioner] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 600 + output: + uc_earned_income_before_work_allowance: [21_519.60, 0] + uc_earned_income: [16_395.60, 0] + uc_maximum_amount: [8_210.88, 0] + uc_work_allowance: [5_124, 0] + uc_income_reduction: [8_210.88, 0] + universal_credit_pre_benefit_cap: [0, 0] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_minimum_guarantee_addition: [0, 4_474.60] + minimum_guarantee: [12_376, 16_850.60] + savings_credit: [0, 0] + pension_credit: [0, 6_850.60] + household_net_income: 44_629.40 + +- name: In Northern Ireland a son's award including the carer element bars a pensioner's addition + # SR 2016/236 reg 31(7) inserted the Universal Credit words into SPC Regs + # (NI) 2003 Sch 1 para 1(1)(a)(iii), with para 1(4) defining the award by + # reference to UC Regs (NI) 2016 reg 30. The model reads the same + # parameters in Northern Ireland: Universal Credit standard allowance + # 424.90 and carer element 209.34 a month (gov.dwp.universal_credit), and + # the addition 86.05 a week + # (gov.dwp.pension_credit.guarantee_credit.severe_disability.addition). + # The pensioner, aged 75, receives Attendance Allowance at the higher rate. + # Her blind son, aged 40, cares 35 hours a week, claims no Carer's + # Allowance and has no income, so his award comes to (424.90 + 209.34) x + # 12 = 7,610.88 and includes the carer element, 209.34 x 12 = 2,512.08. + # DWP ignores him as a resident (para 2(2)(b)), but his award bars the + # addition, which would otherwise come to 86.05 x 52 = 4,474.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: NORTHERN_IRELAND + output: + uc_carer_element: [0, 2_512.08] + universal_credit_pre_benefit_cap: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_minimum_guarantee_addition: [0, 0] diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index 6d76b8513d..a6ad8ee35a 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -9,13 +9,11 @@ 2021/249 Sch 1 paras 5 and 6(1)(c), from 1 April 2022) pays the carer premium to an applicant or partner with such an award. -The model reads the award from its conditions and an upper bound on it -(has_uc_award_with_carer_element, uc_award_upper_bound) for the severe -disability carer test, since the award itself reads Pension Credit, unless the -simulation stores the award. Scotland's carer premium reads the positive award -(receives_uc_carer_element), or from 1 April 2022 to 31 March 2025, when the -model counts a nil award as one with a value of zero, the award's conditions. -Each example simulates the households as drawn, and again with +The severe disability carer test (has_uc_award_with_carer_element) reads the +award before the benefit cap, universal_credit_pre_benefit_cap, since the cap +reads Pension Credit, or the award itself where the simulation stores it. +Scotland's carer premium reads the positive award (receives_uc_carer_element) +in every year. Each example simulates the households as drawn, and again with has_uc_award_with_carer_element supplied as false for everyone, which leaves receives_carer_benefit as the attribution's only source of carers. carer_premium gives the Scottish premium's counterpart without the limb. @@ -37,26 +35,23 @@ the number of claimants and partners, and at most one where partners care for the same person; a person with both an allowance and a carer element counts once. -5. A nil bound leaves a nil award: uc_award_upper_bound >= - universal_credit_pre_benefit_cap >= universal_credit for awards the - model's formulas calculate, so receives_uc_carer_element implies - has_uc_award_with_carer_element. That implication also holds where the - simulation stores the award, which can exceed the bound. +5. The carer test is the award: has_uc_award_with_carer_element equals + receives_uc_carer_element for every person, and + universal_credit_pre_benefit_cap comes to more than nil wherever + universal_credit does, and for every family whose award includes the + carer element in respect of someone, exactly where universal_credit does. + The equality also holds where the simulation stores the award. 6. Attribution: the attribution makes no one their own carer, everyone it counts as cared for meets Carer's Allowance's severe disability test, and no household has more people cared for than carers (carer benefit recipients and carer element holders). 7. No cycle: every reader calculates on one simulation, whichever the test requests first, and also where the simulation stores the award. -8. The bound reads neither the award nor Pension Credit: its trace reaches - none of the award's steps, the non-dependant deduction or the attribution. -9. Endpoint parity: uc_award_upper_bound equals, exactly, the larger of two - awards the model's own formulas calculate, one with non-dependant - deductions supplied as nil and one with the housing costs element supplied - as nil, at the rows test_upper_bound_matches_real_award_endpoints draws. -10. Every component: the bound reads each element of uc_maximum_amount in its - order, and every input of the award's steps apart from the non-dependant - deduction, the Tax-Free Childcare choice and the claim. +8. The cut: the non-dependant deduction's Pension Credit exemption reads the + non-dependant family's Pension Credit in a branch with every housing costs + element set to nil, where the carer test reads the award without housing + costs. test_the_non_dependant_branch_reads_the_award_without_housing_costs + pins the one kind of case where that changes a deduction. A reform that makes is_uc_eligible read Pension Credit would cycle through the attribution, which reads eligibility; these tests do not cover reforms. @@ -103,7 +98,7 @@ "is_cared_for_by_carer_benefit_recipient", "has_uc_award_with_carer_element", "receives_uc_carer_element", - "uc_award_upper_bound", + "universal_credit_pre_benefit_cap", "universal_credit", "pension_credit", "housing_benefit", @@ -162,8 +157,7 @@ def benefit_unit(draw): @st.composite def households(draw): # DWP includes housing costs for renters and deducts an amount for each - # non-dependant in another benefit unit. The upper bound leaves that - # deduction out. + # non-dependant in another benefit unit. tenure, rent = draw( st.sampled_from( [ @@ -343,9 +337,9 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati "receives_uc_carer_element", "receives_carer_benefit", "is_claimant_or_partner", - "uc_award_upper_bound", "universal_credit_pre_benefit_cap", "universal_credit", + "uc_carer_element", ) sim, values = with_and_without_limb(population, variables) drawn = {v: pair[0] for v, pair in values.items()} @@ -354,15 +348,17 @@ def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(populati benunit_household = household_index(sim, "benunit") person_household = household_index(sim, "person") - # 5. A nil bound leaves a nil award, exactly, so the carer test misses no - # award. - bound = drawn["uc_award_upper_bound"] + # 5. The carer test is the award: the award before the cap is positive + # wherever the final award is, and exactly where it is for a family whose + # award includes the carer element, so the two tests agree. award = drawn["universal_credit_pre_benefit_cap"] - assert (bound >= award).all() - assert (award >= drawn["universal_credit"]).all() + final = drawn["universal_credit"] + assert not ((final > 0) & ~(award > 0)).any() + carer_family = drawn["uc_carer_element"] > 0 + assert ((award > 0) == (final > 0))[carer_family].all() has_award = drawn["has_uc_award_with_carer_element"] positive_award = drawn["receives_uc_carer_element"] - assert not (positive_award & ~has_award).any() + assert (has_award == positive_award).all() # 1. No change without a carer element, household by household. touched = list(set(person_household[has_award | positive_award])) @@ -482,7 +478,7 @@ def test_a_stored_award_is_the_award_and_reads_no_cycle(order): has_award = sim.calculate("has_uc_award_with_carer_element", YEAR) receives = sim.calculate("receives_uc_carer_element", YEAR) assert receives.tolist() == [False, True] - assert has_award.tolist() == [False, True] + assert has_award.tolist() == receives.tolist() assert sim.calculate("is_cared_for_by_carer_benefit_recipient", YEAR).tolist() == [ True, False, @@ -513,8 +509,8 @@ def apply(self): def require(condition, message): - # Raise RuntimeError when a premise fails, so xfail accepts only the - # premium assertion. + # Raise RuntimeError when a premise fails, so a failure names the premise + # apart from the premium assertion. if not condition: raise RuntimeError(message) @@ -587,14 +583,6 @@ def tax_free_childcare_situation(): return {"people": people, "benunits": benunits, "households": {"h": household}} -@pytest.mark.xfail( - raises=AssertionError, - strict=True, - reason=( - "#2795: the carer test reads the bound, which leaves out the " - "non-dependant deduction that takes this award to nil." - ), -) def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award(): # DWP's deduction for the non-dependant (UC Regs 2013 Sch 4 paras 13 and # 16; blindness brings no exemption, and para 15 does not exempt the @@ -606,22 +594,19 @@ def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award() # keeps the premium in law. sim = Simulation(situation=non_dependant_situation()) award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) - bound = sim.calculate("uc_award_upper_bound", YEAR) deductions = sim.calculate("uc_non_dep_deductions", YEAR) - require(award[0] == 0 and bound[0] > 0, "the carer's award is not nil") - require(deductions[0] > bound[0], "the deduction does not take it to nil") + without_deduction = non_dependant_situation() + without_deduction["benunits"]["carer_unit"]["uc_non_dep_deductions"] = {YEAR: 0} + award_without = Simulation(situation=without_deduction).calculate( + "universal_credit_pre_benefit_cap", YEAR + ) + require(award[0] == 0 and deductions[0] > 0, "the carer's award is not nil") + require(award_without[0] > 0, "the deduction does not take it to nil") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() severe = sim.calculate("severe_disability_premium", YEAR) assert severe[1] == pytest.approx(86.05 * WEEKS) -@pytest.mark.xfail( - raises=AssertionError, - strict=True, - reason=( - "#2795: the carer test cannot read whether a family takes " - "Tax-Free Childcare instead, which reads the award." - ), -) def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): # The family gets more from the Tax-Free Childcare top-up than from the # award, so it takes no award (forgoes_universal_credit_for_tax_free_ @@ -630,8 +615,11 @@ def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): sim = Simulation(situation=tax_free_childcare_situation()) forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) - bound = sim.calculate("uc_award_upper_bound", YEAR) - require(forgoes[0] and award[0] == 0 and bound[0] > 0, "the family claims") + maximum = sim.calculate("uc_maximum_amount", YEAR) + reduction = sim.calculate("uc_income_reduction", YEAR) + require(forgoes[0] and award[0] == 0, "the family claims") + require(maximum[0] > reduction[0], "the award would come to nil anyway") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() severe = sim.calculate("severe_disability_premium", YEAR) assert severe[1] == pytest.approx(86.05 * WEEKS) @@ -666,14 +654,6 @@ def small_housing_situation(): return {"people": people, "benunits": benunits, "households": {"h": household}} -@pytest.mark.xfail( - raises=AssertionError, - strict=True, - reason=( - "#2795: the bound's calculation without housing costs takes the " - "higher work allowance and stays positive when this award comes to nil." - ), -) def test_small_housing_costs_with_a_work_allowance_leave_no_award(): # DWP has determined that the carer has limited capability for work, so # her award has a work allowance (UC Regs 2013 reg 22(1)(b)), and she @@ -683,318 +663,108 @@ def test_small_housing_costs_with_a_work_allowance_leave_no_award(): # claim (reg 30(1)(a)(i) and (2)). Her award has a housing costs element of # £600, so DWP applies the lower work allowance, £427 a month (reg # 22(2)(b)). The taper then exceeds the maximum amount, and the award - # comes to nil (Welfare Reform Act 2012 s.5(1)(b); reg 17). The bound - # also works out the award with no housing costs element and the higher - # work allowance, £710 a month, which leaves £461.10. The council + # comes to nil (Welfare Reform Act 2012 s.5(1)(b); reg 17), although an + # award with no housing costs element and the higher work allowance, + # £710 a month, would leave £461.10. The council # ignores the blind carer as a resident (HB Regs 2006 Sch 3 para # 14(4)(b)), and with no award she does not bar the premium (para # 14(2)(a)(iii)). So the disabled person keeps the premium in law. sim = Simulation(situation=small_housing_situation()) award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) - bound = sim.calculate("uc_award_upper_bound", YEAR) housing = sim.calculate("uc_housing_costs_element", YEAR) allowance = sim.calculate("uc_work_allowance", YEAR) deductions = sim.calculate("uc_non_dep_deductions", YEAR) forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) lcwra = sim.calculate("uc_LCWRA_element", YEAR) carer_element = sim.calculate("uc_carer_element", YEAR) - require(award[0] == 0 and bound[0] > 0, "the carer's award is not nil") + require(award[0] == 0, "the carer's award is not nil") require(housing[0] > 0 and allowance[0] == 427 * 12, "no lower work allowance") require(deductions[0] == 0 and not forgoes[0], "another route takes it to nil") require(lcwra[0] == 0 and carer_element[0] > 0, "the award has no carer element") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() severe = sim.calculate("severe_disability_premium", YEAR) assert severe[1] == pytest.approx(86.05 * WEEKS) -def test_the_bound_reads_neither_the_award_nor_pension_credit(): - # The carer test reads uc_award_upper_bound because the award reads - # Pension Credit (through the non-dependant deduction's exemption), whose - # addition reads the attribution. A pensioner on Attendance Allowance - # shares a privately rented home with her blind son, who claims Universal - # Credit with the carer element; the trace of the bound reaches none of - # those variables. - forbidden = { - "pension_credit", - "uc_housing_costs_element", - "uc_non_dep_deductions", - "uc_non_dep_deduction_exempt", - "uc_maximum_amount", - "uc_income_reduction", - "universal_credit_pre_benefit_cap", - "universal_credit", - "is_cared_for_by_carer_benefit_recipient", +def veteran_situation(exempt=None): + """A blind carer (40) earning £20,000 rents from the council at £6,000 a + year as household head and cares 35 hours a week for a veteran (70) in + another family of the household, who receives armed forces independence + payment and £13,000 of State Pension. exempt, if given, supplies the + veteran family's Pension Credit exemption from the non-dependant + deduction.""" + people = { + "carer": { + "age": {YEAR: 40}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 20_000}, + "is_household_head": {YEAR: True}, + }, + "veteran": { + "age": {YEAR: 70}, + "armed_forces_independence_payment": {YEAR: 150 * WEEKS}, + "state_pension": {YEAR: 13_000}, + }, } - sim = Simulation( - situation=situation( - [ - dict( - country="ENGLAND", - tenure="RENT_PRIVATELY", - rent=9_000, - units=[ - dict(adults=[single(75, disability="aa_higher")], children=[]), - dict( - adults=[single(40, blind=True, care_hours=35)], - children=[], - ), - ], - ) - ] - ) - ) - sim.trace = True - sim.calculate("uc_award_upper_bound", YEAR) - reached = set() - - def visit(node): - reached.add(node.name) - for child in node.children: - visit(child) - - for tree in sim.tracer.trees: - visit(tree) - assert "uc_carer_element" in reached - assert not reached & forbidden, reached & forbidden - - -# Families for the endpoint comparison, each in its own English household: -# every element of the maximum amount, both work allowances, owners, council -# and housing association renters, and private renters below and above the -# Local Housing Allowance rate. The award reads the earned and unearned -# income each row supplies. -PARITY_FAMILIES = [ - # A single owner, with no work allowance. - dict(adults=[dict(age=30)], children=[], tenure="OWNED_OUTRIGHT", rent=0), - # A lone parent renting privately, with a child (3) in childcare. - dict( - adults=[dict(age=30, employment_income=30_000)], - children=[dict(age=3, childcare_expenses=6_000)], - tenure="RENT_PRIVATELY", - rent=9_000, - ), - # A lone parent of two who owns her home. - dict( - adults=[dict(age=35, employment_income=20_000)], - children=[dict(age=4), dict(age=7)], - tenure="OWNED_OUTRIGHT", - rent=0, - ), - # A council renter with limited capability for work and work-related - # activity. - dict( - adults=[dict(age=45, uc_limited_capability_for_WRA=True)], - children=[], - tenure="RENT_FROM_COUNCIL", - rent=6_000, - ), - # A carer with limited capability for work and a small council rent, - # where the calculation without housing costs gives the larger award. - dict( - adults=[dict(age=35, care_hours=35, uc_limited_capability_for_work=True)], - children=[], - tenure="RENT_FROM_COUNCIL", - rent=600, - ), - # A couple, one caring, with a disabled child, renting from a housing - # association. - dict( - adults=[ - dict(age=40, care_hours=35), - dict(age=38, employment_income=15_000), - ], - children=[dict(age=6, dla_sc_category="MIDDLE")], - tenure="RENT_FROM_HA", - rent=7_200, - ), - # A lone parent earning £6,000, with a child (3) in childcare, who rents - # privately for £30,000 a year. The model places a household with no - # region in Inner South East London, where DWP caps the housing costs - # element at the two-bedroom Local Housing Allowance rate, £1,550 a month. - dict( - adults=[dict(age=30, employment_income=6_000)], - children=[dict(age=3, childcare_expenses=6_000)], - tenure="RENT_PRIVATELY", - rent=30_000, - ), -] -# Earned income before the work allowance and unearned income, a year. -PARITY_INCOMES = [(0, 0), (6_000, 0), (18_000, 0), (12_000, 1_500)] -BOUNDARY_EARNINGS = 6_000 -# The endpoints: the award with non-dependant deductions supplied as nil, and -# the award with the housing costs element supplied as nil. -ENDPOINTS = ("uc_non_dep_deductions", "uc_housing_costs_element") - - -def parity_situation(rows, endpoint=None): - """One household per (family, earned income, unearned income) row. Each - family claims and takes no Tax-Free Childcare instead, with no benefit cap - reduction or debt deduction, so the final award equals the award before - the cap. The situation supplies endpoint, a variable in ENDPOINTS, as - nil.""" - people, benunits, households = {}, {}, {} - for i, (family, earned, unearned) in enumerate(rows): - members = [] - for role, group in (("a", family["adults"]), ("c", family["children"])): - for j, inputs in enumerate(group): - name = f"r{i}_{role}{j}" - people[name] = {k: {YEAR: v} for k, v in inputs.items()} - people[name]["is_claimant_or_partner"] = {YEAR: role == "a"} - members.append(name) - unit = { - "members": members, - "claims_universal_credit": {YEAR: True}, - "forgoes_universal_credit_for_tax_free_childcare": {YEAR: False}, - "uc_benefit_cap_reduction_after_floor": {YEAR: 0}, - "uc_deductions": {YEAR: 0}, - "uc_earned_income_before_work_allowance": {YEAR: float(earned)}, - "uc_unearned_income": {YEAR: float(unearned)}, - } - if endpoint is not None: - unit[endpoint] = {YEAR: 0} - benunits[f"r{i}"] = unit - households[f"r{i}"] = { - "members": members, - "country": {YEAR: "ENGLAND"}, - "tenure_type": {YEAR: family["tenure"]}, - "rent": {YEAR: family["rent"]}, - } - return {"people": people, "benunits": benunits, "households": households} - - -def endpoint_values(rows, variables): - """Each variable at each endpoint, from its own simulation.""" - results = [] - for endpoint in ENDPOINTS: - sim = Simulation(situation=parity_situation(rows, endpoint)) - results.append({v: np.asarray(sim.calculate(v, YEAR)) for v in variables}) - return results - - -def boundary_rows(): - """Rows whose unearned income brings each endpoint's income reduction to - within a few float32 steps of its maximum amount, where the award - cancels to nil or a fraction of a penny.""" - seeds = [(family, BOUNDARY_EARNINGS, 0) for family in PARITY_FAMILIES] - values = endpoint_values(seeds, ("uc_maximum_amount", "uc_income_reduction")) - rows = [] - for at_endpoint in values: - maximum = at_endpoint["uc_maximum_amount"].astype(np.float32) - reduction = at_endpoint["uc_income_reduction"].astype(np.float32) - assert (reduction < maximum).all(), "a seed's taper reaches its maximum" - for f, family in enumerate(PARITY_FAMILIES): - step = np.spacing(maximum[f]) - for k in range(-3, 4): - unearned = np.float32(maximum[f] - reduction[f] + k * step) - rows.append((family, BOUNDARY_EARNINGS, float(unearned))) - return rows - - -def test_upper_bound_matches_real_award_endpoints(): - # The bound repeats the award's arithmetic at two endpoints, since the - # carer test cannot read the award. At each row the bound must equal, - # exactly, the larger of the two awards the model's own formulas give: - # one with non-dependant deductions supplied as nil, one with the - # housing costs element supplied as nil. Comparing the award before the - # cap and the final award also catches a new amount the award adds - # after the cap stage. Drift either way at these rows fails the test. - rows = [ - (family, earned, unearned) - for family in PARITY_FAMILIES - for earned, unearned in PARITY_INCOMES - ] - n_seed_rows = len(rows) - rows += boundary_rows() - components = ( - "uc_standard_allowance", - "uc_child_element", - "uc_disability_elements", - "uc_carer_element", - "uc_housing_costs_element", - "uc_childcare_element", - ) - variables = components + ( - "uc_LHA_cap", - "benunit_rent", - "uc_work_allowance", - "universal_credit_pre_benefit_cap", - "universal_credit", - ) - with_housing, without_housing = endpoint_values(rows, variables) - base = Simulation(situation=parity_situation(rows)) - bound = np.asarray(base.calculate("uc_award_upper_bound", YEAR)) - - # The rows cover every element, a housing costs element that DWP caps at - # the Local Housing Allowance rate, both work allowances, both endpoints - # deciding the bound, and awards that cancel to nil or under a penny. - for component in components: - assert (with_housing[component] > 0).any(), component - lha_cap = with_housing["uc_LHA_cap"] - assert ( - (lha_cap < with_housing["benunit_rent"]) - & (with_housing["uc_housing_costs_element"] == lha_cap) - ).any() - assert (with_housing["uc_work_allowance"] == 427 * 12).any() - assert (without_housing["uc_work_allowance"] == 710 * 12).any() - pre_cap = [ - values["universal_credit_pre_benefit_cap"] - for values in (with_housing, without_housing) - ] - assert (pre_cap[0] > pre_cap[1]).any() and (pre_cap[1] > pre_cap[0]).any() - for award in pre_cap: - boundary = award[n_seed_rows:] - assert (boundary == 0).any() and ((boundary > 0) & (boundary < 0.01)).any() - - for stage in ("universal_credit_pre_benefit_cap", "universal_credit"): - larger = np.maximum(with_housing[stage], without_housing[stage]) - mismatched = np.flatnonzero(bound != larger) - assert not mismatched.size, (stage, mismatched, bound[mismatched]) - - -# The award's steps that the bound repeats, and the reads it leaves out: the -# non-dependant deduction and the Tax-Free Childcare choice, which read -# Pension Credit and the award, and the claim, which the carer test reads -# beside the bound. -AWARD_STEPS = ( - "uc_maximum_amount", - "uc_housing_costs_element", - "uc_work_allowance", - "uc_earned_income", - "uc_income_reduction", - "universal_credit_pre_benefit_cap", -) -LEFT_OUT = { - "uc_non_dep_deductions", - "forgoes_universal_credit_for_tax_free_childcare", - "claims_universal_credit", -} + benunits = { + "carer_unit": {"members": ["carer"]}, + "veteran_unit": {"members": ["veteran"], "would_claim_uc": {YEAR: False}}, + } + if exempt is not None: + benunits["veteran_unit"]["uc_non_dep_receives_pension_credit"] = {YEAR: exempt} + household = { + "members": ["carer", "veteran"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: 6_000}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} -def test_the_bound_reads_every_component_of_the_maximum_amount(): - # A new element in uc_maximum_amount, or a new read in any step of the - # award, must reach the bound too. The couple with a disabled child - # rents and has a carer, so every step runs. - sim = Simulation(situation=parity_situation([(PARITY_FAMILIES[5], 12_000, 1_500)])) - sim.trace = True - sim.calculate("universal_credit_pre_benefit_cap", YEAR) - sim.calculate("uc_award_upper_bound", YEAR) - reads = {} - - def visit(node): - if node.children: - # A variable's formula runs once; later reads hit the cache. - reads.setdefault(node.name, [child.name for child in node.children]) - for child in node.children: - visit(child) - - for tree in sim.tracer.trees: - visit(tree) - bound_reads = reads["uc_award_upper_bound"] - adds = sim.tax_benefit_system.get_variable("uc_maximum_amount").adds - # The elements in uc_maximum_amount's order, which float32 addition - # depends on; the bound rebuilds the housing costs element from rent. - assert [name for name in bound_reads if name in adds] == [ - name for name in adds if name != "uc_housing_costs_element" +@pytest.mark.parametrize( + "first", ["universal_credit", "pension_credit", "has_uc_award_with_carer_element"] +) +def test_the_non_dependant_branch_reads_the_award_without_housing_costs(first): + # The carer's award: income tax 1,486.00 and National Insurance 594.40 + # leave earned income of 17,919.60, and the taper, 0.55 x 17,919.60 = + # 9,855.78, leaves (424.90 + 209.34) x 12 + 6,000 - 9,855.78 = 3,755.10 + # with no non-dependant deduction. The award includes the carer element, + # so the veteran, whom DWP and the model treat as severely disabled + # through armed forces independence payment, gets no severe disability + # addition, and with £250 a week of State Pension against a standard + # minimum guarantee of £238.00, no Pension Credit. + # + # The deduction's exemption reads the veteran's Pension Credit in a + # branch with every housing costs element set to nil. There the carer's + # award, 7,610.88 - 9,855.78, comes to nil, the veteran gets the addition + # (DWP ignores a blind resident, SPC Regs 2002 Sch I para 2(2)(b)) and + # Pension Credit, (238.00 + 86.05 - 250.00) x 52 = 3,850.60, and the model + # exempts the veteran from the deduction. UC Regs 2013 Sch 4 para 16(2) + # names no armed forces independence payment, so the law, in which the + # veteran gets no Pension Credit, deducts 96.55 x 12 = 1,158.60 and leaves + # an award of 2,596.50, which still includes the carer element. This test + # pins the model's figures, whichever variable the simulation requests + # first. + sim = Simulation(situation=veteran_situation()) + sim.calculate(first, YEAR) + assert sim.calculate("has_uc_award_with_carer_element", YEAR).tolist() == [ + True, + False, ] - for step in AWARD_STEPS: - missing = set(reads[step]) - set(AWARD_STEPS) - LEFT_OUT - set(bound_reads) - assert not missing, (step, missing) + assert sim.calculate("pension_credit", YEAR).tolist() == [0, 0] + assert sim.calculate("uc_non_dep_receives_pension_credit", YEAR).tolist() == [ + False, + True, + ] + assert sim.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + assert award[0] == pytest.approx(3_755.10, abs=0.01) + # The law's figures, with the exemption supplied as the law reads it. + law = Simulation(situation=veteran_situation(exempt=False)) + assert law.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx(96.55 * 12) + law_award = law.calculate("universal_credit_pre_benefit_cap", YEAR) + assert law_award[0] == pytest.approx(2_596.50, abs=0.01) + assert law.calculate("has_uc_award_with_carer_element", YEAR)[0] + assert law.calculate("pension_credit", YEAR)[1] == 0 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 537f2538e7..daa584b612 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -29,9 +29,11 @@ class severe_disability_minimum_guarantee_addition(Variable): "benefit to the person it is paid for caring for, and the legacy " "severe disability premium shares it. The attribution counts an award of " "Universal Credit which includes the carer element (para 1(4)), which " - "has_uc_award_with_carer_element reads from the award's conditions " - "and the upper bound on the award (uc_award_upper_bound). The bound " - "can stay positive when the actual award comes to nil (#2795). " + "has_uc_award_with_carer_element reads from the carer's award: a " + "carer whose award comes to nil has no award and does not bar the " + "addition. The model applies the same rule in Northern Ireland, " + "where SR 2016/236 reg 31(7) inserted the same words into SPC Regs " + "(NI) 2003 Sch 1 para 1, with the same amount parameter. " "Blindness (is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) @@ -44,6 +46,9 @@ class severe_disability_minimum_guarantee_addition(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/2", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/3", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", ) def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index 64394d7f8d..a7faff105e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -23,46 +23,67 @@ class has_uc_award_with_carer_element(Variable): "premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b)) and of the " "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " "1(1) and (4)), through is_cared_for_by_carer_benefit_recipient. " - "The premium and addition cannot read the award: the non-dependant " - "deduction reads Pension Credit, whose addition reads the " - "attribution. has_uc_award_with_carer_element therefore reads the " - "award's conditions and bound. " - "It counts an award where the family claims Universal Credit " - "(claims_universal_credit), meets the age, pension-route and capital " - "conditions (is_uc_eligible) and has a positive upper bound on its " - "award (uc_award_upper_bound), and where the award includes the carer " - "element in respect of this claimant " - "(uc_carer_element_included_for_claimant). A nil bound " - "leaves a nil award, and so no award (Welfare Reform Act 2012 s.5(1)(b) " - "and (2)(b); DWP sets the minimum at one penny, reg 17). A carer " - "whose income reduces both bound calculations to nil does not count. " - "A carer can still count after income reduces the actual award to " - "nil when the bound's calculation without housing costs remains " - "positive. A carer whose award a non-dependant deduction takes to nil " - "still counts, and so does one whose family takes Tax-Free Childcare " - "instead (forgoes_universal_credit_for_tax_free_childcare reads the " - "award); #2795 tracks all three. " - "Where an input or a scenario stores universal_credit for the period, " - "or a reform neutralizes it, reading it cannot cycle, so this reads " - "the award itself (receives_uc_carer_element)." + "An award needs an amount payable of at least one penny (Welfare " + "Reform Act 2012 s.5(1)(b) and (2)(b); UC Regs 2013 reg 17), so the " + "model counts a claimant in respect of whom the award includes the " + "carer element (uc_carer_element_included_for_claimant) only where " + "the family's award before the benefit cap " + "(universal_credit_pre_benefit_cap) comes to more than nil. That " + "award comes to nil where income tapers it to nil, after any " + "non-dependant deduction and with the work allowance that the " + "housing costs element sets, and where the family takes Tax-Free " + "Childcare instead (forgoes_universal_credit_for_tax_free_childcare). " + "The model reads the award before the cap, not universal_credit, " + "because the benefit cap reads Child Tax Credit, whose income reads " + "Pension Credit, whose addition reads this variable. The two agree " + "for an award that includes the carer element: DWP exempts it from " + "the cap (UC Regs 2013 reg 83(1)(j)), and debt deductions leave at " + "least one penny payable. The housing costs element reads the " + "non-dependant deduction. Its exemption for a non-dependant family " + "on Pension Credit (uc_non_dep_receives_pension_credit) calculates " + "that family's Pension Credit in a branch with every housing costs " + "element set to nil, and in that branch this variable reads the " + "award without housing costs. The branch decides only that " + "exemption. A member of the non-dependant family gains or loses it " + "through this variable only where the family gets Pension Credit " + "with the severe disability addition and none without it, and the " + "member has no other exemption: someone who receives armed forces " + "independence payment and no other qualifying benefit, or a blind " + "partner under SPC Sch I para 1(1)(c) who receives none. There the " + "model can exempt a non-dependant whom the law does not, or the " + "reverse. Where an input or a scenario stores universal_credit for " + "the period, or a reform neutralizes it, reading it cannot cycle, so " + "this reads the award itself (receives_uc_carer_element). The model " + "applies the same rule in Northern Ireland, whose legislation uses " + "the same words: Welfare Reform (Northern Ireland) Order 2015 art. " + "10, UC Regs (NI) 2016 regs 17 and 30, SPC Regs (NI) 2003 Sch 1 para " + "1 as SR 2016/236 reg 31(7) amended it, and HB Regs (NI) 2006 Sch 4 " + "para 14(2)(a)(iii)." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", "https://www.legislation.gov.uk/ukpga/2012/5/section/5", "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/nisi/2015/2006/article/10", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/17", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", + "https://www.legislation.gov.uk/nisr/2016/216/schedule/4/paragraph/15", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/14", ) def formula(person, period, parameters): if award_reads_no_other_variable(person.simulation, period): return person("receives_uc_carer_element", period) benunit = person.benunit - on_claim = ( - benunit("claims_universal_credit", period) - & benunit("is_uc_eligible", period) - & (benunit("uc_award_upper_bound", period) > 0) - ) + # The award before the cap: DWP exempts a carer-element award from + # the cap, and universal_credit's cap reads Pension Credit. + award = benunit("universal_credit_pre_benefit_cap", period) + includes_carer_element = benunit("uc_carer_element", period) > 0 included = person("uc_carer_element_included_for_claimant", period) - return on_claim & included + return included & includes_carer_element & (award > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py deleted file mode 100644 index aade3f9656..0000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_award_upper_bound.py +++ /dev/null @@ -1,112 +0,0 @@ -from policyengine_uk.model_api import * - - -class uc_award_upper_bound(Variable): - value_type = float - entity = BenUnit - label = "Upper bound on the Universal Credit award" - documentation = ( - "The model calculates this upper bound without reading the " - "non-dependant deduction. Its formula for " - "universal_credit_pre_benefit_cap never returns a larger award. DWP " - "awards the maximum amount less the income reduction (UC Regs 2013 " - "reg 22). The maximum amount includes the housing costs element " - "(uc_housing_costs_element): the rent a renter may claim less DWP's " - "non-dependant deductions. Where that element exceeds nil, DWP " - "applies the lower work allowance (reg 22(2)(b)). Where DWP deducts " - "less than the housing costs it allows (the rent, capped at the Local " - "Housing Allowance rate for a private renter), the award stays at or " - "below the award with no deduction. Where it deducts at least those " - "housing costs, DWP calculates the award with no housing costs " - "element and the higher work allowance " - "(reg 22(2)(a); Sch 4 para 13). The formula takes the larger of those " - "two awards. It repeats the award's steps in their order, rounding " - "where they store a value, so the bound never falls below the award " - "the model's formulas calculate. An input or a scenario can store a " - "larger award; the carer test then reads the stored award " - "(has_uc_award_with_carer_element). A family whose bound comes to nil " - "has a nil award, and so no award: Parliament makes an amount " - "payable of at least the prescribed minimum a financial condition " - "(Welfare Reform Act 2012 s.5(1)(b) and (2)(b)), and DWP sets that " - "minimum at one penny (reg 17). DWP makes no deduction for a " - "non-dependant who receives Pension Credit (Sch 4 para 16(2)(b)), so " - "the model's deduction reads Pension Credit, whose severe disability " - "addition reads who has an award with the carer element. The bound " - "reads neither, so has_uc_award_with_carer_element can read it. The " - "bound can exceed a nil award in three ways (#2795). A non-dependant " - "deduction can take the award to nil. A family that takes Tax-Free " - "Childcare instead gets no award " - "(forgoes_universal_credit_for_tax_free_childcare). And for a family " - "with a work allowance and a small housing costs element, the " - "calculation without housing costs applies the higher work allowance, " - "whose extra earnings disregard can outweigh the housing costs it " - "drops; that calculation can stay positive when the award comes to " - "nil, even with no non-dependant deduction." - ) - definition_period = YEAR - unit = GBP - defined_for = "is_uc_eligible" - reference = ( - "https://www.legislation.gov.uk/uksi/2013/376/regulation/22", - "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", - "https://www.legislation.gov.uk/ukpga/2012/5/section/5", - "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", - ) - - def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.universal_credit.means_test - # Housing costs before any non-dependant deduction, as in - # uc_housing_costs_element. - tenure_type = benunit("benunit_tenure_type", period) - tenure_types = tenure_type.possible_values - rent = benunit("benunit_rent", period) - capped_rent_amount = min_(benunit("uc_LHA_cap", period), rent) - max_housing_costs = select( - [ - (tenure_type == tenure_types.RENT_FROM_COUNCIL) - | (tenure_type == tenure_types.RENT_FROM_HA), - tenure_type == tenure_types.RENT_PRIVATELY, - ], - [rent, capped_rent_amount], - default=0, - ) - no_deduction = max_(max_housing_costs, 0).astype(np.float32) - elements = [ - benunit(name, period) - for name in ( - "uc_standard_allowance", - "uc_child_element", - "uc_disability_elements", - "uc_carer_element", - ) - ] - childcare = benunit("uc_childcare_element", period) - earned_income = benunit("uc_earned_income_before_work_allowance", period) - unearned_income = benunit("uc_unearned_income", period) - work_allowance_eligible = benunit("is_uc_work_allowance_eligible", period) - awards = [] - for housing in (no_deduction, np.zeros_like(no_deduction)): - # The steps of uc_maximum_amount (its adds, in order), - # uc_work_allowance, uc_earned_income, uc_income_reduction and - # universal_credit_pre_benefit_cap, each rounded to float32 where - # that variable stores its value, so no rounding puts the bound - # below the award. - maximum = 0 - for element in elements: - maximum = maximum + element - maximum = maximum + housing + childcare - monthly_allowance = where( - housing > 0, - p.work_allowance.with_housing, - p.work_allowance.without_housing, - ) - work_allowance = where( - work_allowance_eligible, - (monthly_allowance * MONTHS_IN_YEAR).astype(np.float32), - 0, - ).astype(np.float32) - earned = max_(0, earned_income - work_allowance) - total_reduction = p.reduction_rate * earned + unearned_income - reduction = min_(maximum, total_reduction).astype(np.float32) - awards.append(maximum - reduction) - return max_(awards[0], awards[1]) diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 4ea9c3f8f6..80bc7d9619 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -49,17 +49,17 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "Pension, reduces to nil keeps the entitlement but receives nothing, " "so the model does not attribute the allowance. " "has_uc_award_with_carer_element gives " - "a carer element award from the claim, the award's conditions and an " - "upper bound on the award (uc_award_upper_bound), because the " - "Universal Credit non-dependant deduction exemption reads Pension " - "Credit, which reads the addition. A carer whose bound comes to nil " - "has no award (Welfare Reform Act 2012 s.5), and the model attributes " - "none. The bound can stay positive after income reduces the actual " - "award to nil, when its calculation without housing costs gives a " - "higher work allowance. A carer whose award a non-dependant deduction " - "takes to nil, or whose family takes Tax-Free Childcare instead, " - "still counts too, and can withhold a premium or addition the law " - "would pay (#2795). A carer with both an allowance and a carer element " + "a carer element award from the family's award before the benefit " + "cap: a carer whose award comes to nil, whether income tapers it to " + "nil or the family takes Tax-Free Childcare instead, has no award " + "(Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and the " + "model attributes none. The model applies the same attribution in " + "Northern Ireland (SPC Regs (NI) 2003 Sch 1 para 1(1) and (4), as SR " + "2016/236 reg 31(7) amended it; HB Regs (NI) 2006 Sch 4 para 14(2); " + "SR 2016/236 regs 26, 28, 35 and 36 insert the same words in Northern " + "Ireland's Income Support, Jobseeker's Allowance, pension-age Housing " + "Benefit and ESA schemes). A carer with both an allowance and a " + "carer element " "counts once. Partners who care for the same severely disabled person " "(partners_care_for_same_severely_disabled_person) have one relevant " "entitlement between them (s.70(7ZA) and (7A)), so where one of them " @@ -77,6 +77,13 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", "https://www.legislation.gov.uk/uksi/2013/376/regulation/30", "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/14", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", + "https://www.legislation.gov.uk/nisi/2015/2006/article/10", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/17", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 5dc57d82aa..65913bcb92 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -21,10 +21,9 @@ class pension_age_severe_disability_premium(Variable): "claim, as a couple may agree under HB(SPC) reg 63(1) and the CTR " "schemes (England SI 2012/2885 Sch 8 para 4(1)). " "is_cared_for_by_carer_benefit_recipient attributes carers, and " - "counts a Universal Credit award with the carer element from the " - "award's conditions and the upper bound on the award " - "(uc_award_upper_bound). The bound can stay positive when the actual " - "award comes to nil (#2795). The Pension Credit severe " + "counts a Universal Credit award with the carer element only where " + "the carer's award comes to more than nil " + "(has_uc_award_with_carer_element). The Pension Credit severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 18f1f60b36..658c8eeb5f 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -22,14 +22,11 @@ class severe_disability_premium(Variable): "paid for both. is_cared_for_by_carer_benefit_recipient attributes " "each carer benefit to the person it is paid for caring for, and the " "Pension Credit severe disability addition shares it. The attribution " - "counts an award of Universal Credit which includes the carer element from the " - "award's conditions and an upper bound on the award " - "(has_uc_award_with_carer_element). A carer whose bound comes to nil " - "does not bar the premium. The model can still withhold it where a " - "non-dependant deduction takes the award to nil, where the carer's " - "family takes Tax-Free Childcare instead, or where a small housing " - "costs element leaves the bound's calculation without housing costs " - "positive (#2795). Not modelled: a carer's " + "counts an award of Universal Credit which includes the carer " + "element (has_uc_award_with_carer_element). A carer whose award " + "comes to nil, after any non-dependant deduction or because the " + "family takes Tax-Free Childcare instead, does not bar the premium. " + "Not modelled: a carer's " "allowance or carer element that would be paid but for a loss of " "benefit restriction under section 6B or 7 of the Social Security " "Fraud Act 2001 also counts (HB Regs 2006 Sch 3 para 14(7), IS Regs " From 31139e822c91a1c052eaccd335e1ece0f856797b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:58:21 +0000 Subject: [PATCH 145/168] Need a positive Universal Credit award for Scotland's carer premium in every year An earnings nil case leaves no award to have a value of zero under SSI 2021/249 reg 4 as it stood from 2022 to 2025, so limb (c) of Sch 1 para 6(1) reads the positive award from 2022-23. Remove uc_award_includes_zero_value_award and update the 2024 case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FQc4koGSzCUYko19B --- .../council_tax_reduction_carer_premium.yaml | 34 +++++----- .../council_tax_reduction_carer_premium.py | 65 ++++++++----------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml index 80cfdf4ca3..2d5b93dc14 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -13,12 +13,13 @@ # a value of zero" (reg 4, as amended by SSI 2022/52 reg 3); from 1 April 2025 # it "means an award of universal credit made under Part 1 of the 2012 Act" # (SSI 2025/24 reg 8), and an income that tapers the award to nil leaves no -# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). The model -# reads the earlier definition as reaching a carer whose earnings take the -# award to nil. The sources leave that reading open: UC Claims and Payments -# Regs 2013 reg 32A(1)(a) covers a claim on which DWP makes no award, and -# Ipswich BC v TD [2024] UKUT 117 (AAC) finds no Universal Credit -# entitlement in a nil award without construing the Scottish text. +# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). An income +# that tapers the amount payable below one penny leaves no award to have a +# value of zero in any year: DWP makes no award or the award ends (UC Claims +# and Payments Regs 2013 reg 32A(1)), and the Scottish Government's policy +# note to SSI 2025/24 confines the earlier words to an award a sanction +# reduces to zero. So the model needs an award of at least one penny in every +# year from 2022-23. # # Amounts: carer premium £48.15 a week from 1 April 2026 (SSI 2021/249 Sch 1 # para 5(1), substituted by SSI 2026/56), £46.40 in 2025-26, £45.60 in @@ -186,15 +187,16 @@ receives_uc_carer_element: false council_tax_reduction_carer_premium: 0 -- name: In 2024 the model counts a Scottish carer's nil award as an award with the carer element +- name: In 2024 a Scottish carer whose earnings taper the award to nil has no award and no carer premium # The same carer in 2024-25. The maximum amount comes to (393.45 + 198.31) - # x 12 = 7,101.12, and the taper on £20,000 of earnings less income tax and - # National Insurance takes more, so the award comes to nil. Until 31 March - # 2025 an "award of universal credit" included one "with a value of zero" - # (reg 4), and the model reads those words as reaching this nil award. - # Under that chosen reading, the model includes the carer premium (para - # 6(1)(c)): 45.60 x 52 = 2,371.20. The sources leave that reading open (see - # the header). + # x 12 = 7,101.12, and the taper, 0.55 x 17,942.66 = 9,868.46 on £20,000 + # of earnings less income tax and National Insurance, takes more, so the + # award comes to nil. Until 31 March 2025 an "award of universal credit" + # included "a universal credit award with a value of zero" (reg 4), but an + # income that fails the financial condition leaves no award to have that + # value (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), so para + # 6(1)(c) does not apply: no carer premium, where the model would + # otherwise include 45.60 x 52 = 2,371.20. period: 2024 absolute_error_margin: 0.01 input: @@ -214,11 +216,13 @@ region: SCOTLAND output: uc_carer_element: 198.31 * 12 + uc_maximum_amount: 7_101.12 + uc_earned_income: 17_942.66 universal_credit: 0 receives_uc_carer_element: false is_entitled_to_carer_benefit: false carer_premium: 0 - council_tax_reduction_carer_premium: 45.60 * 52 + council_tax_reduction_carer_premium: 0 - name: From 1 April 2025 the same Scottish carer's nil award leaves no award # The same carer in 2025-26. The maximum amount comes to (400.14 + 201.68) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py index 283670b440..cda349ac2e 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -19,33 +19,31 @@ class council_tax_reduction_carer_premium(Variable): "an award of Universal Credit which includes the carer element. " "carer_premium tests limbs (a) and (b) " "(is_entitled_to_carer_benefit). For limb (c), the Scottish Ministers " - "defined an award of Universal Credit to include one with a value of " - "zero from 1 April 2022 (reg 4, as amended by SSI 2022/52 reg 3), and " - "from 1 April 2025 as an award made under Part 1 of the Welfare Reform " - "Act 2012 (SSI 2025/24 reg 8), under which an income that tapers the " - "award to nil leaves no award (s.5(1)(b); UC Regs 2013 reg 17). The " - "model reads the earlier definition as reaching a carer whose " - "earnings take the award to nil, and so counts that nil award from " - "2022-23 to 2024-25. The sources leave that reading open. UC Claims " - "and Payments Regs 2013 reg 32A(1)(a) covers a claim on which DWP " - "makes no award because income fails the financial condition in " - "s.5(1)(b). The Scottish Government's policy note " - "to SSI 2025/24 records a 2022 intention to treat such a claimant as " - "having an award, but calls reg 10A an expansion that gives the " - "intention effect. Ipswich BC v TD [2024] UKUT 117 (AAC) holds that " - "a nil award carries no Universal Credit entitlement, without " - "construing the Scottish definition. While the earlier definition " - "holds (uc_award_includes_zero_value_award), this variable reads limb " - "(c) from the award's conditions: the family claims Universal Credit, " - "meets its basic conditions and does not take Tax-Free Childcare " - "instead, and the award includes the carer element for this " - "claimant. Afterwards it reads the positive award " - "(receives_uc_carer_element). From 1 April " + "defined an award of Universal Credit from 1 April 2022 to include " + '"a universal credit award with a value of zero" (reg 4, as SSI ' + '2022/52 reg 3 amended it), and from 1 April 2025 as "an award of ' + 'universal credit made under Part 1 of the 2012 Act" (SSI 2025/24 ' + "reg 8). An income that tapers the amount payable below one penny " + "fails a financial condition of entitlement (Welfare Reform Act 2012 " + "s.5(1)(b); UC Regs 2013 reg 17), so DWP makes no award or the award " + "ends (UC Claims and Payments Regs 2013 reg 32A(1)), and there is no " + "award to have a value of zero. The Scottish Government's policy " + "note to SSI 2025/24 confines the earlier words to an award a " + "sanction reduces to zero, and the Scottish Ministers inserted reg " + "10A to bring part of the earnings case in from 1 April 2025. So in " + "every year from 2022-23 the model reads limb (c) from the award " + "itself (receives_uc_carer_element): an award of at least one penny " + "that includes the carer element in respect of this claimant. For " + "an award that earnings end, its last assessment period and any " + "reclaim months from 2022-23 to 2024-25 remain arguable; the model " + "cannot tell that award from a first claim, on which DWP makes no " + "award. From 1 April " "2025 the Scottish Ministers also count two nil awards as awards (reg " "10A): one a sanction reduces to nil, and one whose claimant the " "Secretary of State treats as reclaiming after earnings end it (UC " "Claims and Payments Regs 2013 reg 32A). The model has neither " - "sanctions nor reclaims, so it pays no premium for those. A person " + "sanctions nor reclaims, so it pays no premium for those, nor for " + "a sanctioned nil award from 2022-23 to 2024-25. A person " "with both an allowance and a carer element qualifies once: councils " "include the premium for each applicant or partner who qualifies " "(paras 5(2) and (3)), not for each award. Partners caring for the " @@ -66,12 +64,16 @@ class council_tax_reduction_carer_premium(Variable): "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/5", "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", "https://www.legislation.gov.uk/ssi/2021/249/regulation/1", - "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2024-04-30", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2022-04-01", + "https://www.legislation.gov.uk/ssi/2022/52/regulation/3/made", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2025-04-01", "https://www.legislation.gov.uk/ssi/2025/24/regulation/8/made", "https://www.legislation.gov.uk/ssi/2021/249/regulation/10A", "https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01", "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/uksi/2013/380/regulation/32A", ) def formula(benunit, period, parameters): @@ -87,20 +89,9 @@ def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) # SSI 2021/249 Sch 1 para 6(1)(c): an award of Universal Credit which - # includes the carer element. Until 31 March 2025 the model counts a - # nil award as one "with a value of zero" (reg 4), a reading the - # sources leave open, so the award's conditions decide. + # includes the carer element. An earnings nil case has no award in + # any year (Welfare Reform Act 2012 s.5(1)(b); reg 4 and reg 10A). carer_element_award = person("receives_uc_carer_element", period) - if scotland.uc_award_includes_zero_value_award: - family = person.benunit - on_claim = ( - family("claims_universal_credit", period) - & family("is_uc_eligible", period) - & ~family("forgoes_universal_credit_for_tax_free_childcare", period) - ) - carer_element_award = carer_element_award | ( - on_claim & person("uc_carer_element_included_for_claimant", period) - ) # Para 6(1)(a) and (b), as in carer_premium, or (c). caring = person("is_entitled_to_carer_benefit", period) | carer_element_award qualifying_carers = benunit.sum(claimant_or_partner & caring) From 4c9c8b177532798c93ebe523764cca94560f1ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:14:22 +0000 Subject: [PATCH 146/168] Charge no Plan 5 student loan repayment before 6 April 2026 Add gov.hmrc.student_loans.repayments_due.plan_5, false until 6 April 2026 (SI 2009/470 reg. 15(2BA) and (2BB)), and have student_loan_repayment charge Plan 5 borrowers nothing while it is false. Fixes #2863 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FfQ4R4oAVPwJQBrx4 --- changelog.d/2863.fixed.md | 1 + .../validation/student-loan-repayments.ipynb | 2 +- .../student_loans/repayments_due/plan_5.yaml | 18 ++ .../student_loans/plan_5_repayment_start.yaml | 70 +++++++ .../test_plan_5_repayment_start_properties.py | 171 ++++++++++++++++++ .../student_loans/student_loan_repayment.py | 8 +- 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2863.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml create mode 100644 policyengine_uk/tests/test_plan_5_repayment_start_properties.py diff --git a/changelog.d/2863.fixed.md b/changelog.d/2863.fixed.md new file mode 100644 index 0000000000..f15cc8fa6f --- /dev/null +++ b/changelog.d/2863.fixed.md @@ -0,0 +1 @@ +The student loan repayment formula now charges Plan 5 borrowers nothing before 6 April 2026, as SI 2009/470 reg. 15(2BA) requires. diff --git a/docs/book/validation/student-loan-repayments.ipynb b/docs/book/validation/student-loan-repayments.ipynb index d6b90f76e6..b49bdf2cf2 100644 --- a/docs/book/validation/student-loan-repayments.ipynb +++ b/docs/book/validation/student-loan-repayments.ipynb @@ -20,7 +20,7 @@ "- **Plan 1** (pre-2012 England/Wales, Scotland, NI): 9% of income above £24,990 (2024-25)\n", "- **Plan 2** (post-2012 England/Wales): 9% of income above £27,295 (2024-25)\n", "- **Plan 4** (Scotland post-2017): 9% of income above £27,660 (2024-25)\n", - "- **Plan 5** (England post-2023): 9% of income above £25,000 (2024-25)\n", + "- **Plan 5** (England post-2023): nothing before 6 April 2026 (SI 2009/470 reg. 15(2BA)), then 9% of income above £25,000 from 2026-27\n", "- **Postgraduate**: 6% of income above £21,000 (2024-25)\n", "\n", "The FRS captures reported student loan repayments, while PolicyEngine calculates repayments based on income and loan plan type." diff --git a/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml new file mode 100644 index 0000000000..59c96cf873 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml @@ -0,0 +1,18 @@ +description: Plan 5 student loan borrowers owe repayments if this is true. A borrower need not repay any part of a Plan 5 loan on or before 5 April 2026, so from 6 April 2026 the student loan repayment formula charges Plan 5 borrowers. +values: + 2015-04-06: false + 2026-04-06: + value: true + metadata: + reference: + - title: SI 2009/470 reg. 15(2BB) (omits paragraph (2BA) on 6 April 2026) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 +metadata: + unit: bool + period: year + label: Plan 5 student loan repayments due + reference: + - title: SI 2009/470 reg. 15(2BA) and (2BB), inserted by SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 + - title: SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml new file mode 100644 index 0000000000..b5864a09ac --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml @@ -0,0 +1,70 @@ +# SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "a borrower +# is not required to repay any part of a plan 5 loan on or before 5th April +# 2026". Reg. 15(2BB) omits (2BA) on 6 April 2026. +# +# The model reads year Y's parameters at 30 April of Y, so 2025 reads the law +# for 2025-26 (no Plan 5 repayment due) and 2026 reads 2026-27 (due). +# Repayment income here equals employment income: there is no other income, +# deduction or benefit in kind. + +- name: A Plan 5 borrower on £40,000 owes nothing in 2024-25 + period: 2024 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 repays 9% above £25,000 in 2026-27 + period: 2026 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # 0.09 x (40,000 - 25,000) = 0.09 x 15,000 = 1,350 + student_loan_repayment: 1_350 + +- name: A Plan 5 borrower on £200,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 200_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies however far income exceeds the £25,000 threshold. + student_loan_repayment: 0 + +- name: A Plan 2 borrower on £60,000 repays 9% above £28,470 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 60_000 + student_loan_plan: PLAN_2 + output: + # Plan 2 threshold from 6 April 2025: £28,470. + # 0.09 x (60,000 - 28,470) = 0.09 x 31,530 = 2,837.70 + student_loan_repayment: 2_837.70 + +- name: A postgraduate borrower on £40,000 repays 6% above £21,000 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: POSTGRADUATE + output: + # Postgraduate threshold £21,000, rate 6%. + # 0.06 x (40,000 - 21,000) = 0.06 x 19,000 = 1,140 + student_loan_repayment: 1_140 diff --git a/policyengine_uk/tests/test_plan_5_repayment_start_properties.py b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py new file mode 100644 index 0000000000..63d803320d --- /dev/null +++ b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py @@ -0,0 +1,171 @@ +"""Plan 5 student loan repayments start on 6 April 2026. + +SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "Despite +paragraphs (2), (2A) and (2B), a borrower is not required to repay any part of +a plan 5 loan on or before 5th April 2026." Reg. 15(2BB) omits (2BA) on 6 April +2026. The model reads year Y's parameters at 30 April of Y, so the years 2015 +to 2025 carry no Plan 5 liability and 2026 onwards do. + +Each property draws borrowers on every plan, batched into one Simulation per +example, and compares the model with ``reference_repayment``: the formula +without a Plan 5 start date, which charges the plan's rate on repayment income +above the plan's threshold, capped by a positive balance. + +Invariants: + +1. P1: a Plan 5 borrower repays nothing in each year from 2015 to 2025, for + any income and any balance. +2. P2: from 2026, every borrower's repayment equals the reference. +3. P3: a borrower on any plan other than Plan 5 repays the reference amount in + every year from 2015. +4. P4: a repayment never falls below nil and never exceeds a positive balance. +""" + +from unittest.mock import patch + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system + +_PROPERTY_SYSTEM = system.clone() +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +PLANS = ("NONE", "PLAN_1", "PLAN_2", "PLAN_4", "PLAN_5", "POSTGRADUATE") +THRESHOLD_KEYS = { + "PLAN_1": "plan_1", + "PLAN_2": "plan_2", + "PLAN_4": "plan_4", + "PLAN_5": "plan_5", + "POSTGRADUATE": "postgraduate", +} +FIRST_YEAR = 2015 +PLAN_5_FIRST_YEAR = 2026 +LAST_YEAR = 2035 + +borrowers = st.lists( + st.tuples( + st.sampled_from(PLANS), + st.integers(0, 300_000), + # Zero means no balance is known, so no cap applies. + st.one_of(st.just(0), st.integers(1, 100_000)), + ), + min_size=1, + max_size=8, +) +plan_5_borrowers = st.lists( + st.tuples( + st.just("PLAN_5"), + st.integers(0, 1_000_000), + st.one_of(st.just(0), st.integers(1, 200_000)), + ), + min_size=1, + max_size=8, +) + + +def simulate(people, year): + """One Simulation with each (plan, employment income, balance) its own + household.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (plan, income, balance) in enumerate(people): + name = f"p{i}" + situation["people"][name] = { + "age": {year: 30}, + "employment_income": {year: income}, + "student_loan_plan": {year: plan}, + "student_loan_balance": {year: balance}, + } + situation["benunits"][f"b{i}"] = {"members": [name]} + situation["households"][f"h{i}"] = {"members": [name]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + +def reference_repayment(sim, people, year): + """Repayments from the plan's rate and threshold alone, with no Plan 5 + start date.""" + p = sim.tax_benefit_system.parameters(str(year)).gov.hmrc.student_loans + net_income = sim.calculate("net_income", year) + benefits = np.minimum( + sim.calculate("taxable_employment_income", year), + sim.calculate("benefits_in_kind", year), + ) + income = np.maximum(net_income - benefits, 0) + out = [] + for (plan, _, balance), person_income in zip(people, income): + if plan == "NONE": + out.append(0.0) + continue + threshold = p.thresholds[THRESHOLD_KEYS[plan]] + rate = ( + p.postgraduate_repayment_rate + if plan == "POSTGRADUATE" + else p.repayment_rate + ) + repayment = rate * max(0.0, person_income - threshold) + out.append(min(repayment, balance) if balance > 0 else repayment) + return np.array(out) + + +@pytest.fixture(scope="module", autouse=True) +def _release_simulation(): + yield + _PROPERTY_SYSTEM.simulation = None + + +@PROPERTY_SETTINGS +@given( + people=plan_5_borrowers, + year=st.integers(FIRST_YEAR, PLAN_5_FIRST_YEAR - 1), +) +def test_p1_plan_5_borrowers_repay_nothing_before_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + assert np.all(repayment == 0) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(PLAN_5_FIRST_YEAR, LAST_YEAR)) +def test_p2_every_plan_follows_threshold_and_rate_from_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given( + people=borrowers.map( + lambda people: [person for person in people if person[0] != "PLAN_5"] + ).filter(bool), + year=st.integers(FIRST_YEAR, LAST_YEAR), +) +def test_p3_other_plans_unchanged_in_every_year(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(FIRST_YEAR, LAST_YEAR)) +def test_p4_repayment_is_bounded_by_nil_and_balance(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + balance = np.array([person[2] for person in people], dtype=float) + assert np.all(repayment >= 0) + capped = balance > 0 + assert np.all(repayment[capped] <= balance[capped] + 1e-6) diff --git a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py index cd24f7df64..6be67396a3 100644 --- a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py +++ b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py @@ -13,7 +13,9 @@ class student_loan_repayment(Variable): "Annual student loan repayment calculated from income and plan type. " "Repayments are 9% of income above threshold for Plans 1/2/4/5, " "and 6% for Postgraduate loans, capped by the outstanding balance " - "when that balance is available." + "when that balance is available. Plan 5 borrowers repay nothing " + "before 6 April 2026 (SI 2009/470 reg. 15(2BA) and (2BB)), so the " + "formula charges them nil for 2025-26 and earlier years." ) definition_period = YEAR unit = GBP @@ -46,6 +48,10 @@ def formula(person, period, parameters): rate = person("student_loan_repayment_rate", period) uncapped_repayment = rate * max_(0, income - threshold) + # SI 2009/470 reg. 15(2BA) and (2BB): no Plan 5 repayment is due on or + # before 5 April 2026. + repayment_due = (plan != StudentLoanPlan.PLAN_5) | p.repayments_due.plan_5 + uncapped_repayment = where(repayment_due, uncapped_repayment, 0) return where(balance > 0, min_(uncapped_repayment, balance), uncapped_repayment) From 3feb308eea20890984f51d3be7864f34a546c843 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:28:30 +0000 Subject: [PATCH 147/168] Give the history and basis of the Scottish pensioner personal relief reading Parliament omitted ITA 2007 s. 36 for 2015-16 (Finance Act 2014 s. 2) and s. 37 for 2016-17 (Finance Act 2015 s. 5), extending s. 35 each time. The documentation now says so and gives the Interpretation Act 1978 s. 17(2)(a) basis for reading SSI 2012/319 reg 37(1)'s reference as s. 35. The formula is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GfQq7afW2dJPbfg3p --- ...ion_scotland_self_employment_deductions.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index 59d3918bb9..f2c2831bbd 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -29,11 +29,19 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "allowance left after the taper, because in s. 35(2) Parliament " "reduces 'the allowance under subsection (1)'. At pension age, they " "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " - "Parliament omitted s. 36 (Finance Act " - "2014), and in s. 37 gives the section 35 amount only to a person " - "born before 6 April 1938; a council that read the reference " - "literally would allow nobody younger any relief. The formula reads " - "it as the same tapered s. 35 allowance for every applicant. A child " + "Parliament omitted s. 36 for 2015-16 and later years and s. 37 for " + "2016-17 and later years, and each time extended s. 35 to the people " + "the omitted section had covered (Finance Act 2014 s. 2(4), (5) and " + "(9); Finance Act 2015 s. 5(4), (5) and (11)). A council that read " + "reg 37(1) literally would allow no applicant any relief from " + "2016-17. Where an Act " + "repeals and re-enacts a provision, Parliament provides that a " + "reference to it in subordinate legislation reads as a reference to " + "the provision re-enacted (Interpretation Act 1978 ss. 17(2)(a) and " + "23(1)). The formula takes each Finance Act as repealing an " + "age-related allowance and re-enacting it in the extended s. 35, so " + "it reads the reference as the tapered s. 35 allowance, which s. " + "35(1) now gives every individual. A child " "minder's chargeable income comes to one-third of the earnings; the " "model does not identify child minders, so it treats every profit as " "chargeable income in full." @@ -48,6 +56,9 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", "https://www.legislation.gov.uk/ukpga/2007/3/section/35", "https://www.legislation.gov.uk/ukpga/2007/3/section/37", + "https://www.legislation.gov.uk/ukpga/2014/26/section/2", + "https://www.legislation.gov.uk/ukpga/2015/11/section/5", + "https://www.legislation.gov.uk/ukpga/1978/30/section/17", ) def formula(person, period, parameters): From 4af0f04297064bd258d2c37dbfccdd8995bea1ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 18:27:12 -0500 Subject: [PATCH 148/168] Cite Interpretation Act 1978 s. 23(1) and (2) for the relief reading Section 23(2) is the provision that makes a reference in "any other enactment" in s. 17(2)(a) include one in subordinate legislation, such as SSI 2012/319 reg 37(1). The documentation cited s. 23(1) alone. Text only; the formula is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_scotland_self_employment_deductions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py index f2c2831bbd..bf6bd79650 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -37,9 +37,10 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "2016-17. Where an Act " "repeals and re-enacts a provision, Parliament provides that a " "reference to it in subordinate legislation reads as a reference to " - "the provision re-enacted (Interpretation Act 1978 ss. 17(2)(a) and " - "23(1)). The formula takes each Finance Act as repealing an " - "age-related allowance and re-enacting it in the extended s. 35, so " + "the provision re-enacted (Interpretation Act 1978 s. 17(2)(a), read " + "with s. 23(1) and (2)). The formula takes each Finance Act as " + "repealing an age-related allowance and re-enacting it in the " + "extended s. 35, so " "it reads the reference as the tapered s. 35 allowance, which s. " "35(1) now gives every individual. A child " "minder's chargeable income comes to one-third of the earnings; the " @@ -59,6 +60,7 @@ class council_tax_reduction_scotland_self_employment_deductions(Variable): "https://www.legislation.gov.uk/ukpga/2014/26/section/2", "https://www.legislation.gov.uk/ukpga/2015/11/section/5", "https://www.legislation.gov.uk/ukpga/1978/30/section/17", + "https://www.legislation.gov.uk/ukpga/1978/30/section/23", ) def formula(person, period, parameters): From be8c090a5e8c3ef184169becbd24976ba54ebd51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 08:26:27 -0500 Subject: [PATCH 149/168] Cite SSI 2023/302 regulation 2, which has no numbered paragraphs The Scottish Ministers list the qualifying disability benefits in regulation 2 of the Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, whose definitions carry no paragraph numbers. The link already points at regulation 2. Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/dwp/cares_for_severely_disabled_person.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py index fba05bc9a4..d3699861a3 100644 --- a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py +++ b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py @@ -55,7 +55,7 @@ class cares_for_severely_disabled_person(Variable): href="https://www.legislation.gov.uk/uksi/1976/409/regulation/3", ), dict( - title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 2(2)", + title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 2", href="https://www.legislation.gov.uk/ssi/2023/302/regulation/2", ), dict( From a759e4fa6f058973f8ab5edf174c40dafd2b21d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 13:39:34 +0000 Subject: [PATCH 150/168] Name six departures and the award-week limits for the supplement Name two more departures from the weekly rule in the documentation and the YAML header: a supplied part-year amount before the reduction netted against an overlapping benefit paid all year, and part weeks. Mark the list as non-exhaustive. State the 13-week and 25-week-6-day limits after a move, and that the move cases count complete award weeks from a Sunday. Name the 2027 rates the 2027 case uses, and label the partition head "Scottish Carer Supplement". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Ja7tnGtKGtfAaux53 --- policyengine_uk/data/fiscal_partition.yaml | 2 +- .../scottish_carer_supplement.yaml | 41 +++++++++++++++---- ...carer_supplement_award_weeks_properties.py | 6 +-- .../scottish_carer_supplement.py | 27 ++++++++---- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index b5f39a095c..3649bbeaa3 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -1038,7 +1038,7 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative - id: scottish_carer_supplement - label: Carer's Allowance Supplement (Scotland) + label: Scottish Carer Supplement variable: scottish_carer_supplement entity: person report_group: disability_and_carers_benefits diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml index e460f3d35c..4ba3ad42b4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml @@ -63,13 +63,32 @@ # days beginning on a Sunday and ending on a Saturday". The Scottish Ministers # set the weekly rates in reg 16(1) and (1A): Carer Support Payment 86.45 and # Scottish Carer Supplement 11.70. Under reg 42(1) and (5), a carer who moves -# to another part of the United Kingdom keeps Carer Support for 13 weeks; -# under reg 42(1A), where the cared-for person dies in that time, for 12 award -# weeks after the first day of the award week following the one in which they -# died, up to 25 weeks in all. The formula counts the weeks as the larger of +# to another part of the United Kingdom keeps Carer Support for 13 weeks from +# the day they stop being ordinarily resident in Scotland (reg 42(4)). Under +# reg 42(1A), where the cared-for person dies in that time, the carer keeps it +# for 12 award weeks after the first day of the award week following the one +# in which they died, up to 25 weeks and 6 days in all. The move cases below +# put the move on a Sunday, so they count 13 and 25 complete award weeks, each +# from a Sunday. A move on any other day spreads the 13 weeks over 14 award +# weeks. The formula counts the weeks as the larger of # carer_support_payment_pre_overlap and carer_support_payment over the weekly # Carer Support Payment rate, up to 52, and pays the weekly supplement rate for # each where carer_support_payment is positive, and nothing where it is £0. +# +# The count differs from the law in these cases, among others; the variable's +# documentation names each: +# - an overlapping benefit or earnings that vary within the year; +# - a supplied part-year payment for a person the model finds eligible in +# Scotland; +# - a supplied payment that an overlapping benefit has reduced, with no amount +# before the reduction; +# - the disable_simulated_benefits reform; +# - a supplied part-year amount before the reduction, which the model nets +# against an overlapping benefit paid all year: 1,123.85 for 13 award weeks +# and 2,000 a year of State Pension net to 0, where the weekly rule leaves +# 623.85 of Carer Support Payment and 152.10 of supplement; +# - part weeks, where Social Security Scotland pays a whole weekly supplement +# for each award week in which Carer Support Payment is payable. - name: Carer in England with 13 weeks of Carer Support Payment gets 13 weeks of supplement period: 2026 @@ -77,7 +96,8 @@ input: age: 35 country: ENGLAND - # Reg 42(1) and (5): 13 weeks after the move, 13 x 86.45 = 1,123.85. + # Reg 42(1), (4) and (5): a move on a Sunday gives 13 complete award + # weeks, each from a Sunday, 13 x 86.45 = 1,123.85. carer_support_payment: 1123.85 output: carer_support_payment_pre_overlap: 0 @@ -90,9 +110,10 @@ input: age: 35 country: ENGLAND - # Reg 42(1A): the cared-for person dies in week 13, and the carer keeps - # Carer Support for the 12 award weeks from the start of week 14, 25 weeks - # in all. 25 x 86.45 = 2,161.25. + # Reg 42(1A): the move falls on a Sunday and the cared-for person dies in + # award week 13. The carer keeps Carer Support for the 12 award weeks from + # the start of award week 14, 25 complete award weeks in all, each from a + # Sunday. 25 x 86.45 = 2,161.25. carer_support_payment: 2161.25 output: carer_support_payment_pre_overlap: 0 @@ -191,7 +212,9 @@ input: age: 35 country: ENGLAND - # 13 x 89.50 = 1,163.50, at the 2027 rate above. + # 13 complete award weeks from a Sunday move. 13 x 89.50 = 1,163.50, at + # the model's projected 2027 rates: Carer Support Payment 89.50 and + # Scottish Carer Supplement 12.10. carer_support_payment: 1163.50 output: # 1,163.50 / 89.50 = 13 award weeks; 13 x 12.10 = 157.30. diff --git a/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py index f00a7ba080..b03cfc4caf 100644 --- a/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py +++ b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py @@ -253,9 +253,9 @@ def pensioner(state_pension, country="SCOTLAND"): @PROPERTY_SETTINGS @given(people=batches(supplied=True)) -# 13 and 25 weeks of the 2026 rate after a move to England (reg 42), one week, -# 60 weeks, a carer in Scotland with no caring inputs, and one the model finds -# eligible. +# 13 and 25 complete award weeks of the 2026 rate after a Sunday move to +# England (reg 42), one week, 60 weeks, a carer in Scotland with no caring +# inputs, and one the model finds eligible. @example( people=[ england(1_123.85), diff --git a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py index ce9f7bdd34..026f3429f6 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py +++ b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py @@ -20,12 +20,15 @@ class scottish_carer_supplement(Variable): "overlapping benefit reduces the payment but leaves some keeps all 52 " "weeks. Where a user supplies the payment and the inputs give no " "amount before the reduction, the formula divides the payment by the " - "weekly rate to count the weeks; a carer who moves to England, for " - "example, keeps Carer Support for up to 13 weeks, or up to 25 in all " - "where the cared-for person dies in that time. The count differs from " - "the law in four cases. Where an overlapping benefit or earnings vary " - "within the year, the formula counts 52 weeks or none from the annual " - "amounts, while Social Security Scotland pays the supplement only for " + "weekly rate to count the weeks. A carer who moves to England, for " + "example, keeps Carer Support for 13 weeks after the move. Where the " + "cared-for person dies in that time, the carer keeps it for up to 25 " + "weeks and 6 days in all. A move on a Sunday gives 13 complete award " + "weeks, or up to 25 after such a death, each counted from a Sunday. A " + "move on any other day spreads the 13 weeks over 14 award weeks. The " + "count differs from the law in these cases, among others. Where an " + "overlapping benefit or earnings vary within the year, the formula " + "counts 52 weeks or none from the annual amounts, while Social Security Scotland pays the supplement only for " "the award weeks in which the payment stays payable. Where a user " "supplies a part-year payment and also sets inputs that make the " "person eligible in Scotland, the formula counts 52 weeks, since the " @@ -40,7 +43,17 @@ class scottish_carer_supplement(Variable): "eligible, even where the report covers only part of the year, and the" " weeks the report implies at each year's weekly rate for any other " "reported carer; the reform doesn't uprate the report, so a report of " - "a full year counts fewer weeks in later years. The supplement is a " + "a full year counts fewer weeks in later years. Where a user supplies " + "a part-year amount before the overlapping-benefit reduction and the " + "person has an overlapping benefit paid all year, the model nets the " + "annual amounts. The formula can then count no weeks, while Social " + "Security Scotland compares the weekly amounts in each award " + "week of the award. The formula also counts part weeks where a " + "payment falls between whole numbers of weekly rates. Such a payment " + "arises for an award that starts or ends during an award week or " + "spans an April uprating. Social Security Scotland instead pays a " + "whole weekly supplement for each award week in which Carer Support " + "Payment is payable. The supplement is a " "component of Carer Support separate from the Carer Support Payment " "component. Pension Credit, Housing Benefit and Scottish Council Tax " "Reduction, at pension age and working age, do not count it as income;" From 7f2ac120bba1175a5457a1739709387ce8d52cfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 09:31:29 -0500 Subject: [PATCH 151/168] Wrap the supplement's documentation and name who nets week by week Split the 119-column chunk of scottish_carer_supplement's documentation, drop the repeated "of the award", and say in the YAML header's fifth departure that Social Security Scotland nets the amounts week by week. No code or expected value changes. Co-Authored-By: Claude Opus 5.5 --- .../scottish_carer_supplement.yaml | 5 ++- .../scottish_carer_supplement.py | 38 +++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml index 4ba3ad42b4..27fbacac25 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml @@ -85,8 +85,9 @@ # - the disable_simulated_benefits reform; # - a supplied part-year amount before the reduction, which the model nets # against an overlapping benefit paid all year: 1,123.85 for 13 award weeks -# and 2,000 a year of State Pension net to 0, where the weekly rule leaves -# 623.85 of Carer Support Payment and 152.10 of supplement; +# and 2,000 a year of State Pension net to 0, where Social Security +# Scotland nets them week by week and pays 623.85 of Carer Support +# Payment and 152.10 of supplement; # - part weeks, where Social Security Scotland pays a whole weekly supplement # for each award week in which Carer Support Payment is payable. diff --git a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py index 026f3429f6..64beeee5fd 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py +++ b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py @@ -28,14 +28,15 @@ class scottish_carer_supplement(Variable): "move on any other day spreads the 13 weeks over 14 award weeks. The " "count differs from the law in these cases, among others. Where an " "overlapping benefit or earnings vary within the year, the formula " - "counts 52 weeks or none from the annual amounts, while Social Security Scotland pays the supplement only for " - "the award weeks in which the payment stays payable. Where a user " - "supplies a part-year payment and also sets inputs that make the " - "person eligible in Scotland, the formula counts 52 weeks, since the " - "model reads those inputs as a whole year. Where a user supplies a " - "payment that an overlapping benefit has reduced, the formula counts " - "fewer weeks than the award weeks in which that payment is payable, " - "unless the inputs also give the amount before the reduction. Under " + "counts 52 weeks or none from the annual amounts, while Social " + "Security Scotland pays the supplement only for the award weeks in " + "which the payment stays payable. Where a user supplies a part-year " + "payment and also sets inputs that make the person eligible in " + "Scotland, the formula counts 52 weeks, since the model reads those " + "inputs as a whole year. Where a user supplies a payment that an " + "overlapping benefit has reduced, the formula counts fewer weeks than " + "the award weeks in which that payment is payable, unless the inputs " + "also give the amount before the reduction. Under " "gov.contrib.policyengine.disable_simulated_benefits, a Scottish " "carer's reported Carer's Allowance becomes carer_support_payment " "while the amount before the reduction stays the model's, so the " @@ -47,17 +48,16 @@ class scottish_carer_supplement(Variable): "a part-year amount before the overlapping-benefit reduction and the " "person has an overlapping benefit paid all year, the model nets the " "annual amounts. The formula can then count no weeks, while Social " - "Security Scotland compares the weekly amounts in each award " - "week of the award. The formula also counts part weeks where a " - "payment falls between whole numbers of weekly rates. Such a payment " - "arises for an award that starts or ends during an award week or " - "spans an April uprating. Social Security Scotland instead pays a " - "whole weekly supplement for each award week in which Carer Support " - "Payment is payable. The supplement is a " - "component of Carer Support separate from the Carer Support Payment " - "component. Pension Credit, Housing Benefit and Scottish Council Tax " - "Reduction, at pension age and working age, do not count it as income;" - " it is taxable." + "Security Scotland compares the weekly amounts in each award week. " + "The formula also counts part weeks where a payment falls between " + "whole numbers of weekly rates. Such a payment arises for an award " + "that starts or ends during an award week or spans an April uprating. " + "Social Security Scotland instead pays a whole weekly supplement for " + "each award week in which Carer Support Payment is payable. The " + "supplement is a component of Carer Support separate from the Carer " + "Support Payment component. Pension Credit, Housing Benefit and " + "Scottish Council Tax Reduction, at pension age and working age, do " + "not count it as income; it is taxable." ) definition_period = YEAR unit = GBP From 178852e6c247b9e273102a17987f515eac563d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:14:51 +0000 Subject: [PATCH 152/168] Keep main's tax measure in the schemes the deduction leaves alone In the English and Welsh working-age schemes, and for families outside Great Britain, council_tax_reduction_tax_deduction deducted the whole income tax before the winter fuel payment charge and all National Insurance. Main's Council Tax Reduction line deducted legacy_means_test_deducted_income_tax there instead, so the merge would have put back the tax on interest, dividends and rent from property that #1995 took out. The formula now deducts main's measure in those schemes: the applicant's and partner's legacy_means_test_deducted_income_tax, with the sum floored at nil, and all their National Insurance. The Welsh working-age case now has pay of 20,000 and savings interest of 20,000, where the two measures differ: the deduction comes to 5,594.40, not the whole liability of 5,880.40. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.yaml | 21 ++++++++++----- ...council_tax_reduction_applicable_income.py | 6 +++-- .../council_tax_reduction_tax_deduction.py | 26 +++++++++++++++---- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index 6898ab6191..f237a1afbe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -702,7 +702,7 @@ # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). council_tax_reduction_applicable_income: 35_994 -- name: Wales's working-age scheme still deducts the whole income tax +- name: Wales's working-age scheme deducts the tax the legacy means tests deduct period: 2026 absolute_error_margin: 0.01 input: @@ -710,7 +710,7 @@ adult: age: 40 employment_income: 20_000 - private_pension_income: 10_000 + savings_interest_income: 20_000 benunits: benunit: members: [adult] @@ -722,10 +722,19 @@ region: WALES council_tax: 2_000 output: - # The model keeps the whole liability here, which the cases above do not - # cover: (30,000 - 12,570) x 20% = 3,486 and Class 1 594.40. - council_tax_reduction_tax_deduction: 4_080.40 - council_tax_reduction_applicable_income: 25_919.60 + # The formula leaves this scheme as the legacy means tests have it: the + # income tax less the tax the interest would bear without the pay + # (legacy_means_test_deducted_income_tax), and National Insurance. + # With the pay: non-savings 20,000 - 12,570 = 7,430 taxable, x 20% = + # 1,486; the interest above it gets no starting rate, a 1,000 personal + # savings allowance and 19,000 x 20% = 3,800, so 5,286. Without the pay: + # 20,000 - 12,570 = 7,430 of taxable interest, 5,000 at the starting rate + # and 1,000 allowance at nil, 1,430 x 20% = 286. 5,286 - 286 = 5,000. + # Class 1: (20,000 - 12,569.96) x 8% = 594.40. + legacy_means_test_deducted_income_tax: 5_000 + council_tax_reduction_tax_deduction: 5_594.40 + # The scheme counts the pay but not the interest: 20,000 - 5,594.40. + council_tax_reduction_applicable_income: 14_405.60 - name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,661 period: 2026 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 2b5b0a40a1..5cc22ea082 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -64,7 +64,8 @@ class council_tax_reduction_applicable_income(Variable): "The income tax and National Insurance deducted are those the scheme " "allows (council_tax_reduction_tax_deduction): on earnings only in " "Scotland's working-age scheme, on income taken into account in the " - "pensioner schemes, and the whole liability in the other schemes." + "pensioner schemes, and in the other schemes the income tax the " + "legacy means tests deduct (legacy_means_test_deducted_income_tax)." ) definition_period = YEAR unit = GBP @@ -356,7 +357,8 @@ def formula(benunit, period, parameters): ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) # The income tax and National Insurance each scheme allows: on earnings # only in Scotland's working-age scheme, on income taken into account - # in the pensioner schemes (council_tax_reduction_tax_deduction). + # in the pensioner schemes, and in the other schemes what the legacy + # means tests deduct (council_tax_reduction_tax_deduction). tax = benunit("council_tax_reduction_tax_deduction", period) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index bb4c51b8cf..72605742b4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -49,9 +49,11 @@ class council_tax_reduction_tax_deduction(Variable): "includes any tax they add on savings and dividends and through the " "marriage allowance. Benefits in kind stay in both terms of each " "difference, since no scheme counts a payment in kind as earnings. " - "The English and Welsh working-age schemes, and families outside " - "Great Britain, deduct the whole income tax before the winter fuel " - "payment charge and all National Insurance. " + DECISION + "In the English and Welsh working-age schemes, and for families " + "outside Great Britain, the formula deducts what the legacy means " + "tests deduct: the applicant's and partner's " + "legacy_means_test_deducted_income_tax, with their sum floored at " + "nil, and all their National Insurance. " + DECISION ) definition_period = YEAR unit = GBP @@ -92,10 +94,24 @@ def total(values): income_tax = person(PRE_CHARGE_TAX, period) tax = total(income_tax) - whole_liability = tax + total(person("national_insurance", period)) class_1 = total(person("ni_class_1_employee", period)) zeros = np.zeros_like(tax) + # The other schemes deduct what the legacy means tests deduct: the + # applicant's and partner's legacy_means_test_deducted_income_tax, + # whose sum the formula floors at nil because one member's value can + # turn negative where another's earnings move a Step 7 charge off + # them, and all their National Insurance. + other_schemes = ~( + with_universal_credit | without_universal_credit | pensioner_scheme + ) + if other_schemes.any(): + legacy_deduction = max_( + 0, total(person("legacy_means_test_deducted_income_tax", period)) + ) + total(person("national_insurance", period)) + else: + legacy_deduction = zeros + # Each branch of income tax is read only where a scheme uses it. if (without_universal_credit | pensioner_scheme).any(): tax_without_employment = total( @@ -155,5 +171,5 @@ def total(values): return select( [with_universal_credit, without_universal_credit, pensioner_scheme], [uc_deduction, no_uc_deduction, pensioner_deduction], - default=whole_liability, + default=legacy_deduction, ) From 023de787d8391e89265a35370c1c4016ef1ac256 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:18:16 +0000 Subject: [PATCH 153/168] Leave the tax on rent from property out of the pensioner deduction Main now treats rent from property as income from capital and no longer counts it, but council_tax_reduction_income_tax_on_other_counted_income kept property_income on its branch, so the pensioner schemes deducted the tax on rent they did not count. The branch now zeroes property_income too. Without property profits, property_finance_costs_relieved relieves no finance costs on the branch, so the finance-cost reduction goes with the rent. The formula keeps property_rental_income, which only measures receipts for the property allowance, and sublet_income, the rent for letting part of the home that the pensioner schemes count and that the model does not tax. A new case pins an English pensioner with a 20,000 pension and 10,000 of rent from a let flat: the deduction comes to 1,486 (not 3,486), Council Tax Reduction income to 18,514 and the reduction to 772.40. Invariant 2 now also varies property income, and the branch test checks that the branch takes rent, with its finance-cost reduction, out of income tax. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.yaml | 35 ++++++++++++++++++ ..._tax_reduction_tax_deduction_properties.py | 36 ++++++++++++++++++- ...tion_income_tax_on_other_counted_income.py | 17 +++++++-- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml index f237a1afbe..2e118cd2ea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -318,6 +318,41 @@ # 20,308 - 1,547.60. council_tax_reduction_applicable_income: 18_760.40 +- name: England's pensioner scheme deducts no tax on rent from a property the pensioner doesn't live in + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + property_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner lets out a flat they don't live in, for a profit of + # 10,000. The scheme counts the pension, not the rent, which the model + # treats as income from capital; the case gives the flat no value, so the + # model counts no capital and no tariff income. Income tax: 30,000 - + # 12,570 = 17,430 x 20% = 3,486. Tax on the pension with the rent above + # it: (20,000 - 12,570) x 20% = 1,486. + income_tax_before_winter_fuel_payment_charge: 3_486 + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + # A single pensioner who reached State Pension age after 1 April 2021 + # gets 238 a week, 12,376 a year: 2,000 - 20% x (18,514 - 12,376) = + # 772.40. + council_tax_reduction_applicable_amount: 12_376 + council_tax_reduction: 772.40 + - name: England's pensioner scheme deducts the savings tax that pay adds period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index fa30fa08de..4bccfd6a49 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -51,6 +51,9 @@ "miscellaneous_income", "company_car_benefit", ] +# The same with the profits of a property business, which the strategies below +# draw on their own. +PENSIONER_UNCOUNTED = UNCOUNTED + ["property_income"] # Taxed income other than earnings that Scotland's working-age scheme counts # or treats as capital. UNEARNED = [ @@ -217,7 +220,7 @@ def test_scottish_working_age_deduction_matches_pension_credit_earnings_tax( @PROPERTY_SETTINGS @given( st.lists(household(pensioner=True, earnings=False), min_size=1, max_size=4), - st.sampled_from(UNCOUNTED), + st.sampled_from(PENSIONER_UNCOUNTED), money(60_000), st.booleans(), ) @@ -616,6 +619,7 @@ def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_firs ("dividend_income", 10_000.0), ("miscellaneous_income", 10_000.0), ("company_car_benefit", 10_000.0), + ("property_income", 10_000.0), ] @@ -640,6 +644,36 @@ def test_branch_takes_each_uncounted_income_out_of_income_tax(): tax_with_income = np.asarray(simulate(with_income).calculate(INCOME_TAX, YEAR)) assert np.all(tax_with_income > expected + 1), tax_with_income + # A landlord's finance costs earn a 20% tax reduction on the rent. The + # branch drops the reduction with the rent, so it gives the same tax with + # or without the costs. + landlords = simulate( + [ + dict( + country="ENGLAND", + benunits=[ + [ + { + **base, + "property_income": 10_000.0, + "property_finance_costs": costs, + } + ] + ], + ) + for costs in (0.0, 5_000.0) + ] + ) + landlord_tax = np.asarray(landlords.calculate(INCOME_TAX, YEAR)) + assert np.isclose(landlord_tax[0] - landlord_tax[1], 1_000, atol=0.01) + assert np.allclose( + landlords.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ), + expected, + atol=0.01, + ) + carer = dict( age=70, private_pension_income=14_000.0, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index b6276d5ee9..ed9e69b947 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -8,7 +8,17 @@ # pensioner schemes' general route: the Scottish Carer Supplement (SSI # 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no # scheme counts a payment in kind as earnings), savings interest and other -# investment income, and dividends. +# investment income, dividends, and the profits of a property business +# (property_income), such as rent from a property the applicant doesn't live +# in. The model taxes those profits through property_income alone. Without +# them, the finance-cost reduction comes to nil on the branch, because +# property_finance_costs_relieved caps the costs it relieves at the property +# profits, so the reduction goes with the rent. property_rental_income only +# measures receipts for the property allowance, which can't take taxable +# property income below the nil the branch leaves, so the formula keeps it. +# Rent for letting part of the home (sublet_income) counts on the pensioner +# route (council_tax_reduction_home_letting_income), so the formula keeps it +# too; the model charges no income tax on it. PENSIONER_UNCOUNTED_INCOME = [ "scottish_carer_supplement", "miscellaneous_income", @@ -16,6 +26,7 @@ "savings_interest_income", "other_investment_income", "dividend_income", + "property_income", ] # The Step 7 charges of ITA 2007 s. 23 that income_tax_charges holds, @@ -39,7 +50,9 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "and statutory sick, maternity and paternity pay), self-employment " "income, or the taxed income the pensioner schemes do not count: the " "Scottish Carer Supplement, miscellaneous income, benefits in kind, " - "savings interest, other investment income and dividends. It also " + "savings interest, other investment income, dividends and the profits " + "of a property business (property_income), with the finance-cost " + "reduction that goes with them. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " "the pension annual allowance charge: through the first, HMRC " From d237386bfc78f08c98a65f4cb735ab41225b757c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:29:16 +0000 Subject: [PATCH 154/168] Describe the pensioner schemes' income from capital and the model's approximation In each pensioner scheme, a council disregards any actual income from capital except income from the capital that Part 2 of the capital Schedule lists (life interests and liferents, rights to rent without a reversionary interest, annuities and trust property for the applicant or partner), and disregards that income too while such capital totals 10,000 or less. A council counts other capital through tariff income and leaves Part 2 capital out of it. The model has no input that marks Part 2 capital, and other_investment_income takes in interest from trusts and settlements, so on the pensioner route council_tax_reduction_applicable_income counts no interest, other investment income, dividends or rent from property, and the deduction takes no tax on any of it. The documentation of council_tax_reduction_income_tax_on_other_counted_income, the comment above PENSIONER_UNCOUNTED_INCOME and the paragraph on income from capital in council_tax_reduction_applicable_income now say so, call the blanket exclusion the model's general approximation of the income rules, and keep the ordinary bank deposit and shares held outright as examples of capital outside Part 2. The variables also cite the Part 2 provisions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- ...council_tax_reduction_applicable_income.py | 49 ++++++++----- ...tion_income_tax_on_other_counted_income.py | 69 +++++++++++++++++-- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 5cc22ea082..08e6348584 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -42,17 +42,31 @@ class council_tax_reduction_applicable_income(Variable): "Compensation Scheme payments are not counted: Scotland disregards or " "omits them, and English and Welsh councils may relieve them beyond " "the prescribed £10 a week, so the model takes the full disregard. " - "No scheme " - "counts income derived from capital, such as rent from property, " - "interest and dividends, as income, so it is not listed. The pensioner schemes " - "disregard any actual income from capital; the Welsh working-age " - "scheme and the English default scheme treat it as capital; the " - "Scottish working-age scheme counts only the unearned income it " - "lists, which includes the assumed yield from capital but not actual " - "rent, interest or dividends. Instead, the national schemes (England " - "for pensioners, Wales and Scotland) count the tariff income they " - "treat capital as yielding (council_tax_reduction_tariff_income); the " - "English local schemes for people under pension age add their own. " + "The formula counts no income derived from capital, such as rent " + "from property other than the home, interest and dividends. In each " + "pensioner scheme, a council disregards any actual income from " + "capital, except income from the capital that Part 2 of the capital " + "Schedule lists: life interests and liferents, rights to rent where " + "the applicant has no reversionary interest, annuities, and property " + "held under a trust that pays or may pay the applicant or partner. It " + "disregards that income too while such capital totals £10,000 or " + "less. The model has no input that marks capital as Part 2 capital, " + "and other_investment_income takes in interest from trusts and " + "settlements, so on the pensioner route the formula counts none of " + "the interest, other investment income, dividends or rent from " + "property, and council_tax_reduction_tax_deduction deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. The Welsh working-age " + "scheme and the English default scheme treat income from capital as " + "capital; the Scottish working-age scheme counts only the unearned " + "income it lists, which includes the assumed yield from capital but " + "not actual rent, interest or dividends. Instead, the national " + "schemes (England for pensioners, Wales and Scotland) count the " + "tariff income they treat capital as yielding " + "(council_tax_reduction_tariff_income), and in the pensioner schemes " + "a council leaves Part 2 capital out of it. The English local schemes " + "for people under pension age add their own tariff income. " "Rent the applicant and partner receive for letting part of the home " "counts, less the sub-tenant disregard, on the schemes' own income " "rules (council_tax_reduction_home_letting_income). It does not count " @@ -61,11 +75,11 @@ class council_tax_reduction_applicable_income(Variable): "Universal Credit assessment (which does not count it), or in the " "Scottish scheme for people under pension age from April 2022, which " "does not list it. " - "The income tax and National Insurance deducted are those the scheme " - "allows (council_tax_reduction_tax_deduction): on earnings only in " - "Scotland's working-age scheme, on income taken into account in the " - "pensioner schemes, and in the other schemes the income tax the " - "legacy means tests deduct (legacy_means_test_deducted_income_tax)." + "The formula deducts the income tax and National Insurance each " + "scheme allows (council_tax_reduction_tax_deduction): on earnings " + "only in Scotland's working-age scheme, on income taken into account " + "in the pensioner schemes, and in the other schemes the income tax " + "the legacy means tests deduct (legacy_means_test_deducted_income_tax)." ) definition_period = YEAR unit = GBP @@ -103,6 +117,9 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9", "https://www.legislation.gov.uk/ssi/2012/319/schedule/3", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", "https://www.legislation.gov.uk/ssi/2012/303/regulation/26", "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py index ed9e69b947..47755f34a7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -7,10 +7,33 @@ # Taxed income that council_tax_reduction_applicable_income leaves out on the # pensioner schemes' general route: the Scottish Carer Supplement (SSI # 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no -# scheme counts a payment in kind as earnings), savings interest and other -# investment income, dividends, and the profits of a property business -# (property_income), such as rent from a property the applicant doesn't live -# in. The model taxes those profits through property_income alone. Without +# scheme counts a payment in kind as earnings), and income from capital: +# savings interest, other investment income, dividends and the profits of a +# property business (property_income), such as rent from a property the +# applicant doesn't live in. +# +# In each pensioner scheme, a council disregards any actual income from +# capital, except income from the capital that Part 2 of the capital +# Schedule lists: life interests and liferents, rights to rent where the +# applicant has no reversionary interest, annuities, and property held under +# a trust that pays or may pay the applicant or partner. It disregards that +# income too while such capital totals £10,000 or less. A council counts +# other capital through tariff income and leaves Part 2 capital out of it +# (SSI 2012/319 reg 27(1)(i) and (2), reg 31(10), Sch 3 paras 21 and 22 and +# Sch 4 Part 2; SI 2012/2885 Sch 1 paras 16(1)(i), 17(11) and 37, Sch 5 +# paras 22 and 23 and Sch 6 Part 2; WSI 2013/3029 Sch 1 paras 10(1)(i), +# 11(11) and 31, Sch 4 paras 22 and 23 and Sch 5 Part 2). So a council +# disregards the interest on savings in an ordinary bank deposit and the +# dividends on shares held outright, and counts the deposit and the shares +# through tariff income. The model has no input that marks capital as Part 2 +# capital, and other_investment_income takes in interest from trusts and +# settlements, so council_tax_reduction_applicable_income counts none of +# this income on the pensioner route, and the formula deducts no tax on any +# of it. The model makes that general approximation of the income rules: +# where Part 2 capital above £10,000 yields such income, a council counts it +# and the model doesn't. +# +# The model taxes property profits through property_income alone. Without # them, the finance-cost reduction comes to nil on the branch, because # property_finance_costs_relieved caps the costs it relieves at the property # profits, so the reduction goes with the rent. property_rental_income only @@ -52,7 +75,24 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): "Scottish Carer Supplement, miscellaneous income, benefits in kind, " "savings interest, other investment income, dividends and the profits " "of a property business (property_income), with the finance-cost " - "reduction that goes with them. It also " + "reduction that goes with them. In each pensioner scheme, a council " + "disregards any actual income from capital, except income from the " + "capital that Part 2 of the capital Schedule lists: life interests " + "and liferents, rights to rent where the applicant has no " + "reversionary interest, annuities, and property held under a trust " + "that pays or may pay the applicant or partner. It disregards that " + "income too while such capital totals £10,000 or less. A council " + "counts other capital, such as " + "savings in an ordinary bank deposit or shares held outright, through " + "tariff income, and leaves Part 2 capital out of it. The model has no " + "input that marks capital as Part 2 capital, and " + "other_investment_income takes in interest from trusts and " + "settlements, so council_tax_reduction_applicable_income counts none " + "of the interest, other investment income, dividends or rent from " + "property on the pensioner route, and the formula deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. It also " "leaves out the Step 7 charges of ITA 2007 s. 23 that " "income_tax_charges holds, the High Income Child Benefit Charge and " "the pension annual allowance charge: through the first, HMRC " @@ -67,6 +107,25 @@ class council_tax_reduction_income_tax_on_other_counted_income(Variable): ) definition_period = YEAR unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/21", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/22", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/37", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/22", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/23", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/31", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/22", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/23", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", + ] def formula(person, period, parameters): zeroed = PENSIONER_UNCOUNTED_INCOME + STEP_7_CHARGES From 4684c2f93a70d5925f82224afa0d49cc4fdee053 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:45:07 +0000 Subject: [PATCH 155/168] Test that the other schemes deduct the legacy means test measure Invariant 8 checks that, in the English and Welsh working-age schemes, the deduction equals the applicant's and partner's legacy_means_test_deducted_income_tax, with the sum floored at nil, plus their National Insurance. With the whole liability in its place, Hypothesis finds an English adult aged 25 whose deduction comes to 338.73 where the legacy measure gives nil. Invariant 7 now also runs a Welsh working-age case, so it reads that branch in both orders. The changelog fragment now names rent from property among the income whose tax the pensioner schemes do not deduct. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- changelog.d/2090.fixed.md | 2 +- ..._tax_reduction_tax_deduction_properties.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md index d83a5b2418..fd65a3c255 100644 --- a/changelog.d/2090.fixed.md +++ b/changelog.d/2090.fixed.md @@ -1 +1 @@ -The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends, rent from property or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py index 4bccfd6a49..cda3059434 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -16,6 +16,8 @@ (option (a)): the tax on earnings is the extra tax the earnings cause, with the earnings as the top slice of income. council_tax_reduction_tax_deduction applies these rules, and council_tax_reduction_applicable_income deducts it. +In the English and Welsh working-age schemes, the formula deducts what the +legacy means tests deduct (legacy_means_test_deducted_income_tax). Each test builds households by hand in 2026, states its invariant in its docstring, and records the scheme of each household it draws as a Hypothesis @@ -584,6 +586,10 @@ def test_non_dependant_income_never_changes_the_deduction( country="WALES", benunits=[[dict(age=70, self_employment_income=60_000.0)]], ), + dict( + country="WALES", + benunits=[[dict(age=40, employment_income=20_000.0)]], + ), ] @@ -695,3 +701,31 @@ def test_branch_takes_each_uncounted_income_out_of_income_tax(): with_supplement.calculate(INCOME_TAX, YEAR)[0] > without_supplement.calculate(INCOME_TAX, YEAR)[0] + 1 ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("ENGLAND", "WALES"), pensioner=False), + min_size=1, + max_size=4, + ) +) +def test_other_schemes_deduct_the_legacy_means_test_measure(households): + """Invariant 8. In the English and Welsh working-age schemes, the + deduction equals what the legacy means tests deduct: the applicant's and + partner's legacy_means_test_deducted_income_tax, with the sum floored at + nil, plus their National Insurance. So these schemes take no tax on the + interest, dividends or rent from property that they do not count, beyond + the extra tax that earnings cause on it.""" + simulation = simulate(households) + schemes(simulation, households) + expected = np.maximum( + 0, applicant_total(simulation, "legacy_means_test_deducted_income_tax") + ) + applicant_total(simulation, "national_insurance") + deduction = benunit_values(simulation, DEDUCTION) + assert np.allclose(deduction, expected, atol=0.01), ( + households, + deduction, + expected, + ) From 87006023870e942f039593f6a0d978d4b065c64b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:45:51 +0000 Subject: [PATCH 156/168] Name main's Universal Credit award test in the deduction's documentation The Scottish working-age route with Universal Credit now follows council_tax_reduction_universal_credit_award through applicant_has_universal_credit, so it covers an award the benefit cap or deductions reduce to nil. The documentation says so, and names interest, dividends and rent from property as examples of U. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- .../council_tax_reduction_tax_deduction.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py index 72605742b4..8b8bcb3686 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -23,13 +23,16 @@ class council_tax_reduction_tax_deduction(Variable): "council_tax_reduction_income_tax_on_other_counted_income). E is " "employed earnings: employment income and statutory sick, maternity " "and paternity pay. S is self-employment income. U is the taxed " - "income the pensioner schemes do not count. " + "income the pensioner schemes do not count, such as interest, " + "dividends and rent from property. " "Scotland's working-age scheme (SSI 2021/249) deducts tax from " - "earnings only. Where nobody in the family has Universal Credit, the " - "formula deducts T less T(E), never negative, primary Class 1 " + "earnings only. Where the applicant has no award of Universal Credit " + "(council_tax_reduction_universal_credit_award, which includes an " + "award the benefit cap or deductions reduce to nil), the formula " + "deducts T less T(E), never negative, primary Class 1 " "contributions, and the notional tax and contributions on profit " "(council_tax_reduction_scotland_self_employment_deductions; reg " - "51(9)). With Universal Credit it deducts T less T(E, S), never " + "51(9)). With an award it deducts T less T(E, S), never " "negative, and the Class 1, 2 and 4 contributions charged (regs " "49(6)(b) and 51(2) step 3). The pensioner schemes of England (SI " "2012/2885), Wales (WSI 2013/3029) and Scotland (SSI 2012/319) " From 3589f7ef7bb6fc82fbe24067d69f09afa97ec9f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:29:33 +0000 Subject: [PATCH 157/168] Read the Council Tax Reduction line's own deduction in the legacy property tests test_legacy_means_test_income_from_capital_properties.py, which #1995 brought, expected the Council Tax Reduction line to respond one-for-one to legacy_means_test_deducted_income_tax for every family. The line now deducts council_tax_reduction_tax_deduction, which reads that measure only in the English and Welsh working-age schemes and outside Great Britain, so at 87006023 invariant 6 failed on a Scottish working-age couple in 2025 whose Council Tax Reduction income did not move when the test raised the measure by a pound. Invariant 6 now expects no response in Scotland's working-age scheme and Great Britain's pensioner schemes, and invariant 1 reads the line's own deduction when it splits the change in Council Tax Reduction income. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDGGUyf1Vv1xacPmhJ --- ...ans_test_income_from_capital_properties.py | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py index ee4849fb03..703726033d 100644 --- a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py @@ -27,9 +27,11 @@ personal allowance can leave rent or dividends taxable. Where someone has earnings, each means-test income changes by exactly minus the change in the tax that test deducts (legacy_means_test_deducted_income_tax, summed - over the members it counts) and in its earnings disregard, which is - capped at net earnings, unless floored or on a route that does not use - the test's own income rules. The awards are compared wherever none of + over the members it counts; for council tax reduction, + council_tax_reduction_tax_deduction, which applies each scheme's own + rule) and in its earnings disregard, which never exceeds net earnings, + unless a floor binds or the family takes a route that does not use the + test's own income rules. The test compares the awards wherever none of those taxes or disregards moves. 2. Bounds: the counted home-letting income is between nil and the rent, and equals the rent less £20 a week, floored at nil. @@ -57,10 +59,16 @@ savings rise while the family stays on the same route (pensioner, UC award, passport, guarantee credit, savings credit only). 6. Differential: for every generated family without self-employment income, - Income Support, Housing Benefit and the council tax reduction line deduct, - for each person they count, the same income tax as Pension Credit's tax - line: the pre-charge income tax less - legacy_means_test_uncounted_income_tax_without_earnings. + Income Support and Housing Benefit deduct, for each person they count, the + same income tax as Pension Credit's tax line: the pre-charge income tax + less legacy_means_test_uncounted_income_tax_without_earnings. The council + tax reduction line deducts council_tax_reduction_tax_deduction, which + deducts that tax only in the schemes it leaves to the legacy means tests + (the English and Welsh working-age schemes and outside Great Britain). + In Scotland's working-age scheme and Great Britain's pensioner schemes it + applies those schemes' own rules + (test_council_tax_reduction_tax_deduction_properties.py), so there the + line does not read Pension Credit's tax. 7. Applicant: where the household head does not apply alone for council tax reduction, council_tax_reduction_home_letting_income equals legacy_benefits_home_letting_income; where the head applies alone, her @@ -264,6 +272,13 @@ def calculate_with_taxes(units, year, variables, **kwargs): for income, members in TEST_MEMBERS.items(): mask = np.asarray(sim.calculate(members, year), dtype=float) out[f"tax:{income}"] = sim.map_result(tax * mask, "person", "benunit") + # The council tax reduction line deducts each scheme's own measure, a + # benefit unit total for the applicant and partner, which equals the + # shared deduction only in the schemes it leaves to the legacy means + # tests. + out["tax:council_tax_reduction_applicable_income"] = np.asarray( + sim.calculate("council_tax_reduction_tax_deduction", year), dtype=float + ) earnings = sum( np.abs(np.asarray(sim.calculate(v, year), dtype=float)) for v in EARNINGS ) @@ -485,12 +500,17 @@ def test_deducted_tax_without_earnings_is_the_step_4_split(units, year): @PROPERTY_SETTINGS @given(populations, st.sampled_from(YEARS)) def test_the_four_tests_deduct_the_same_income_tax(units, year): - """Income Support, Housing Benefit and the council tax reduction line - deduct, for each person they count, the income tax Pension Credit's tax - line deducts. Each test's income is recomputed with the person's income - tax raised by one pound and compared with the original, so the check - reads each formula rather than the shared variable. The families have no - self-employment income, where Pension Credit's notional tax applies.""" + """Income Support and Housing Benefit deduct, for each person they count, + the income tax Pension Credit's tax line deducts, and so does the council + tax reduction line in the schemes council_tax_reduction_tax_deduction + leaves to the legacy means tests (the English and Welsh working-age + schemes and outside Great Britain). In Scotland's working-age scheme and + Great Britain's pensioner schemes, the line applies those schemes' own + rules and does not respond to that tax. The test recomputes each test's + income with the person's income tax raised by one pound and compares it + with the original, so the check reads each formula rather than the + shared variable. The families have no self-employment income, where + Pension Credit's notional tax applies.""" sim = Simulation(situation=situation(units, year)) people = sim.populations["person"].count pension_credit_tax = np.asarray( @@ -547,6 +567,15 @@ def test_the_four_tests_deduct_the_same_income_tax(units, year): ) == 0 ) + # Each family is one benefit unit in its own household. + region = np.array([unit["region"] for unit in units]) + pensioner = np.asarray( + sim.calculate("council_tax_reduction_pensioner", year) + ).astype(bool) + own_scheme_rules = (region != "NORTHERN_IRELAND") & ( + pensioner | (region == "SCOTLAND") + ) + counted = np.where(own_scheme_rules, 0.0, counted) checked = unfloored & own_rules # The earnings disregard is capped at net earnings, so it can fall # with them. From f75f41abc6637ad2d43912e3c0bdc191c6aff625 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:56:48 +0000 Subject: [PATCH 158/168] Compare the veteran test with the law at the merge with main After the merge with main, the model exempts the veteran from the non-dependant deduction through armed forces independence payment, which UC Regs 2013 reg 2 counts as attendance allowance for Sch 4 para 16(2)(d). The veteran test now asserts the model's figures with the branch's Pension Credit result supplied as false, labels the forced-deduction run a counterfactual, and records that Sch 4 para 15 exempts the blind single renter from every deduction. A new test pins a blind partner whose deduction the branch's result decides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HcTFPvy9Mo5Upj4Rk --- .../test_uc_carer_element_award_properties.py | 159 +++++++++++++++--- .../has_uc_award_with_carer_element.py | 13 +- 2 files changed, 143 insertions(+), 29 deletions(-) diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py index a6ad8ee35a..5e62f7c1db 100644 --- a/policyengine_uk/tests/test_uc_carer_element_award_properties.py +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -51,7 +51,10 @@ non-dependant family's Pension Credit in a branch with every housing costs element set to nil, where the carer test reads the award without housing costs. test_the_non_dependant_branch_reads_the_award_without_housing_costs - pins the one kind of case where that changes a deduction. + pins that reading in three request orders, for a household where the + branch's result changes no deduction, and + test_the_branch_decides_a_blind_partners_deduction pins a household where + the branch's result decides one. A reform that makes is_uc_eligible read Pension Credit would cycle through the attribution, which reads eligibility; these tests do not cover reforms. @@ -686,13 +689,19 @@ def test_small_housing_costs_with_a_work_allowance_leave_no_award(): assert severe[1] == pytest.approx(86.05 * WEEKS) -def veteran_situation(exempt=None): +def veteran_situation(pension_credit=None, veteran_exempt=None, blind_partner=False): """A blind carer (40) earning £20,000 rents from the council at £6,000 a year as household head and cares 35 hours a week for a veteran (70) in another family of the household, who receives armed forces independence - payment and £13,000 of State Pension. exempt, if given, supplies the - veteran family's Pension Credit exemption from the non-dependant - deduction.""" + payment and £13,000 of State Pension. The carer claims alone, so UC Regs + 2013 Sch 4 para 15(1)(a) and (2)(a) exempt the carer, a blind single + renter, from every non-dependant deduction; the model holds no such + exemption. pension_credit, if given, supplies the veteran family's + uc_non_dep_receives_pension_credit, which the model otherwise reads from + a branch. veteran_exempt, if given, supplies the veteran's + uc_non_dep_deduction_exempt. blind_partner adds the veteran's blind + partner (68), who receives no disability benefit, and raises the + veteran's State Pension to £21,000.""" people = { "carer": { "age": {YEAR: 40}, @@ -712,14 +721,23 @@ def veteran_situation(exempt=None): "carer_unit": {"members": ["carer"]}, "veteran_unit": {"members": ["veteran"], "would_claim_uc": {YEAR: False}}, } - if exempt is not None: - benunits["veteran_unit"]["uc_non_dep_receives_pension_credit"] = {YEAR: exempt} + if pension_credit is not None: + benunits["veteran_unit"]["uc_non_dep_receives_pension_credit"] = { + YEAR: pension_credit + } + if veteran_exempt is not None: + people["veteran"]["uc_non_dep_deduction_exempt"] = {YEAR: veteran_exempt} household = { "members": ["carer", "veteran"], "country": {YEAR: "ENGLAND"}, "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, "rent": {YEAR: 6_000}, } + if blind_partner: + people["veteran"]["state_pension"] = {YEAR: 21_000} + people["partner"] = {"age": {YEAR: 68}, "is_blind": {YEAR: True}} + benunits["veteran_unit"]["members"].append("partner") + household["members"].append("partner") return {"people": people, "benunits": benunits, "households": {"h": household}} @@ -733,20 +751,26 @@ def test_the_non_dependant_branch_reads_the_award_without_housing_costs(first): # with no non-dependant deduction. The award includes the carer element, # so the veteran, whom DWP and the model treat as severely disabled # through armed forces independence payment, gets no severe disability - # addition, and with £250 a week of State Pension against a standard - # minimum guarantee of £238.00, no Pension Credit. + # addition. The veteran pays income tax of 86.00 and keeps 12,914.00 of + # State Pension, which exceeds the standard minimum guarantee of 238.00 + # x 52 = 12,376.00, so the veteran gets no Pension Credit. # - # The deduction's exemption reads the veteran's Pension Credit in a - # branch with every housing costs element set to nil. There the carer's - # award, 7,610.88 - 9,855.78, comes to nil, the veteran gets the addition - # (DWP ignores a blind resident, SPC Regs 2002 Sch I para 2(2)(b)) and - # Pension Credit, (238.00 + 86.05 - 250.00) x 52 = 3,850.60, and the model - # exempts the veteran from the deduction. UC Regs 2013 Sch 4 para 16(2) - # names no armed forces independence payment, so the law, in which the - # veteran gets no Pension Credit, deducts 96.55 x 12 = 1,158.60 and leaves - # an award of 2,596.50, which still includes the carer element. This test - # pins the model's figures, whichever variable the simulation requests - # first. + # uc_non_dep_receives_pension_credit reads the veteran's Pension Credit + # in a branch with every housing costs element set to nil. There the + # carer's award, 7,610.88 - 9,855.78, comes to nil, the veteran gets the + # addition (DWP ignores a blind resident, SPC Regs 2002 Sch I para + # 2(2)(b)) and Pension Credit of (238.00 + 86.05) x 52 - 12,914.00 = + # 3,936.60, so the branch returns true. + # + # In law DWP deducts nothing for this household, on two grounds. UC Regs + # 2013 Sch 4 para 15(1)(a) and (2)(a) exempt the carer, a blind single + # renter, from every deduction. Para 16(2)(d) exempts the veteran, who + # receives "attendance allowance" under head (f) of reg 2's definition, + # armed forces independence payment. The model holds the second ground + # (uc_receives_attendance_allowance) but not the first, so the model's + # award and the law's agree at 3,755.10, and the branch's result changes + # neither. This test pins the model's figures, whichever variable the + # simulation requests first. sim = Simulation(situation=veteran_situation()) sim.calculate(first, YEAR) assert sim.calculate("has_uc_award_with_carer_element", YEAR).tolist() == [ @@ -761,10 +785,97 @@ def test_the_non_dependant_branch_reads_the_award_without_housing_costs(first): assert sim.calculate("uc_non_dep_deductions", YEAR)[0] == 0 award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) assert award[0] == pytest.approx(3_755.10, abs=0.01) - # The law's figures, with the exemption supplied as the law reads it. - law = Simulation(situation=veteran_situation(exempt=False)) - assert law.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx(96.55 * 12) + # The branch's result set to its value in law, false, since the veteran + # gets no Pension Credit: the model still exempts the veteran through + # armed forces independence payment and deducts nothing. + law = Simulation(situation=veteran_situation(pension_credit=False)) + assert law.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + ] + assert law.calculate("uc_non_dep_deductions", YEAR)[0] == 0 law_award = law.calculate("universal_credit_pre_benefit_cap", YEAR) - assert law_award[0] == pytest.approx(2_596.50, abs=0.01) + assert law_award[0] == pytest.approx(3_755.10, abs=0.01) assert law.calculate("has_uc_award_with_carer_element", YEAR)[0] assert law.calculate("pension_credit", YEAR)[1] == 0 + # A counterfactual that neither the law nor the model gives this + # household: with the veteran's exemption supplied as false, the model + # deducts 96.55 x 12 = 1,158.60 and leaves an award of 3,755.10 - + # 1,158.60 = 2,596.50. The award still includes the carer element, so + # even a deduction would leave the carer test and the veteran's nil + # Pension Credit unchanged. + counterfactual = Simulation(situation=veteran_situation(veteran_exempt=False)) + assert counterfactual.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx( + 96.55 * 12 + ) + counterfactual_award = counterfactual.calculate( + "universal_credit_pre_benefit_cap", YEAR + ) + assert counterfactual_award[0] == pytest.approx(2_596.50, abs=0.01) + assert counterfactual.calculate("has_uc_award_with_carer_element", YEAR)[0] + assert counterfactual.calculate("pension_credit", YEAR)[1] == 0 + + +def test_the_branch_decides_a_blind_partners_deduction(): + # The veteran of the test above, now with £21,000 of State Pension and a + # blind partner (68) who receives no disability benefit. Para 16(2) + # exempts the veteran through armed forces independence payment but + # lists no blindness, so only the family's Pension Credit can exempt the + # partner, and the branch's result decides the partner's deduction. + # + # The veteran pays income tax of (21,000 - 12,570) x 0.20 - 1,260 x 0.20 + # = 1,434.00, with the partner's marriage allowance, and the couple keep + # 19,566.00, which exceeds the couple's standard minimum guarantee of + # 363.25 x 52 = 18,889.00, so the couple get no Pension Credit. In the + # branch, the carer's award comes to nil, and SPC Regs 2002 Sch I para + # 1(1)(c) gives the couple the addition (the veteran receives armed + # forces independence payment, the partner is blind, and DWP ignores the + # blind carer as a resident under para 2(2)(b)): Pension Credit comes to + # (363.25 + 86.05) x 52 - 19,566.00 = 3,797.60, the branch returns true, + # and the model exempts the partner and deducts nothing. + # + # In law DWP deducts nothing either, because para 15(1)(a) and (2)(a) + # exempt the carer, a blind single renter, from every deduction; the + # carer's award then comes to 3,755.10, includes the carer element and + # leaves the couple without the addition or Pension Credit. The model + # holds no para 15 exemption, so with the branch's result supplied at + # its value in law, false, the model deducts 96.55 x 12 = 1,158.60 for + # the partner and leaves an award of 2,596.50. + sim = Simulation(situation=veteran_situation(blind_partner=True)) + assert sim.calculate("has_uc_award_with_carer_element", YEAR).tolist() == [ + True, + False, + False, + ] + assert sim.calculate("pension_credit", YEAR).tolist() == [0, 0] + assert sim.calculate("uc_non_dep_receives_pension_credit", YEAR).tolist() == [ + False, + True, + ] + assert sim.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + True, + ] + assert sim.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + assert award[0] == pytest.approx(3_755.10, abs=0.01) + branch = Simulation(situation=veteran_situation(blind_partner=True)) + branch.set_input("uc_housing_costs_element", YEAR, [0, 0]) + assert branch.calculate("pension_credit", YEAR)[1] == pytest.approx( + 3_797.60, abs=0.01 + ) + flag_false = Simulation( + situation=veteran_situation(pension_credit=False, blind_partner=True) + ) + assert flag_false.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + False, + ] + assert flag_false.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx( + 96.55 * 12 + ) + flag_false_award = flag_false.calculate("universal_credit_pre_benefit_cap", YEAR) + assert flag_false_award[0] == pytest.approx(2_596.50, abs=0.01) + assert flag_false.calculate("has_uc_award_with_carer_element", YEAR)[0] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py index a7faff105e..4bb7d495a5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -47,11 +47,14 @@ class has_uc_award_with_carer_element(Variable): "exemption. A member of the non-dependant family gains or loses it " "through this variable only where the family gets Pension Credit " "with the severe disability addition and none without it, and the " - "member has no other exemption: someone who receives armed forces " - "independence payment and no other qualifying benefit, or a blind " - "partner under SPC Sch I para 1(1)(c) who receives none. There the " - "model can exempt a non-dependant whom the law does not, or the " - "reverse. Where an input or a scenario stores universal_credit for " + "member has no other exemption: a blind partner under SPC Sch I para " + "1(1)(c) who receives no qualifying benefit. There the model reads " + "that member's exemption from the award without housing costs. For " + "the family to get the addition, DWP must ignore the renter as a " + "resident (SPC Sch I para 2(2)); a single renter whom DWP ignores as " + "blind or for a qualifying benefit gets UC Regs 2013 Sch 4 para 15's " + "exemption from every deduction, which the model does not hold. " + "Where an input or a scenario stores universal_credit for " "the period, or a reform neutralizes it, reading it cannot cycle, so " "this reads the award itself (receives_uc_carer_element). The model " "applies the same rule in Northern Ireland, whose legislation uses " From f8cba9d544a2c493e6388b374b4739d86e43b54f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 14:01:25 -0500 Subject: [PATCH 159/168] Say when the Pension Credit branch's result can differ from the family's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uc_non_dep_receives_pension_credit calculates a non-dependant family's Pension Credit in a branch with every family's housing costs element set to nil. Now that the carer test reads the carer's award, a family whose severe disability addition reads a renter's carer element sees that award without housing costs in the branch, so the branch's Pension Credit can differ from the family's own (the veteran test: £3,936.60 in the branch, nil outside it). The documentation and the formula's comment said the branch preserved the family's entitlement; they now say when it doesn't. Co-Authored-By: Claude Opus 5.5 --- .../uc_non_dep_receives_pension_credit.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py index d04efdbf02..1a1c9cbc52 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py @@ -10,13 +10,17 @@ class uc_non_dep_receives_pension_credit(Variable): documentation = ( "Whether a family not liable for rent receives Pension Credit, for the " "Universal Credit non-dependant housing contribution exemption. Such " - "a family's own Universal Credit housing element is zero. Calculating " - "its Pension Credit in a branch with that element fixed at zero " - "therefore preserves its entitlement, including legacy-closure " - "routing, take-up, explicit inputs and frozen Pension Credit reforms. " - "It avoids evaluating a renter's own Pension Credit while calculating " - "their Universal Credit housing element. Rent-liable families' " - "branch results are disregarded because they are not non-dependants." + "a family's own Universal Credit housing element is zero. The formula " + "calculates its Pension Credit in a branch with every family's " + "housing costs element set to nil, which keeps the family's " + "legacy-closure routing, take-up, explicit inputs and frozen Pension " + "Credit reforms. Where the family's severe disability addition reads " + "a renter's Universal Credit award that includes the carer element, " + "the branch reads that award without housing costs, so the branch's " + "Pension Credit can differ from the family's own. The branch avoids " + "evaluating a renter's own Pension Credit while calculating their " + "Universal Credit housing element. The formula ignores the branch " + "result for a rent-liable family, which is not a non-dependant." ) definition_period = YEAR reference = ( @@ -26,9 +30,11 @@ class uc_non_dep_receives_pension_credit(Variable): def formula(benunit, period, parameters): # A non-dependant is not liable for the rent (Sch 4 para 9(2)(d)), - # so their own housing element is already zero. This branch keeps - # their actual Pension Credit calculation while breaking the cycle - # through the renter's UC entitlement and pension-age route. + # so their own housing element is already zero. The branch keeps + # their Pension Credit calculation and breaks the cycle through the + # renter's UC entitlement and pension-age route. Where their severe + # disability addition reads a renter's carer element, the branch + # reads the renter's award without housing costs. simulation = benunit.simulation # Income tax is independent of the UC housing element. Calculate # the earnings counterfactuals on the parent so this branch and From ffe20fb9165ddcda48ac57531f87847ecc317b9e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 19:53:14 -0500 Subject: [PATCH 160/168] Date the 0.06% school VAT bound and move the 2pp VAT fixture to 27.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage description's 0.06% bound comes from the run at a coverage of 0.67 (Aggregate impact run 40116746457, 24 October 2026), so it now says so, as docs/engineering/vat-coverage.md does. The Aggregate impact report for this branch at af0b602ca, merged into main at 1d4d020aa (run 41633358040, policyengine-uk 2.172.1), costs the 2pp VAT rise at £27.07bn in 2029-30 on Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1), the change in gov_balance that test_reform_impacts.py measures. The fixture now expects 27.1 and keeps that costing in its receipts. Co-Authored-By: Claude Opus 5.5 --- .../simulation/vat/survey_consumption_coverage.yaml | 11 ++++++----- .../tests/microsimulation/reforms_config.yaml | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 6eb57aaf5d..4ac312c888 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -26,11 +26,12 @@ metadata: per-dataset results. These receipts already include private school VAT. The vat formula therefore removes school-fee VAT's share of receipts from the grossed-up amount (not from energy-bill VAT) before gov_tax adds - private_school_vat. At current law on Enhanced FRS 2024-25 - (policyengine-uk-data 1.58.1), the two heads sum to the receipt-calibrated - VAT to within the share's accuracy (at most 0.06% below it), so no extra - school receipts are added to this denominator. See the receipt-partition - derivation in that document. + private_school_vat. At a coverage of 0.67 on Enhanced FRS 2024-25 + (policyengine-uk-data 1.58.1), the two heads summed to the + receipt-calibrated VAT at current law to within the share's accuracy, at + most 0.06% below it (Aggregate impact run 40116746457, 24 October 2026), + so this denominator needs no extra school receipts. See the + receipt-partition derivation in that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index 0d43f6b6fa..022450ec2b 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -159,7 +159,7 @@ reforms: data: 1.58.1 enhanced_frs_2024_25 model: 2.135.7 (merged 7717438bf, main 7ad002f54, 2026-10-18) - name: Raise VAT standard rate by 2pp - expected_impact: 27.6 + expected_impact: 27.1 tolerance: 1.5 parameters: gov.hmrc.vat.standard_rate: 0.22 @@ -194,6 +194,9 @@ reforms: - impact: 27.581 data: 1.58.1 enhanced_frs_2024_25 model: 2.135.7 (merged 7717438bf, main 7ad002f54, 2026-10-18) + - impact: 27.071 + data: 1.58.1 enhanced_frs_2024_25 + model: 2.172.1 (merged af0b602ca, main 1d4d020aa, 2026-11-03) - name: Raise additional rate by 3pp expected_impact: 3.7 parameters: From 44ea4c161eef9bb2130281efce280a8f41c5eda1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:41:08 +0000 Subject: [PATCH 161/168] Say which reading case 18's values agree with, and turn pension_credit's guard comment round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 18's comment now says which reading of SPC Regs 2002 Sch VI para 4(2)(a) the expected values agree with, as the docstring of pension_credit_without_income_related_esa says which reading the model's result agrees with. The comment on pension_credit's guard cites WRA 2007 Sch 1 para 6(1)(c) and (d) for the award depending on the absence of Pension Credit, not the reverse. Case 25 writes £366.60 with its pound sign, as case 26 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwxAaGSkVVvGDuMZzZR --- .../esa_income/esa_income_pension_age.yaml | 21 ++++++++++--------- .../gov/dwp/pension_credit/pension_credit.py | 6 ++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index dadc199e8f..e776243db7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -651,15 +651,16 @@ # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings # derived from that work which do not exceed the limits" (ESA Regs 2008 - # Sch 7 para 5): all of them, not £20. The expected values take "£20 was - # disregarded" to mean the £20 disregard of Sch 7 paras 6 and 7(1), as - # under para 5 DWP disregards "any earnings ... which do not exceed the - # limits", not a fixed £20. On that reading he fails para 4(2)(a), his - # Pension Credit would fall back to B(0), nil, and his ESA would come back, - # so ESA with no Pension Credit holds and Pension Credit with no ESA does - # not. On another reading, para 4(2)(a) holds wherever DWP disregarded at - # least £20 of earnings in the award, and he would then keep £525.20 of - # Pension Credit with no ESA while he stays in employment (para 4(2)(b)). + # Sch 7 para 5): all of them, not £20. The expected values agree with the + # reading under which "£20 was disregarded" means the £20 disregard of + # Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any earnings ... + # which do not exceed the limits", not a fixed £20. On that reading he + # fails para 4(2)(a), his Pension Credit would fall back to B(0), nil, and + # his ESA would come back, so ESA with no Pension Credit holds and Pension + # Credit with no ESA does not. On another reading, para 4(2)(a) holds + # wherever DWP disregarded at least £20 of earnings in the award, and he + # would then keep £525.20 of Pension Credit with no ESA while he stays in + # employment (para 4(2)(b)). esa_income_eligible: true esa_income: 5_491.20 claimant_or_partner_esa_income: 5_491.20 @@ -933,7 +934,7 @@ # disregarded in respect of earnings taken into account in that award" # and "the person whose earnings qualified for the disegard continues in # employment after the termination of that award". Once the couple - # claimed, it could then keep 366.60 of Pension Credit with no ESA. The + # claimed, it could then keep £366.60 of Pension Credit with no ESA. The # model holds no record of how an award started, and # pension_credit_earnings_disregard does not apply para 4(2), so the model # pays this couple the award and no Pension Credit, as in case 18. diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index acff1f9705..3611dd82a1 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -35,8 +35,10 @@ class pension_credit(Variable): def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - # WRA 2007 Sch 1 para 6(1)(c) and (d): with the couple's - # income-related ESA in payment, the couple has no Pension Credit. + # The formula pays nothing while the couple has income-related ESA, + # so that no couple gets both: Parliament allows the award only where + # neither member of the couple is entitled to State Pension Credit + # (WRA 2007 Sch 1 para 6(1)(c) and (d)). couple_esa = benunit("claimant_or_partner_esa_income", period) amount = where(couple_esa > 0, 0, entitlement * would_claim) From dd8cd3e8e227642fb877f98ec787e0ce17a24a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:43:41 +0000 Subject: [PATCH 162/168] Name the model as the actor in the Child Benefit changelog fragment The fragment said Child Benefit "is not paid" for a young person who qualifies only through the terminal date and works 24 hours or more a week. It now says the model pays none (reg. 7(3)). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwwvSTEwBJa5rD7bHvZ --- changelog.d/cb-qyp-school-leaver-routes.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md index 7dcf048000..b8cf3f0d0c 100644 --- a/changelog.d/cb-qyp-school-leaver-routes.fixed.md +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -1 +1 @@ -Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7), which the Housing Benefit, legacy benefit and Council Tax Reduction young persons follow: a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, two new inputs place the 20-week extension period and the terminal date, and Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance, or their own Income Support while DWP pays it, is not one, on any route (reg. 8). +Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7), which the Housing Benefit, legacy benefit and Council Tax Reduction young persons follow: a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, two new inputs place the 20-week extension period and the terminal date, and the model pays no Child Benefit for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance, or their own Income Support while DWP pays it, is not one, on any route (reg. 8). From 0835944512f8632f878168bbe1d2eceba951500d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:39:29 +0000 Subject: [PATCH 163/168] Guard against random draws in the engine Port policyengine-us's scanner for engine-time randomness and hash draws as a code-health test. EXEMPT lists the package's SHA-256 digests, which fingerprint, key caches or check content; RATCHET lists today's draws: the splitmix64 helper, the formulas and uprating that call it, and the seeded numpy draws in participation and two scenarios. Cases that need newer syntax are filtered at collection, so no Python reports a skip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6H1FBndAKeuXdSGuft --- .../test_no_engine_time_randomness.py | 4203 +++++++++++++++++ 1 file changed, 4203 insertions(+) create mode 100644 policyengine_uk/tests/code_health/test_no_engine_time_randomness.py diff --git a/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py new file mode 100644 index 0000000000..5b38e94666 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py @@ -0,0 +1,4203 @@ +"""The model draws nothing while a simulation is built or run. + +A formula is a pure, deterministic function of its inputs +(policyengine-core#500), and seeding does not exempt a draw. Stochastic +inputs such as take-up flags are drawn when the dataset is built and stored +as input variables; the model compares them with its rules. policyengine-core#519 checks formulas +for numpy and stdlib randomness when variables register, but a hand-written +integer hash of entity ids passes that check, so this scan looks for the +hash as well. + +Every module of the package except tests is parsed (formulas, reforms, +scenarios, simulation code, data loading, uprating and utilities). A name resolves to +everything it may refer to where it is used: the targets of every binding +that may be the one read, in each scope Python looks in, so the scan errs +toward reporting. Bindings come from imports, named imports and re-exports +followed across the package, star imports, assignments of a name or +attribute chain (``xp = np``, also in tuples), default arguments, ``global`` +and ``nonlocal`` (the nearest enclosing function that binds the name, +wherever in its body; either sends the name there in the code nested in +its scope too, unless that code binds the name itself), functions, lambdas +and comprehensions. An attribute +of a package module, or of a module EXTERNAL_EXPORTS maps, is what that +module binds under the name, so ``api.np.random`` is numpy's random module +where ``api`` is ``policyengine_core.model_api``. No code sees the names a +class body it is nested in binds, or its ``global`` and ``nonlocal`` +statements. A module or class body looks further out +when no binding is sure to have run before the use with no ``del`` or +``except ... as`` cleanup that may come between (all are in an ``if``, loop, +``try``, ``with``, ``match`` or ``assert``, in a comprehension, past the +first operand of ``and``, ``or`` or a chained comparison, or in a branch of +a conditional expression, or the use comes first). A class body that binds +the name anywhere (an annotation alone or a ``del`` counts) then looks in +the module, never in a function around it, and so does what the body +evaluates: its annotations, its methods' defaults and annotations, and +type-parameter bounds and ``type`` values in it. A function may run +as soon as its ``def`` has run, so the scopes around it count only the +bindings before the ``def``, and every later deletion. A function's ``del`` +of a name it declares ``global`` runs whenever the function is called, so +after the ``def`` it may come between any binding and any use. A generator +expression runs its body as it is iterated, so the same holds for it from +the expression (its first iterable runs at once). A class body, +a decorated function and a lambda may run before the statement that creates +them has bound its names (a decorator may call the function, and an +assignment's value may call a lambda in it), so code inside them doesn't +take those bindings as sure. Nor does a use take as sure a binding its own +statement makes (a multi-line assignment, or a default or base on a later +line of its ``def`` or ``class``): Python makes those after evaluating it, +and treating a walrus earlier in the statement the same way errs toward +reporting. An annotation is read as code that may run any time after its +statement starts, seeing a class body around it, which covers both ways +Python may evaluate it: with its statement, as up to 3.13, and whenever the +annotations are read, as 3.14 defers them (PEP 649). A type parameter's +bound or default and a ``type`` alias's value, which Python evaluates when +they are read (PEP 695, 3.12+; defaults PEP 696, 3.13+), are read the same +way. A ``type`` statement +binds its alias's name, but a type parameter's own name is not taken as +bound, which errs toward reporting. An annotation alone binds no value in a +module or class body. + +So ``from numpy import random as r`` followed by ``r.random(3)`` is caught, +as are ``xp = np`` followed by ``xp.random.uniform()``, ``from +policyengine_uk.model_api import np as lib`` followed by +``lib.random.uniform()``, and a ``try: import numpy as lib`` with a +fallback, while a local variable called ``random`` is not. A star import of +a package module binds its ``__all__`` (built from string literals with +``=``, ``+``, ``+=``, ``append``, ``extend``, ``insert`` or ``remove``; any +other ``__all__`` exports every name) or, without one, every name it binds +without a leading underscore. It may not bind a name an earlier or removed +``__all__`` held (an import cycle can see that one), one the module binds +only conditionally or deletes, or any name where ``__all__`` is built +otherwise, so the scan also looks further out. Where the module, while it +runs, may import the importing one (directly or through other modules, +counting every import in them, even in a function; the submodules a star +import of a package loads: the module each name its ``__all__`` lists +names below the package (``sub.leaf`` too, with ``sub``) or, where +``__all__`` is built otherwise, every module below it, and the same where +a literal ``__import__`` ``fromlist`` holds ``*`` (read as Python iterates +it: a list's, tuple's or set's elements, a dict's keys or a string's +characters, a literal ``*`` or ``**`` entry's own in its place); any call +whose literal arguments name a module as ``importlib.import_module`` or +``__import__`` would read them, whatever name the call is made under, a +relative one against its literal ``package`` (or the module's +``__package__`` or ``__name__``) or the package its ``globals`` give, as +Python reads them (``globals()`` or a dict literal, a literal ``**`` +entry's own entries in its place and the last of a repeated key counting: +``__package__``, else ``__spec__``, else ``__name__`` and ``__path__``); +the packages an import starts; the code of modules it may +reach without importing them, as another module may have loaded them: an +attribute chain, ``getattr`` with a literal name, ``sys.modules`` with a +literal key, or the module a literal dynamic import returns, from a name +any import, or assignment (or walrus) of such a chain or a conditional +choice of one (``if``-``else``, ``and``, ``or``), in the module binds, a +submodule a star import of a package may bind, or in a package's +``__init__`` its own submodule; and the imports in code that may run +later, when it is called or read (functions, lambdas, generator +expressions, annotations, type parameters and ``type`` values), in the +packages around a module, which have run before it, and in the modules +they import or reach as they run, since its code may call that code), the +star import may see it part-run, before it binds a name or sets ``__all__``: it may then bind any +name the module binds without a leading underscore, and binds none surely. +EXTERNAL_EXPORTS lists the +names numpy and ``policyengine_core.model_api`` bind where they matter here; +``np``, ``numpy`` and ``pd`` are bound to numpy and pandas wherever nothing +else binds them. Reported, as (file, enclosing function or class, kind): +- ``stochastic import``: importing ``random``, ``secrets`` or + ``numpy.random`` (or anything from them), including through + ``importlib.import_module`` or ``__import__`` with literal arguments, + positional or keyword (``package``, ``fromlist`` and ``level`` too); +- ``random source``: a name or attribute that resolves into ``numpy.random``, + ``random`` or ``secrets``, or to ``uuid.uuid1``, ``uuid.uuid4`` or + ``os.urandom``, including through ``getattr`` with a literal name; +- ``builtin hash``: any use of the unshadowed ``hash``, which Python salts + per process; +- ``hash library``: importing a hashing or checksum library (``hashlib``, + ``zlib``, ``uuid``, ...); +- ``hash use``: a name or attribute that resolves into one of those + libraries or to pandas' hashing functions, so that a new use of an + import EXEMPT allows still counts; +- ``hash constant``: an integer constant of a common hash mixer + (splitmix64, murmur3, FNV, xxHash, PCG), which catches a copied hash; +- ``xorshift``: the step ``x ^ (x >> k)`` such mixers are built from, + written with operators, ``^=`` or numpy's ``bitwise_xor`` and shifts; +- ``sampling call``: ``.sample(...)`` with sampling arguments, or a method + named ``shuffle``, ``permutation``, ``choice`` and the like; +- ``id modulo``: ``... % n``, ``%=``, ``np.mod``, ``divmod`` and the like, + where the dividend reads an id (a name or string ending ``_id``); +- ``draw helper call``: a use, under any name it is imported or re-exported + as, of a top-level function of the package (one defined in an ``if`` or + ``try`` too) whose body has a finding EXEMPT does not cover, or that + itself uses such a function, so that calling an existing draw again also + counts. + +This is a ratchet over recognised patterns, not a proof that no +hand-written draw exists. It does not follow ``eval`` or ``exec``, imports +of a computed name (a relative dynamic import whose ``package`` or +``globals`` is computed is read against the module's own package), +package search paths changed as the code runs (a ``__path__`` assignment) +or import hooks, code outside the package (what it imports, or calls back +into), values reached through objects or containers (``self.r = +np.random`` is caught where ``np.random`` is read; a later +``self.r.uniform()`` is not; nor are functions one module sets on another +or registers for a later call), names bound otherwise than by an import or +an assignment to a plain name (unpacking, ``for``, ``with``, parameters), +the values calls return (``r = +importlib.import_module("random")`` is caught at the import; a later +``r.random()`` is not), names ``match`` patterns capture, or methods as draw +helpers. A lookup that nests deeper than MAX_RESOLUTION_DEPTH fails the scan +rather than passing unresolved. + +EXEMPT lists reviewed occurrences that are not draws; RATCHET lists today's +engine-time draws that still have to move into the data. Both count exact +occurrences per (file, enclosing scope, kind): an extra occurrence fails, and +so does an entry that no longer matches as many as it says, so entries are +lowered and removed as sites go. RATCHET_CEILING only ever falls, and +RATCHET must reach zero. +""" + +import ast +from collections import Counter +from functools import cache +from itertools import permutations +from pathlib import Path +import sys +from typing import NamedTuple + +import pytest + + +PACKAGE = Path(__file__).resolve().parents[2] +TESTS = PACKAGE / "tests" + +RATCHET = { + # The splitmix64 finalizer that maps entity ids to uniform draws on + # [0, 1): its three mixing constants and three xorshift steps. Every + # other entry calls it, or calls what calls it. + "utils/stochastic.py": { + ("splitmix64_uniform", "hash constant"): 3, + ("splitmix64_uniform", "xorshift"): 3, + }, + # Formula: hash of benunit_id (salt 0) that decides whether a UC + # deduction applies and how large it is, in simulations built from data. + "variables/gov/dwp/universal_credit/deductions/uc_deduction_random_draw.py": { + ("uc_deduction_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of benunit_id (salt 1) that picks the UC deduction type + # combination, in simulations built from data. + "variables/gov/dwp/universal_credit/deductions/uc_deduction_type_random_draw.py": { + ("uc_deduction_type_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of household_id (salt 2) that decides whether a household + # on a qualifying benefit receives the Warm Home Discount, in simulations + # built from data. + "variables/gov/ofgem/warm_home_discount_random_draw.py": { + ("warm_home_discount_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of person_id (salt 2) that orders people within each year + # of age and sex before stratified_uniform spreads them over the year, in + # simulations built from data. + "variables/household/demographic/months_since_last_birthday.py": { + ("months_since_last_birthday.formula", "draw helper call"): 1, + }, + # Uprating: uprate_student_loan_plans hashes person_id (salt 3) and + # assigns a plan to new borrowers whose draw falls below the take-up + # rate. apply_single_year_uprating calls it, apply_uprating calls that, + # and extend_single_year_dataset calls apply_uprating. + "data/economic_assumptions.py": { + ("uprate_student_loan_plans", "draw helper call"): 1, + ("apply_single_year_uprating", "draw helper call"): 1, + ("apply_uprating", "draw helper call"): 1, + ("extend_single_year_dataset", "draw helper call"): 1, + }, + # Simulation loading: extends a single-year dataset through + # extend_single_year_dataset, and so the student loan draw. + "simulation.py": { + ("Simulation._extend_dataset_with_cgt_provenance", "draw helper call"): 1, + }, + # Labour supply: a seeded numpy RandomState that draws participation + # responses. + "dynamics/participation.py": { + ("apply_participation_responses", "random source"): 1, + }, + # UC rebalancing scenario: a seeded numpy Generator draws one value per + # benefit unit and compares it with the new claimant share; the Scenario + # at module level passes on the modifier. + "scenarios/uc_reform.py": { + ("add_universal_credit_reform", "random source"): 1, + ("", "draw helper call"): 1, + }, + # PIP phase-in scenario: seeds numpy's global stream and draws one value + # per person, which removes PIP where it falls below the phase-in share; + # the Scenario at module level passes on the modifier. + "scenarios/pip_reform.py": { + ("modify_simulation", "random source"): 2, + ("", "draw helper call"): 1, + }, +} +# The number of RATCHET occurrences. Lower it when one goes; never raise it. +RATCHET_CEILING = 21 + +EXEMPT = { + # SHA-256 fingerprint of the files that shape the data build, for + # provenance. + "build_metadata.py": { + ("", "hash library"): 1, + ("get_data_build_fingerprint", "hash use"): 1, + }, + # SHA-256 of the capital gains projection's metadata and provenance, for + # identifying the projection. + "data/capital_gains_projection.py": { + ("", "hash library"): 1, + ("_fingerprint", "hash use"): 1, + }, + # SHA-256 of a dataset's bucket, path and generation, naming its local + # cache directory. + "data/dataset_sources.py": { + ("", "hash library"): 1, + ("_cached_dataset_path", "hash use"): 1, + }, + # SHA-256 of the uprating parameters, which the simulation compares for + # equality to decide whether a cached projection still applies. + "data/economic_assumptions.py": { + ("", "hash library"): 1, + ("uprating_input_fingerprint", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 of a file it records. + "utils/aggregate_impact/__main__.py": { + ("", "hash library"): 1, + ("_sha256", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 content check of a pinned dataset file. + "utils/aggregate_impact/dataset.py": { + ("", "hash library"): 1, + ("sha256_file", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 identities of the tool's files, the + # measured spec and reforms, the model's files and the installed + # distributions, stamped on a result. + "utils/aggregate_impact/measure.py": { + ("", "hash library"): 1, + ("_digest_tool_files", "hash use"): 1, + ("definitions_digest", "hash use"): 1, + ("model_fingerprint", "hash use"): 1, + ("runtime_identity", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 of a partition file, recorded with it. + "utils/aggregate_impact/partition.py": { + ("", "hash library"): 1, + ("load_partition", "hash use"): 1, + }, + # Offline LHA rates builder: SHA-256 content check of a downloaded source + # file. + "utils/build_lha_published_rates.py": { + ("", "hash library"): 1, + ("fetch", "hash use"): 1, + }, +} +OFFLINE_MODULES = set() + +# Names modules outside the package bind, where they matter here, for star +# and named imports alike. numpy's __all__ includes its random module. +EXTERNAL_EXPORTS = { + "numpy": {"random": "numpy.random"}, + "policyengine_core.model_api": { + "np": "numpy", + "numpy": "numpy", + "pd": "pandas", + # Core's guard that raises on formula-time randomness. + "random": "policyengine_core.commons.formulas.random", + }, +} +# What a name bound nowhere in the module refers to. +UNBOUND_NAMES = { + "np": "numpy", + "numpy": "numpy", + "pd": "pandas", + "hash": "builtins.hash", + "getattr": "builtins.getattr", + "__import__": "builtins.__import__", + "divmod": "builtins.divmod", +} +DYNAMIC_IMPORTS = {"importlib.import_module", "builtins.__import__"} +XOR_FUNCTIONS = {"numpy.bitwise_xor", "operator.xor"} +SHIFT_FUNCTIONS = { + "numpy.right_shift", + "numpy.left_shift", + "operator.rshift", + "operator.lshift", +} +MOD_FUNCTIONS = { + "numpy.mod", + "numpy.remainder", + "numpy.fmod", + "operator.mod", + "builtins.divmod", +} +STOCHASTIC_MODULES = ("numpy.random", "random", "secrets") +STOCHASTIC_CALLABLES = {"uuid.uuid1", "uuid.uuid4", "os.urandom"} +HASH_MODULES = { + "hashlib", + "zlib", + "binascii", + "uuid", + "mmh3", + "xxhash", + "blake3", + "crcmod", +} +HASH_APIS = ( + "pandas.util.hash_pandas_object", + "pandas.util.hash_array", + "pandas.core.util.hashing", +) +SAMPLING_METHODS = { + "shuffle", + "permutation", + "permuted", + "choice", + "choices", + "randint", + "randrange", +} +SAMPLING_KEYWORDS = {"n", "frac", "random_state", "weights", "replace"} +HASH_CONSTANTS = { + # splitmix64: golden gamma and the two finalizer multipliers + 0x9E3779B97F4A7C15, + 0xBF58476D1CE4E5B9, + 0x94D049BB133111EB, + # murmur3 fmix64 and fmix32 + 0xFF51AFD7ED558CCD, + 0xC4CEB9FE1A85EC53, + 0x85EBCA6B, + 0xC2B2AE35, + # FNV-1a 64-bit and 32-bit offset bases and primes + 0xCBF29CE484222325, + 0x100000001B3, + 0x811C9DC5, + 0x01000193, + # xxHash64 primes + 0x9E3779B185EBCA87, + 0xC2B2AE3D27D4EB4F, + 0x165667B19E3779F9, + 0x85EBCA77C2B2AE63, + 0x27D4EB2F165667C5, + # PCG and Knuth MMIX 64-bit LCG multiplier + 0x5851F42D4C957F2D, +} +FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef) +COMPREHENSION_NODES = (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp) +TYPE_ALIAS = getattr(ast, "TypeAlias", ()) # Python 3.12+ +# Statements whose bodies may not run, so a binding inside may not happen. +COMPOUND_NODES = ( + ast.If, + ast.For, + ast.AsyncFor, + ast.While, + ast.Try, + ast.TryStar, + ast.With, + ast.AsyncWith, + ast.Match, +) +# Lookups nested deeper than this are cut, and the package scan fails +# (test_package_lookups_stay_within_the_depth_limit). +MAX_RESOLUTION_DEPTH = 64 +# What ``from module import *`` binds when the module has no __all__, and +# when its __all__ is built in a way this scan does not evaluate. +DEFAULT_PUBLIC = "names without a leading underscore" +EVERY_NAME = "every name" + + +def _matches(target: str, prefixes) -> bool: + return any( + target == prefix or target.startswith(prefix + ".") for prefix in prefixes + ) + + +def _is_stochastic(target: str | None) -> bool: + if not target: + return False + return target in STOCHASTIC_CALLABLES or _matches(target, STOCHASTIC_MODULES) + + +def _classify(target: str) -> str | None: + """The kind of finding a use of ``target`` is, if any.""" + if _is_stochastic(target): + return "random source" + if target == "builtins.hash": + return "builtin hash" + if target.split(".")[0] in HASH_MODULES or _matches(target, HASH_APIS): + return "hash use" + return None + + +def _absolute(module: str, level: int, package: str | None) -> str: + """The absolute name of a ``from`` import's module.""" + if not level: + return module + if package is None: + return "." * level + module + parts = package.split(".") + parent = ".".join(parts[: len(parts) - level + 1]) + return f"{parent}.{module}" if module else parent + + +def _dotted(node): + """('np', ['random', 'seed']) for np.random.seed, else None.""" + parts = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + return node.id, list(reversed(parts)) + return None + + +def _literal(node) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _targets(target) -> set: + """The dotted names a binding's target may be.""" + if target is None: + return set() + if isinstance(target, str): + return {target} + return set(target.targets()) + + +class _Alias: + """A name bound, at ``line``, to another name or attribute chain.""" + + def __init__(self, scope, value, line): + self.scope = scope + self.root, self.attributes = _dotted(value) + self.line = line + + def targets(self): + # The value is evaluated where and when the binding is made. + roots = self.scope.modules.guarded( + self, + frozenset(), + lambda: frozenset(self.scope.resolve(self.root, self.line)), + ) + return self.scope.modules.expand_all( + ".".join([root] + self.attributes) for root in roots + ) + + +class _Scope: + """One namespace: a module, class, function, lambda or comprehension, or + the annotations of one statement.""" + + def __init__(self, kind, qualname, parent, modules, line=None): + self.kind = kind + self.qualname = qualname + self.parent = parent + self.modules = modules + # Where the scope starts: a function's def, for instance. + self.line = line + # name -> [(line, target, conditional)]. The target is what the name + # refers to (a module, function or other import), an _Alias to + # resolve, or None for a value. A conditional binding + # (in an if, loop, try, with or match, or a deletion) may not happen. + self.bindings = {} + # name -> [line] of each del or except ... as cleanup that may unbind + # it. + self.deletions = {} + # name -> [line] of each def whose function may unbind it when + # called, which may be at any time after that line. + self.deletions_after = {} + # For a scope that may run before the statement creating it has bound + # its names (a class body, a decorated function, a lambda), that + # statement's (first line, last line). + self.unfinished = None + # (line, module, conditional, importing module) for each star + # import. + self.stars = [] + # Names a global or nonlocal statement gives to another scope. + self.declared = {} + # For a function, lambda or class body, the names Python makes + # local to it. + self.local_names = frozenset() + + def bind(self, name, line, target=None, conditional=False): + owner = self.declared.get(name) + if owner is not None: + # Bound in that scope whenever this one runs, which may be never. + return owner.bind(name, line, target, conditional=True) + self.bindings.setdefault(name, []).append((line, target, conditional)) + + def delete(self, name, line): + """Record that ``name`` may be unbound at ``line``.""" + self.bind(name, line, None, conditional=True) + owner = self.declared.get(name, self) + # A deletion in a function, of a name another scope binds, happens + # when the function is called: in the owner's time, any time after + # the def of the outermost function between them. + called_after = None + scope = self + while scope is not owner: + if scope.kind in ("function", "lambda"): + called_after = scope.line + scope = scope.parent + if called_after is None: + owner.deletions.setdefault(name, []).append(line) + else: + owner.deletions_after.setdefault(name, []).append(called_after) + + def own(self, name, line, deferred, unfinished=()): + """(targets, bound, falls through) for ``name`` in this scope alone. + + The use is at ``line`` or, with ``deferred``, at any time after it; + a None line is once the scope has run. A binding within one of the + ``unfinished`` (first line, last line) spans may not have happened + yet. + """ + entries = [(entry[0], entry[2]) for entry in self.bindings.get(name, [])] + targets = set() + for _, target, _ in self.bindings.get(name, []): + targets |= _targets(target) + for star_line, base, conditional, importer in self.stars: + provided = self.modules.star_binds(base, name, importer) + if provided is not None: + targets |= provided + sure = self.modules.star_binds_surely(base, name, importer) + entries.append((star_line, conditional or not sure)) + if not entries: + return targets, False, True + if self.kind not in ("module", "class"): + # A name bound anywhere in a function is local to all of it. + return targets, True, False + # A module or class body looks further out unless a binding is sure + # to have run before the use, with no deletion that may come between. + deletions = self.deletions.get(name, []) + deletions_after = self.deletions_after.get(name, []) + + def holds(bound_line): + if deferred or line is None: + return ( + (line is None or bound_line <= line) + and all(deleted < bound_line for deleted in deletions) + and not deletions_after + ) + return ( + bound_line < line + and not any(bound_line <= deleted <= line for deleted in deletions) + and not any(after <= line for after in deletions_after) + ) + + sure = [ + entry_line + for entry_line, conditional in entries + if not conditional + and not any(first <= entry_line <= last for first, last in unfinished) + ] + return targets, True, not any(map(holds, sure)) + + def resolve(self, name, line=None, deferred=False, statement=None) -> set: + """Every dotted target ``name`` may refer to at ``line``. + + The answer errs toward reporting: the targets of every binding that + may be the one read, in each scope Python looks in until a binding is + sure to hold. ``deferred`` says the code may run at any time after + ``line``. ``statement`` is the (first line, last line) of the + statement the use is in, whose own bindings may not have happened. + """ + targets = set() + # The scope a global or nonlocal statement sends the name to, until + # the lookup reaches it. + owner = None + first = True + unfinished = [statement] if statement else [] + scope = self + while scope is not None: + if scope.kind == "annotation": + # Up to 3.13 an annotation is evaluated with its statement. + # 3.14 defers it to whenever the annotations are read (PEP + # 649), any time after the statement starts, in a scope that + # also sees a class body around it. Reading it as deferred + # from the statement covers both, since no binding the + # statement makes is sure (resolve). + line, deferred = scope.line, True + scope = scope.parent + continue + if scope is owner: + owner = None + # A global or nonlocal name skips the scopes up to the one it + # names, and no code nested in a class body sees the names it + # binds or declares. + if owner is None and (first or scope.kind != "class"): + # A global or nonlocal statement here sends the name there, + # here and in the code nested here. + owner = scope.declared.get(name) + if owner is None: + found, bound, falls = scope.own(name, line, deferred, unfinished) + targets |= found + if bound and not falls: + return targets + if scope.kind == "class" and name in scope.local_names: + # A class body that binds the name reads it from its + # namespace, then the module's (LOAD_NAME): the + # functions around it are skipped. + owner = scope + while owner.parent is not None: + owner = owner.parent + if scope.unfinished is not None: + # It may run before its statement's bindings have happened. + unfinished.append(scope.unfinished) + if scope.kind in ("function", "lambda", "generator"): + # A function may run as soon as its def has run, and a + # generator expression's body as soon as it is made, so the + # scopes around them are read as of then, and any later + # deletion counts. + line, deferred = scope.line, True + first = False + scope = scope.parent + if name in UNBOUND_NAMES: + targets.add(UNBOUND_NAMES[name]) + return targets + + +def _comprehension_walrus_targets(node) -> set: + """Names a walrus binds anywhere in a comprehension, which Python binds + in the function around it. A lambda inside is a scope of its own, apart + from its defaults.""" + targets = set() + stack = [node] + while stack: + child = stack.pop() + if isinstance(child, ast.NamedExpr): + targets.add(child.target.id) + if isinstance(child, ast.Lambda): + stack.extend(child.args.defaults) + stack.extend(d for d in child.args.kw_defaults if d) + continue + stack.extend(ast.iter_child_nodes(child)) + return targets + + +def _function_locals(node) -> frozenset: + """The names Python makes local to a function or lambda, wherever its + body binds them: its parameters and every name bound outside nested + scopes, less those a global or nonlocal statement sends elsewhere.""" + arguments = node.args + parameters = arguments.posonlyargs + arguments.args + arguments.kwonlyargs + parameters += [arguments.vararg, arguments.kwarg] + names = {argument.arg for argument in parameters if argument is not None} + body = node.body if isinstance(node.body, list) else [node.body] + return frozenset(names) | _block_locals(body) + + +def _block_locals(body) -> frozenset: + """The names Python makes local to a block of statements, wherever it + binds them: every name bound outside nested scopes, an annotation alone + and a ``del`` included, less those a global or nonlocal statement sends + elsewhere.""" + names, declared = set(), set() + stack = list(body) + while stack: + child = stack.pop() + if isinstance(child, (ast.Global, ast.Nonlocal)): + declared.update(child.names) + continue + if isinstance(child, FUNCTION_NODES + (ast.ClassDef, ast.Lambda)): + # Only its name, decorators, defaults, annotations and bases are + # this scope's. Up to 3.13 annotations are evaluated here, so a + # walrus in one binds here; 3.14 defers them and refuses a walrus + # in one, so the earlier rule covers both. + if not isinstance(child, ast.Lambda): + names.add(child.name) + stack.extend(getattr(child, "decorator_list", [])) + if isinstance(child, ast.ClassDef): + stack.extend(child.bases + [k.value for k in child.keywords]) + else: + stack.extend(child.args.defaults) + stack.extend(d for d in child.args.kw_defaults if d) + if isinstance(child, FUNCTION_NODES): + stack.extend([child.returns] if child.returns else []) + stack.extend( + argument.annotation + for argument in ast.walk(child.args) + if isinstance(argument, ast.arg) and argument.annotation + ) + continue + if isinstance(child, COMPREHENSION_NODES): + # Its loop variables are its own; a walrus binds here. Since 3.12 + # CPython inlines a list, set or dict comprehension (PEP 709; not a + # generator expression), and a nested nonlocal of a loop + # variable's name then names a cell here that only such nonlocals + # set, not the binding further out that 3.11 and a generator + # expression name. Following 3.11 keeps every value either can + # read. + names.update(_comprehension_walrus_targets(child)) + stack.append(child.generators[0].iter) + continue + if isinstance(child, ast.Name) and not isinstance(child.ctx, ast.Load): + names.add(child.id) + elif isinstance(child, (ast.Import, ast.ImportFrom)): + names.update( + (alias.asname or alias.name).split(".")[0] + for alias in child.names + if alias.name != "*" + ) + elif isinstance(child, ast.ExceptHandler) and child.name: + names.add(child.name) + elif isinstance(child, (ast.MatchAs, ast.MatchStar)) and child.name: + names.add(child.name) + elif isinstance(child, ast.MatchMapping) and child.rest: + names.add(child.rest) + stack.extend(ast.iter_child_nodes(child)) + return frozenset(names - declared) + + +def _may_not_run(node, child) -> bool: + """Whether ``child``, a part of ``node``, may not run when ``node`` does: + an operand of ``and`` or ``or`` after the first, a chained comparison's + after the second, a conditional expression's branches, and all of an + ``assert``, which ``python -O`` drops.""" + if isinstance(node, ast.BoolOp): + return child is not node.values[0] + if isinstance(node, ast.Compare): + return any(child is part for part in node.comparators[1:]) + if isinstance(node, ast.IfExp): + return child is not node.test + return isinstance(node, ast.Assert) + + +def _type_parameter_values(node) -> list: + """The bounds and defaults of a definition's type parameters (PEP 695, + Python 3.12+; defaults 3.13+), which Python evaluates when they are + read.""" + values = [] + for parameter in getattr(node, "type_params", []): + for field in ("bound", "default_value"): + value = getattr(parameter, field, None) + if value is not None: + values.append(value) + return values + + +def _alias(scope, value, line): + """What binding ``value`` at ``line`` binds: an _Alias of a name or chain.""" + if value is not None and _dotted(value) is not None: + return _Alias(scope, value, line) + return None + + +def _is_all(node) -> bool: + return isinstance(node, ast.Name) and node.id == "__all__" + + +def _strings(node, current): + """The set of strings ``node`` builds, with ``current`` as __all__.""" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + values = [_literal(element) for element in node.elts] + return None if None in values else set(values) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left, right = _strings(node.left, current), _strings(node.right, current) + return None if left is None or right is None else left | right + if _is_all(node): + return set(current) + return None + + +class _Exports(NamedTuple): + # Names __all__ holds once the module has run. + final: frozenset + # Names it held before, or that were removed, which a module importing it + # during an import cycle may see. + earlier: frozenset + + +def _public_names(tree): + """What ``from module import *`` binds, from the module's ``__all__``. + + An _Exports, DEFAULT_PUBLIC without an ``__all__``, or EVERY_NAME for an + ``__all__`` built other than from string literals with ``=``, ``+``, + ``+=``, ``append``, ``extend``, ``insert`` and ``remove`` at the top + level. + """ + uses = {node for node in ast.walk(tree) if _is_all(node)} + if not uses: + return DEFAULT_PUBLIC + names, seen, handled = set(), set(), set() + for statement in tree.body: + value = None + if isinstance(statement, (ast.Assign, ast.AnnAssign)): + targets = ( + statement.targets + if isinstance(statement, ast.Assign) + else [statement.target] + ) + if len(targets) == 1 and _is_all(targets[0]) and statement.value: + value = _strings(statement.value, names) + if value is None: + return EVERY_NAME + names = value + elif ( + isinstance(statement, ast.AugAssign) + and _is_all(statement.target) + and isinstance(statement.op, ast.Add) + ): + value = _strings(statement.value, names) + if value is None: + return EVERY_NAME + names = names | value + elif ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Attribute) + and _is_all(statement.value.func.value) + ): + method, args = statement.value.func.attr, statement.value.args + if method == "append" and len(args) == 1 and _literal(args[0]): + value = {_literal(args[0])} + elif method == "insert" and len(args) == 2 and _literal(args[1]): + value = {_literal(args[1])} + elif method == "extend" and len(args) == 1: + value = _strings(args[0], names) + elif method == "remove": + removed = _literal(args[0]) if len(args) == 1 else None + # Without a literal, any name may be the one removed. + names = names - {removed} if removed else set() + value = set() + if value is None: + return EVERY_NAME + names = names | value + if value is not None: + handled |= {node for node in ast.walk(statement) if _is_all(node)} + seen |= names + if uses - handled: + return EVERY_NAME + return _Exports(frozenset(names), frozenset(seen - names)) + + +class Modules: + """The package's modules, built on demand, for names one imports from another. + + ``sources`` maps module names to (package name, a function returning the + source). A lookup is a pure function of the sources: a cycle is cut + where a lookup meets itself, and a result computed across a cut is not + kept, so no answer depends on the order of lookups. A lookup nested + deeper than ``max_depth`` is cut too, and recorded in ``exhausted``: a + result kept from a shallower lookup could fill that cut in another order. + """ + + def __init__(self, sources: dict, external=None, max_depth=MAX_RESOLUTION_DEPTH): + self.sources = sources + self.external = EXTERNAL_EXPORTS if external is None else external + self.max_depth = max_depth + self.exhausted = set() + self.tops = {} + self.publics = {} + # module -> the package modules it may start, or whose code it may + # reach, when it runs. + self.imported = {} + # module -> the modules it star-imports from. + self.star_bases = {} + # module -> (the package modules it imports or reaches as it runs, + # the packages it star-imports from as it runs). + self.run_imports = {} + # module -> (the package modules code in it that may run later + # imports or reaches, the packages that code star-imports from). + self.later_imports = {} + # package -> the modules below it; every module and package around one. + self._below = None + self._known = None + self._reached = {} + # the packages around a module -> (the package modules that ran + # before it, the package modules their later code may start). + self._ran = {} + self._memo = {} + self._active = set() + self._cuts = 0 + + def top(self, module): + if module not in self.tops: + package, read = self.sources[module] + tree = ast.parse(read(), filename=module) + self.register(module, _build_scopes(tree, module, package, self)[0], tree) + return self.tops[module] + + def register(self, module, top, tree): + if module not in self.tops: + self.tops[module] = top + self.publics[module] = _public_names(tree) + package = self._package(module) + (run, run_stars), (later, later_stars) = _imports_in(tree, package, module) + self._index() + run_reached, later_reached = _references_in( + tree, package, run_stars | later_stars, self._known, module + ) + run, later = run | run_reached, later | later_reached + # Its code may call a function that imports, so every import + # counts. + self.imported[module] = self._imports_of(module, run | later) + self.star_bases[module] = frozenset(run_stars | later_stars) + self.run_imports[module] = ( + self._imports_of(module, run), + frozenset(run_stars), + ) + self.later_imports[module] = ( + self._imports_of(module, later), + frozenset(later_stars), + ) + + def _package(self, module): + return self.sources.get(module, (None,))[0] + + def _imports_of(self, module, names) -> frozenset: + """The package modules ``module`` may start, or reach, through + ``names`` (from ``_imports_in`` and ``_references_in``): each one and + the packages around it. The packages around ``module`` have started + before it. Star imports' submodules are added by ``reached``.""" + started = _started(module) + imported = set() + for name in names: + parts = name.split(".") + for end in range(1, len(parts) + 1): + prefix = ".".join(parts[:end]) + if prefix in self.sources and prefix not in started: + imported.add(prefix) + return frozenset(imported) + + def reached(self, module) -> frozenset: + """The package modules ``module`` may start while it runs, directly + or through those it starts.""" + if module not in self._reached: + seen, stack = set(), [module] + while stack: + current = stack.pop() + self.top(current) + fresh = ( + self.imported[current] + | self.star_loads(current) + | self.called(current) + ) - seen + seen |= fresh + stack.extend(fresh) + self._reached[module] = frozenset(seen) + return self._reached[module] + + def called(self, module) -> frozenset: + """The package modules ``module``'s code may start by calling or + reading code that may run later in the modules that ran before it: + the packages around it, and the package modules their code, as it + ran, imported, directly or through the modules it started.""" + around = _started(module) - {module} + if around not in self._ran: + ran = {package for package in around if package in self.sources} + stack = list(ran) + while stack: + current = stack.pop() + self.top(current) + names, bases = self.run_imports[current] + fresh = (names | self._star_children(bases)) - ran + ran |= fresh + stack.extend(fresh) + starts = set() + for current in ran: + names, bases = self.later_imports[current] + starts |= names | self._star_children(bases) + self._ran[around] = (frozenset(ran), frozenset(starts)) + return self._ran[around][1] - _started(module) + + def star_loads(self, module) -> frozenset: + """The modules ``module``'s star imports of packages may start below + them: what each name a package's ``__all__`` lists, in any version + of it, names below the package, with the packages on the way + (Python imports the package's name, a dot and the listed name, + dotted or not), or every module below it where ``__all__`` is built + otherwise. A star import of a package without ``__all__`` starts + none. The packages around ``module`` have started before it.""" + return self._star_children(self.star_bases[module]) - _started(module) + + def _index(self): + if self._below is None: + self._below, known = {}, set() + for name in self.sources: + known |= _started(name) + parts = name.split(".") + for end in range(1, len(parts)): + self._below.setdefault(".".join(parts[:end]), set()).add(name) + self._known = frozenset(known) + + def _star_children(self, bases) -> frozenset: + """The modules below them star imports from ``bases`` may start.""" + self._index() + loads = set() + for base in bases: + if base not in self.sources: + continue + self.top(base) + public = self.publics[base] + if public == EVERY_NAME: + loads |= self._below.get(base, set()) + elif isinstance(public, _Exports): + for listed in public.final | public.earlier: + parts = f"{base}.{listed}".split(".") + for end in range(base.count(".") + 2, len(parts) + 1): + path = ".".join(parts[:end]) + if path in self.sources: + loads.add(path) + return frozenset(loads) + + def cyclic(self, importer, base) -> bool: + """Whether ``importer``'s star import of ``base`` may run while + ``base`` is still running, and see only what it has bound so far: + when ``base`` may start ``importer``, or is it.""" + return importer == base or importer in self.reached(base) + + def guarded(self, key, cut, compute): + """``compute()``, or ``cut`` where the lookup meets itself.""" + if key in self._memo: + return self._memo[key] + if key in self._active or len(self._active) > self.max_depth: + if key not in self._active: + self.exhausted.add(key) + self._cuts += 1 + return cut + self._active.add(key) + cuts = self._cuts + try: + result = compute() + finally: + self._active.discard(key) + if self._cuts == cuts and not isinstance(key, _Alias): + self._memo[key] = result + return result + + def binding(self, module, name): + """(bound, targets, falls through) for ``module``'s top-level + ``name`` once it ran: whether any statement binds it, what to, and + whether it may be unbound after all.""" + + def compute(): + targets, bound, falls = self.top(module).own(name, None, True) + return bound, frozenset(targets), falls + + cut = (False, frozenset(), True) + return self.guarded(("binding", module, name), cut, compute) + + def expand(self, target, start=1) -> frozenset: + """``target`` and what else it names, where a prefix of it at least + ``start`` parts long is a package module or one ``external`` maps and + the next part is a name that module binds. + + The binding's targets, expanded, replace that prefix and name, and + only the rest is expanded further: a dotted name is both a module + path and a chain of attributes, and ``a.b`` may bind ``b`` to a path + through the submodule ``a.b``. + """ + + def compute(): + found = {target} + parts = target.split(".") + for index in range(start, len(parts)): + base, name = ".".join(parts[:index]), parts[index] + if base in self.sources: + bound = self.binding(base, name)[1] + else: + mapped = self.external.get(base, {}).get(name) + bound = {mapped} if mapped else set() + for head in self.expand_all(bound): + named = ".".join([head] + parts[index + 1 :]) + if named != target: + found |= self.expand(named, len(head.split("."))) + return frozenset(found) + + key = ("expand", target, start) + return self.guarded(key, frozenset({target}), compute) + + def expand_all(self, targets) -> set: + return set().union(*map(self.expand, targets)) + + def star_binds(self, base, name, importer): + """What ``from base import *`` in ``importer`` binds ``name`` to, or + None.""" + if base not in self.sources: + mapped = self.external.get(base, {}).get(name) + return {mapped} if mapped else None + self.top(base) + public = self.publics[base] + if public == DEFAULT_PUBLIC: + exported = not name.startswith("_") + elif public == EVERY_NAME: + exported = True + else: + exported = name in public.final or name in public.earlier + if not exported and not name.startswith("_"): + # In an import cycle it may run before base sets __all__, + # and then binds every public name base has bound. + exported = self.cyclic(importer, base) and self.binding(base, name)[0] + if not exported: + return None + bound, targets, _ = self.binding(base, name) + if bound: + return set(targets) + if isinstance(public, _Exports): + return {f"{base}.{name}"} # A submodule __all__ lists. + return None + + def star_binds_surely(self, base, name, importer) -> bool: + """Whether ``from base import *`` in ``importer``, which star_binds + says may bind ``name``, is sure to.""" + if base not in self.sources: + return True + if self.cyclic(importer, base): + return False # It may run before base binds anything. + public = self.publics[base] + if public == EVERY_NAME: + return False + if isinstance(public, _Exports) and name not in public.final: + return False # Only an earlier or removed __all__ held it. + bound, _, falls = self.binding(base, name) + return not (bound and falls) + + +def _build_scopes(tree, module, package, modules): + """(module scope, the innermost scope of every node, the (first line, + last line) of the innermost statement around every node) of ``tree``.""" + top = _Scope("module", "", None, modules) + scope_of, statement_of = {}, {} + + def assign(target, value, scope, conditional, evaluated_in=None): + """Bind ``target`` in ``scope`` to ``value``, evaluated in + ``evaluated_in`` (by default ``scope``).""" + scope_of[target] = scope + if isinstance(target, ast.Name): + line = target.lineno + alias = _alias(evaluated_in or scope, value, line) + scope.bind(target.id, line, alias, conditional) + elif ( + isinstance(target, (ast.Tuple, ast.List)) + and isinstance(value, (ast.Tuple, ast.List)) + and len(target.elts) == len(value.elts) + ): + for element, element_value in zip(target.elts, value.elts): + assign(element, element_value, scope, conditional, evaluated_in) + else: + visit(target, scope, conditional) + + def bind_arguments(inner, outer, arguments, line): + positional = arguments.posonlyargs + arguments.args + defaults = [None] * (len(positional) - len(arguments.defaults)) + defaults += arguments.defaults + pairs = list(zip(positional, defaults)) + pairs += list(zip(arguments.kwonlyargs, arguments.kw_defaults)) + pairs += [(arguments.vararg, None), (arguments.kwarg, None)] + for argument, default in pairs: + if argument is not None: + inner.bind(argument.arg, line, _alias(outer, default, line)) + + def declare(inner, body): + """Send names a global or nonlocal statement in ``body`` names to + the scope that binds them.""" + stack = list(body) + while stack: + node = stack.pop() + if isinstance(node, ast.Global): + for name in node.names: + inner.declared[name] = top + elif isinstance(node, ast.Nonlocal): + # The nearest enclosing function that makes the name local, + # wherever in its body it binds it; class bodies are skipped. + for name in node.names: + owner = inner.parent + while owner is not None and not ( + owner.kind in ("function", "lambda") + and name in owner.local_names + ): + owner = owner.parent + if owner is not None: + inner.declared[name] = owner + elif not isinstance(node, FUNCTION_NODES + (ast.ClassDef, ast.Lambda)): + stack.extend(ast.iter_child_nodes(node)) + + def visit_annotations(annotations, scope, line, conditional): + # Evaluated in the outer scope, with the statement or later (see + # _Scope.resolve); the statement's own bindings are not sure for + # them, as for any use in it. + if not annotations: + return + inner = _Scope("annotation", scope.qualname, scope, modules, line) + for annotation in annotations: + visit(annotation, inner, conditional) + + def visit_function(node, scope, conditional): + # Decorators and defaults are evaluated in the outer scope. + outer = list(getattr(node, "decorator_list", [])) + outer += node.args.defaults + [d for d in node.args.kw_defaults if d] + for child in outer: + visit(child, scope, conditional) + if not isinstance(node, ast.Lambda): + annotations = [node.returns] if node.returns else [] + annotations += [ + argument.annotation + for argument in ast.walk(node.args) + if isinstance(argument, ast.arg) and argument.annotation + ] + annotations += _type_parameter_values(node) + visit_annotations(annotations, scope, node.lineno, conditional) + name = getattr(node, "name", "") + if not isinstance(node, ast.Lambda): + top_level = scope.parent is None + target = f"{module}.{name}" if top_level else None + scope.bind(name, node.lineno, target, conditional) + qualname = name if scope.parent is None else f"{scope.qualname}.{name}" + kind = "lambda" if isinstance(node, ast.Lambda) else "function" + inner = _Scope(kind, qualname, scope, modules, node.lineno) + if getattr(node, "decorator_list", None): + # A decorator may call the function before its name is bound. A + # lambda's body is part of its own statement, which every use + # in it already treats as unfinished (resolve). + inner.unfinished = statement_span[-1] + inner.local_names = _function_locals(node) + body = node.body if isinstance(node.body, list) else [node.body] + declare(inner, body) + bind_arguments(inner, scope, node.args, node.lineno) + for child in body: + visit(child, inner) + + def visit_class(node, scope, conditional): + for child in node.decorator_list + node.bases: + visit(child, scope, conditional) + for keyword in node.keywords: + visit(keyword.value, scope, conditional) + visit_annotations(_type_parameter_values(node), scope, node.lineno, conditional) + top_level = scope.parent is None + target = f"{module}.{node.name}" if top_level else None + scope.bind(node.name, node.lineno, target, conditional) + qualname = node.name if top_level else f"{scope.qualname}.{node.name}" + inner = _Scope("class", qualname, scope, modules, node.lineno) + inner.unfinished = statement_span[-1] + inner.local_names = _block_locals(node.body) + declare(inner, node.body) + for child in node.body: + visit(child, inner) + + def visit_comprehension(node, scope, conditional): + # The first iterable is evaluated in the enclosing scope; the rest + # in the comprehension's own, reported under the enclosing name. + visit(node.generators[0].iter, scope, conditional) + # A generator expression runs the rest when it is iterated. + kind = "generator" if isinstance(node, ast.GeneratorExp) else "comprehension" + inner = _Scope(kind, scope.qualname, scope, modules, node.lineno) + # The rest may run any number of times, a generator expression's + # only as it is iterated, so a walrus in it may not bind. + for index, generator in enumerate(node.generators): + scope_of[generator] = inner + if index: + visit(generator.iter, inner, True) + assign(generator.target, None, inner, False) + for condition in generator.ifs: + visit(condition, inner, True) + if isinstance(node, ast.DictComp): + elements = [node.key, node.value] + else: + elements = [node.elt] + for element in elements: + visit(element, inner, True) + + # (first line, last line) of each statement being visited, innermost + # last; a definition's decorators are part of its statement. + statement_span = [(1, 1)] + + def visit(node, scope, conditional=False): + if not isinstance(node, ast.stmt): + return visit_node(node, scope, conditional) + lines = [node.lineno] + lines += [d.lineno for d in getattr(node, "decorator_list", [])] + statement_span.append((min(lines), node.end_lineno)) + try: + return visit_node(node, scope, conditional) + finally: + statement_span.pop() + + def visit_node(node, scope, conditional): + scope_of[node] = scope + statement_of[node] = statement_span[-1] + if isinstance(node, FUNCTION_NODES + (ast.Lambda,)): + return visit_function(node, scope, conditional) + if isinstance(node, ast.ClassDef): + return visit_class(node, scope, conditional) + if isinstance(node, COMPREHENSION_NODES): + return visit_comprehension(node, scope, conditional) + if isinstance(node, TYPE_ALIAS): + # Its value is evaluated when it is read, like an annotation. + scope_of[node.name] = scope + scope.bind(node.name.id, node.lineno, None, conditional) + values = [node.value] + _type_parameter_values(node) + return visit_annotations(values, scope, node.lineno, conditional) + if isinstance(node, ast.Assign): + visit(node.value, scope, conditional) + for target in node.targets: + assign(target, node.value, scope, conditional) + return + if isinstance(node, ast.AnnAssign): + body = scope.kind in ("module", "class") + if body and node.simple: + visit_annotations([node.annotation], scope, node.lineno, conditional) + else: + # 3.14 never evaluates this one; earlier versions do, with + # the statement, outside a function. + visit(node.annotation, scope, conditional) + if node.value is not None: + visit(node.value, scope, conditional) + elif body and isinstance(node.target, ast.Name): + # An annotation alone binds no value here; in a function it + # still makes the name local. + scope_of[node.target] = scope + return + return assign(node.target, node.value, scope, conditional) + if isinstance(node, ast.NamedExpr): + visit(node.value, scope, conditional) + # The target belongs to the scope around any comprehensions or + # annotations; the value is evaluated where the expression is. + owner = scope + while owner.kind in ("comprehension", "generator", "annotation"): + owner = owner.parent + return assign(node.target, node.value, owner, conditional, scope) + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + scope.bind(alias.asname, node.lineno, alias.name, conditional) + else: + root = alias.name.split(".")[0] + scope.bind(root, node.lineno, root, conditional) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + for alias in node.names: + if alias.name == "*": + scope.stars.append((node.lineno, base, conditional, module)) + else: + # A submodule or a name base binds: expand decides. + name = alias.asname or alias.name + scope.bind(name, node.lineno, f"{base}.{alias.name}", conditional) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + scope.bind(node.id, node.lineno, None, conditional) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Del): + scope.delete(node.id, node.lineno) + elif isinstance(node, ast.ExceptHandler) and node.name: + # Python deletes the name when the handler ends. + scope.bind(node.name, node.lineno, None, True) + scope.delete(node.name, node.end_lineno) + inside = conditional or isinstance(node, COMPOUND_NODES) + for child in ast.iter_child_nodes(node): + visit(child, scope, inside or _may_not_run(node, child)) + + visit(tree, top) + return top, scope_of, statement_of + + +def _reads_an_id(node) -> bool: + for child in ast.walk(node): + if isinstance(child, ast.Name) and child.id.endswith("_id"): + return True + if ( + isinstance(child, ast.Constant) + and isinstance(child.value, str) + and child.value.endswith("_id") + ): + return True + return False + + +def _is_xorshift(left, right, shift_of) -> bool: + """``x ^ (x >> k)`` or ``x ^ (x << k)``, in either order. + + ``shift_of`` gives the shifted operand of a shift, or None. + """ + for shifted, other in ((left, right), (right, left)): + operand = shift_of(shifted) + if operand is not None and ast.unparse(operand) == ast.unparse(other): + return True + return False + + +def _argument(call, position, keyword): + """The argument ``call`` passes at ``position`` or as ``keyword``.""" + if len(call.args) > position: + return call.args[position] + for passed in call.keywords: + if passed.arg == keyword: + return passed.value + return None + + +def _anchor(node, module): + """The package a literal, or the module's ``__name__``, names, else None + (callers read anything computed, ``__package__`` or ``__spec__.parent`` + too, as the module's own package).""" + if _literal(node) is not None: + return _literal(node) + if isinstance(node, ast.Name) and node.id == "__name__": + return module + return None + + +def _is_none(node) -> bool: + return isinstance(node, ast.Constant) and node.value is None + + +def _dict_entries(node) -> list: + """(key, value) for each entry of dict literal ``node``, in order, a + literal ``**`` dict's own entries in its place; a computed ``**`` entry + gives (None, the mapping).""" + entries = [] + for key, value in zip(node.keys, node.values): + if key is None and isinstance(value, ast.Dict): + entries += _dict_entries(value) + else: + entries.append((key, value)) + return entries + + +def _iterated(node) -> list: + """The items Python gets iterating ``node``, as far as it is literal: a + string's characters, a list's, tuple's or set's elements and a dict's + keys, a ``*`` or ``**`` entry's own in its place. An item that is not a + literal string is None; a computed iterable gives none.""" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + items = [] + for element in node.elts: + if isinstance(element, ast.Starred): + items += _iterated(element.value) + else: + items.append(_literal(element)) + return items + if isinstance(node, ast.Dict): + return [_literal(key) for key, _ in _dict_entries(node)] + return list(_literal(node) or "") + + +def _globals_package(node, package, module): + """The package a relative ``__import__`` with ``globals`` ``node`` + imports from, as Python reads it: ``__package__``, else + ``__spec__.parent``, else ``__name__``, the package around it unless + ``__path__`` is set; None where it has neither, and Python raises. + A dict literal's literal ``**`` entries are read in place; anything + else (``globals()``, a computed mapping, or a dict literal with a + computed key or ``**`` entry) is read as the module's own globals.""" + if not isinstance(node, ast.Dict): + return package + pairs = _dict_entries(node) + if not all(isinstance(key, ast.Constant) for key, _ in pairs): + return package + # The last of a repeated key wins, as in the dict Python builds; a + # None value then reads as absent. Keys that aren't strings are + # never ones Python reads. + entries = {key.value: value for key, value in pairs} + given = {key: value for key, value in entries.items() if not _is_none(value)} + if "__package__" in given: + return _anchor(given["__package__"], module) or package + if "__spec__" in given: + return package + if "__name__" not in given: + return None + name = _anchor(given["__name__"], module) + if name is None: + return package + return name if "__path__" in entries else name.rpartition(".")[0] + + +def _dynamic_imports(call, called, package, module=None) -> list: + """Absolute names a call of a dynamic import function imports, where its + arguments are literals: ``base.*`` where an ``__import__`` ``fromlist`` + holds ``*``.""" + names = [] + if "importlib.import_module" in called: + name = _literal(_argument(call, 0, "name")) + given = _argument(call, 1, "package") + relative_to = _anchor(given, module) + if relative_to is None and given is not None and _literal(given) is None: + # A computed package is read as the module's own; without one, + # Python raises. + relative_to = None if _is_none(given) else package + if name and name.startswith("."): + level = len(name) - len(name.lstrip(".")) + names.append(_absolute(name.lstrip("."), level, relative_to)) + elif name: + names.append(name) + if "builtins.__import__" in called: + name = _literal(_argument(call, 0, "name")) + level = _argument(call, 4, "level") + level = level.value if isinstance(level, ast.Constant) else 0 + if name is not None and isinstance(level, int): + relative_to = _globals_package( + _argument(call, 1, "globals"), package, module + ) + base = _absolute(name, level, relative_to) if level else name + names.append(base) + items = _iterated(_argument(call, 3, "fromlist")) + names += [f"{base}.{item}" for item in items if item] + return names + + +# Code that may run after the statement holding it, when it is called or +# read, so after the module has run: as may annotations (deferred from 3.14), +# type parameters and ``type`` values. +LATER_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.GeneratorExp) +LATER_FIELDS = ("annotation", "type_params") + + +def _later_walk(tree): + """(node, whether it may run later) for every node of ``tree``: in a + function, lambda or generator expression (with its decorators, defaults + and first iterable, which run at once, to be safe), an annotation, type + parameters or a ``type`` alias.""" + stack = [(tree, False)] + while stack: + node, later = stack.pop() + yield node, later + later = later or isinstance(node, LATER_NODES) or isinstance(node, TYPE_ALIAS) + for field, value in ast.iter_fields(node): + for child in value if isinstance(value, list) else [value]: + if isinstance(child, ast.AST): + stack.append((child, later or field in LATER_FIELDS)) + + +def _imports_in(tree, package, module=None): + """((names, star bases) of the imports that run with ``tree``'s module, + (names, star bases) of those in code that may run later (``_later_walk``). + + Names are absolute, as an import, or a call whose literal arguments name + a module as ``importlib.import_module`` or ``__import__`` would read + them, imports them, whatever name the call is made under: an alias, a + re-export or ``getattr`` may make it one. Star bases are the packages a + star import, or ``__import__`` with ``*`` in its ``fromlist``, loads + from.""" + found = {False: (set(), set()), True: (set(), set())} + for node, later in _later_walk(tree): + names, stars = found[later] + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + names.add(base) + names.update(f"{base}.{alias.name}" for alias in node.names) + if any(alias.name == "*" for alias in node.names): + stars.add(base) + elif isinstance(node, ast.Call): + for name in _dynamic_imports(node, DYNAMIC_IMPORTS, package, module): + names.add(name) + if name.endswith(".*"): + stars.add(name[: -len(".*")]) + return found[False], found[True] + + +# What _references_in's rules read, with the paths on the way. +REFERENCE_NAMES = frozenset( + {"getattr", "builtins", "builtins.getattr", "sys", "sys.modules", "sys.modules.get"} +) + + +def _references_in(tree, package, stars, known, module=None): + """(paths code that runs with ``tree``'s module may name, paths code that + may run later may name; ``_later_walk``). + + Another module may have loaded a module, which is then an attribute of + its package, so code can reach it, and call its functions, without + importing it. Each name, attribute chain, ``getattr`` with a literal + name, ``sys.modules`` read with a literal key (``[]`` or ``get``) and + literal dynamic import (the module or its top package, which it + returns), and each branch of a conditional expression, ``and`` or + ``or``, names every path its first name may: what any import in the + module, or assignment of such an expression (or walrus), binds the name + to (an assignment may name the name itself, as in ``x = x.sub``), the name + itself, the name below each package in ``stars`` (a star import of a + package binds its loaded submodules), and in a package's ``__init__`` + the name below the package. Only paths in ``known`` (each package module + and the packages around it), from which a module may yet be named, and + those the rules read are kept. Which paths are package modules is the + caller's to say.""" + bound = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + bound.setdefault(name, []).append(alias.name if alias.asname else name) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + for alias in node.names: + if alias.name != "*": + name = alias.asname or alias.name + bound.setdefault(name, []).append(f"{base}.{alias.name}") + elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name) and node.value is not None: + bound.setdefault(target.id, []).append(node.value) + around = list(stars) + ([package] if package and module in (None, package) else []) + keep = known | REFERENCE_NAMES + names = {} + + def name_paths(name): + if name not in names: + names[name] = ({name} | {f"{base}.{name}" for base in around}) & keep + return names[name] + + def paths(node): + return _paths(node) & keep + + def _paths(node): + if isinstance(node, ast.Name): + return name_paths(node.id) + if isinstance(node, ast.Attribute): + return {f"{path}.{node.attr}" for path in paths(node.value)} + if isinstance(node, ast.Subscript): + key = _literal(node.slice) + if key and "sys.modules" in paths(node.value): + return {key} + return set() + if isinstance(node, ast.IfExp): + return paths(node.body) | paths(node.orelse) + if isinstance(node, ast.BoolOp): + return set().union(*map(paths, node.values)) + if isinstance(node, ast.NamedExpr): + return paths(node.value) + if not isinstance(node, ast.Call): + return set() + called = paths(node.func) + if called & {"getattr", "builtins.getattr"} and len(node.args) >= 2: + attribute = _literal(node.args[1]) + if attribute: + return {f"{path}.{attribute}" for path in paths(node.args[0])} + if "sys.modules.get" in called and node.args and _literal(node.args[0]): + return {_literal(node.args[0])} + returned = set() + for name in _dynamic_imports(node, DYNAMIC_IMPORTS, package, module): + if not name.endswith(".*"): + returned |= {name, name.split(".")[0]} + return returned + + # Bind each name to what its imports and assignments name, until no + # path is new: kept paths are finite, so this ends. + changed = True + while changed: + changed = False + for name, values in bound.items(): + for value in values: + fresh = ({value} & keep if isinstance(value, str) else paths(value)) - ( + name_paths(name) + ) + if fresh: + names[name] |= fresh + changed = True + + found = {False: set(), True: set()} + for node, later in _later_walk(tree): + if isinstance(node, (ast.Name, ast.Attribute, ast.Subscript, ast.Call)): + found[later] |= paths(node) + return found[False], found[True] + + +def _started(module) -> frozenset: + """``module`` and the packages around it, which start before it runs.""" + parts = module.split(".") + return frozenset(".".join(parts[:end]) for end in range(1, len(parts) + 1)) + + +class Analysis(NamedTuple): + module: str + # (line, enclosing scope, kind), without draw helper calls. + findings: list + # (line, enclosing scope, targets) for each use that may name something + # in the package. + references: list + # Names of the module's top-level functions, also those defined in an + # if or try. + functions: list + # (line, absolute module name) for each import, static or literal dynamic. + imports: list + + +def analyse( + source: str, + filename: str = "", + module: str = "m", + package: str | None = None, + modules: Modules | None = None, +) -> Analysis: + """Draw patterns, package references and imports of one module. + + ``modules`` resolves names imported from other package modules; without + it the module stands alone. + """ + tree = ast.parse(source, filename=filename) + if modules is None: + modules = Modules({module: (package, lambda: source)}) + top, scope_of, statement_of = _build_scopes(tree, module, package, modules) + modules.register(module, top, tree) + parents = { + child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node) + } + root_package = module.split(".")[0] + "." + findings, references, imports = [], [], [] + + def scope_for(node): + while node not in scope_of: + node = parents[node] + return scope_of[node] + + def statement_for(node): + while node not in statement_of: + node = parents[node] + return statement_of[node] + + def report(node, kind): + findings.append((node.lineno, scope_for(node).qualname, kind)) + + def report_targets(node, targets): + for kind in sorted({_classify(target) for target in targets} - {None}): + report(node, kind) + + def targets_of(node) -> set: + dotted = _dotted(node) + if dotted is None: + return set() + root, attributes = dotted + return modules.expand_all( + ".".join([target] + attributes) + for target in scope_for(node).resolve( + root, node.lineno, statement=statement_for(node) + ) + ) + + def shift_of(node): + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.RShift, ast.LShift) + ): + return node.left + if isinstance(node, ast.Call) and node.args: + if targets_of(node.func) & SHIFT_FUNCTIONS: + return node.args[0] + return None + + def check_import(node, name): + imports.append((node.lineno, name)) + if _is_stochastic(name): + report(node, "stochastic import") + elif name.split(".")[0] in HASH_MODULES: + report(node, "hash library") + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + check_import(node, alias.name) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + names = [base] + [f"{base}.{alias.name}" for alias in node.names] + imports.extend((node.lineno, name) for name in names) + if any(map(_is_stochastic, names)): + report(node, "stochastic import") + elif base.split(".")[0] in HASH_MODULES: + report(node, "hash library") + elif isinstance(node, (ast.Name, ast.Attribute)) and isinstance( + node.ctx, ast.Load + ): + parent = parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue # Only the whole chain counts. + targets = targets_of(node) + report_targets(node, targets) + named = frozenset(t for t in targets if t.startswith(root_package)) + if named: + references.append((node.lineno, scope_for(node).qualname, named)) + if isinstance(node, ast.Call): + func = node.func + called = targets_of(func) + if called & DYNAMIC_IMPORTS: + for name in _dynamic_imports(node, called, package, module): + check_import(node, name) + if "builtins.getattr" in called and len(node.args) >= 2: + attribute = _literal(node.args[1]) + if attribute: + owners = targets_of(node.args[0]) + report_targets( + node, + modules.expand_all(f"{owner}.{attribute}" for owner in owners), + ) + if called & XOR_FUNCTIONS and len(node.args) == 2: + if _is_xorshift(*node.args, shift_of): + report(node, "xorshift") + if called & MOD_FUNCTIONS and node.args and _reads_an_id(node.args[0]): + report(node, "id modulo") + if isinstance(func, ast.Attribute): + keywords = {keyword.arg for keyword in node.keywords} + if func.attr in SAMPLING_METHODS or ( + func.attr == "sample" + and (node.args or keywords & SAMPLING_KEYWORDS) + ): + report(node, "sampling call") + elif isinstance(node, ast.Constant): + value = node.value + if ( + isinstance(value, int) + and not isinstance(value, bool) + and value in HASH_CONSTANTS + ): + report(node, "hash constant") + elif isinstance(node, (ast.BinOp, ast.AugAssign)): + left = node.left if isinstance(node, ast.BinOp) else node.target + right = node.right if isinstance(node, ast.BinOp) else node.value + if isinstance(node.op, ast.BitXor) and _is_xorshift(left, right, shift_of): + report(node, "xorshift") + if isinstance(node.op, ast.Mod) and _reads_an_id(left): + report(node, "id modulo") + functions = [ + node.name + for node in ast.walk(tree) + if isinstance(node, FUNCTION_NODES) and scope_of.get(node) is top + ] + return Analysis(module, findings, references, functions, imports) + + +def helper_calls(analysis: Analysis, helpers) -> list: + """(line, scope, "draw helper call") for each use of a draw helper.""" + return [ + (line, scope, "draw helper call") + for line, scope, targets in analysis.references + if any(_matches(target, helpers) for target in targets) + ] + + +def draw_helpers(analyses: dict, exempt: dict) -> set: + """Top-level functions with a finding ``exempt`` does not cover. + + A function that uses one of them is one too, so this is a fixed point + over the package. + """ + helpers = set() + while True: + added = set() + for relative, analysis in analyses.items(): + allowed = exempt.get(relative, {}) + counted = Counter( + (scope, kind) + for _, scope, kind in analysis.findings + + helper_calls(analysis, helpers) + ) + for name in analysis.functions: + target = f"{analysis.module}.{name}" + if target not in helpers and any( + count > allowed.get(key, 0) + for key, count in counted.items() + if key[0] == name or key[0].startswith(name + ".") + ): + added.add(target) + if not added: + return helpers + helpers |= added + + +def draw_findings(source: str, filename: str = "", exempt=None) -> list: + """(line, enclosing scope, kind) for every draw pattern in ``source``. + + ``exempt`` is the file's EXEMPT entries, which decide which of its + functions are draw helpers. + """ + analysis = analyse(source, filename) + helpers = draw_helpers({filename: analysis}, {filename: exempt or {}}) + return sorted(analysis.findings + helper_calls(analysis, helpers)) + + +def count_findings(findings_by_file: dict) -> dict: + """{file: {(scope, kind): [lines]}} from {file: draw_findings(...)}.""" + counted = {} + for relative, findings in findings_by_file.items(): + for line, scope, kind in findings: + counted.setdefault(relative, {}).setdefault((scope, kind), []).append(line) + return counted + + +def check_allowlists(counted: dict, ratchet: dict, exempt: dict): + """(violations, stale entries) of counted findings against the lists.""" + violations, stale = [], [] + allowed = {} + for allowlist in (ratchet, exempt): + for relative, entries in allowlist.items(): + for key, count in entries.items(): + allowed[(relative, key)] = allowed.get((relative, key), 0) + count + for relative, entries in sorted(counted.items()): + for (scope, kind), lines in sorted(entries.items()): + limit = allowed.get((relative, (scope, kind)), 0) + if len(lines) > limit: + violations.append( + f"{relative}:{','.join(map(str, lines))} in {scope}: {kind} " + f"({len(lines)} found, {limit} allowed)" + ) + for (relative, (scope, kind)), limit in sorted(allowed.items()): + found = len(counted.get(relative, {}).get((scope, kind), [])) + if found < limit: + stale.append( + f"{relative} in {scope}: {kind} ({limit} allowed, {found} found)" + ) + return violations, stale + + +def package_modules(): + """(relative path, module name, package name, path) of every module.""" + for path in sorted(PACKAGE.rglob("*.py")): + if TESTS in path.parents: + continue + relative = path.relative_to(PACKAGE).as_posix() + dotted = f"{PACKAGE.name}.{relative.removesuffix('.py').replace('/', '.')}" + if dotted.endswith(".__init__"): + module = dotted.removesuffix(".__init__") + yield relative, module, module, path + else: + yield relative, dotted, dotted.rsplit(".", 1)[0], path + + +def _reader(path): + return lambda: path.read_text(encoding="utf-8") + + +class PackageScan(NamedTuple): + counts: dict + helpers: set + imports: dict + modules: Modules + + +@cache +def package_scan() -> PackageScan: + """Every module analysed once per test session.""" + listed = list(package_modules()) + modules = Modules( + {module: (package, _reader(path)) for _, module, package, path in listed} + ) + analyses = { + relative: analyse( + path.read_text(encoding="utf-8"), relative, module, package, modules + ) + for relative, module, package, path in listed + } + helpers = draw_helpers(analyses, EXEMPT) + counts = count_findings( + { + relative: analysis.findings + helper_calls(analysis, helpers) + for relative, analysis in analyses.items() + } + ) + imports = {relative: analysis.imports for relative, analysis in analyses.items()} + return PackageScan(counts, helpers, imports, modules) + + +@pytest.fixture(scope="module", autouse=True) +def _release_package_scan(): + """Free the package scan when this module's tests finish.""" + yield + package_scan.cache_clear() + + +def _kinds(source: str) -> set: + return {kind for _, _, kind in draw_findings(source)} + + +def _on_this_python(cases, minor=-1) -> list: + """The cases whose syntax this Python parses. + + Each case gives the Python 3 minor version it needs at index ``minor``. + Filtering them as the tests are collected, rather than skipping them, + leaves no skipped outcome on an older Python. + """ + return [case for case in cases if sys.version_info >= (3, case[minor])] + + +@pytest.mark.parametrize( + "source, kind", + [ + ("import random\n", "stochastic import"), + ("from random import uniform\nuniform()\n", "random source"), + ("import random as rnd\nrnd.random()\n", "random source"), + ("import secrets\n", "stochastic import"), + ("import numpy.random as npr\nnpr.uniform()\n", "random source"), + ("from numpy import random as r\nr.random(3)\n", "random source"), + ("from numpy import random as r\n", "stochastic import"), + ("from numpy.random import default_rng as g\ng(43)\n", "random source"), + ("np.random.seed(1)\n", "random source"), + ("import numpy as n\nn.random.default_rng(0)\n", "random source"), + ("def f():\n return np.random.random(3)\n", "random source"), + ("import uuid\nuuid.uuid4()\n", "random source"), + ("import os\nos.urandom(8)\n", "random source"), + # Aliases, star imports and scopes. + ("import numpy as np\nxp = np\nxp.random.uniform()\n", "random source"), + ("r = np.random\ndef f():\n return r.uniform()\n", "random source"), + ("xp, k = np, 1\nxp.random.uniform()\n", "random source"), + ("def f(xp=np):\n return xp.random.uniform()\n", "random source"), + ("from numpy import *\nrandom.uniform()\n", "random source"), + ("getattr(np, 'random').uniform()\n", "random source"), + ( + "import numpy as np\n" + "def f():\n" + " return [np for np in np.random.uniform(size=2)]\n", + "random source", + ), + ("if (r := np.random):\n r.uniform()\n", "random source"), + # A walrus in a comprehension binds outside it, to a value read inside. + ( + "import numpy as np\n[(lib := np) for _ in [0]]\nlib.random.uniform()\n", + "random source", + ), + # A name may refer to any binding that may be the one read. + ( + "from policyengine_core.model_api import np as lib\nlib.random.uniform()\n", + "random source", + ), + # An attribute of a module is what the module binds under the name. + ( + "import policyengine_core.model_api as api\napi.np.random.uniform()\n", + "random source", + ), + ( + "from policyengine_core import model_api\nmodel_api.np.random.uniform()\n", + "random source", + ), + ( + "import policyengine_core.model_api as api\nlib = api.np\nlib.random.uniform()\n", + "random source", + ), + ( + "import numpy as lib\n" + "values = [lib.random.uniform() for _ in range(1)]\n" + "lib = object()\n", + "random source", + ), + ( + "import numpy as np\n" + "class Outer:\n" + " np = object()\n" + " class Inner:\n" + " value = np.random.uniform()\n", + "random source", + ), + ( + "class Outer:\n hash = 1\n class Inner:\n key = hash('x')\n", + "builtin hash", + ), + ("import numpy as lib; alias = lib; alias.random.uniform()\n", "random source"), + ( + "try:\n" + " import numpy as lib\n" + "except ImportError:\n" + " lib = None\n" + "lib.random.uniform()\n", + "random source", + ), + ( + "def setup():\n" + " global lib\n" + " import numpy as lib\n" + "def f():\n" + " return lib.random.uniform()\n", + "random source", + ), + ( + "def outer():\n" + " lib = None\n" + " def setup():\n" + " nonlocal lib\n" + " import numpy as lib\n" + " return lib.random.uniform()\n", + "random source", + ), + # A global name skips the functions between it and the module. + ( + "import numpy as lib\n" + "def outer():\n" + " lib = None\n" + " def inner():\n" + " global lib\n" + " return lib.random.random()\n" + " return inner\n", + "random source", + ), + # nonlocal names the nearest function that binds the name, not the + # nearest function, and wherever in its body the binding is. + ( + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " def inner():\n" + " nonlocal lib\n" + " lib.random.random()\n" + " lib = None\n" + " inner()\n" + " middle()\n", + "random source", + ), + ( + "def outer():\n" + " def middle():\n" + " def inner():\n" + " nonlocal lib\n" + " lib = None\n" + " return lib.random.random()\n" + " return inner\n" + " import numpy as lib\n" + " return middle\n", + "random source", + ), + # A walrus in a nested function's annotation binds in the function + # around it, so middle binds lib and inner reads numpy. + ( + "import numpy as np\n" + "def outer():\n" + " lib = None\n" + " def middle():\n" + " def annotated(x: (lib := np)):\n" + " pass\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + "random source", + ), + ( + "import numpy as np\n" + "def outer():\n" + " lib = None\n" + " def middle():\n" + " def annotated() -> (lib := np):\n" + " pass\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + "random source", + ), + # A walrus in a lambda binds in the lambda, even inside a + # comprehension, so middle doesn't bind lib. + ( + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " [(lambda: (lib := None)) for marker in ()]\n" + " def inner():\n" + " nonlocal lib\n" + " lib.random.random()\n" + " lib = None\n" + " inner()\n" + " middle()\n", + "random source", + ), + ("if flag:\n hash = str\nkey = hash('x')\n", "builtin hash"), + ("key = hash('x')\nhash = str\n", "builtin hash"), + # A deletion, or the end of an except clause, may unbind a shadow. + ("hash = str\ndel hash\nkey = hash('x')\n", "builtin hash"), + ( + "class A:\n hash = str\n del hash\n key = hash('x')\n", + "builtin hash", + ), + ( + "hash = str\n" + "try:\n" + " pass\n" + "except ValueError as hash:\n" + " pass\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "hash = str\n" + "def clear():\n" + " global hash\n" + " del hash\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + # A function's del of a global may run after a later binding. + ( + "def clear():\n" + " global hash\n" + " del hash\n" + "hash = str\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "def clear():\n" + " global hash\n" + " if flag:\n" + " del hash\n" + "hash = str\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "def outer():\n" + " def clear():\n" + " global hash\n" + " del hash\n" + " return clear\n" + "hash = str\n" + "outer()()\n" + "def consumer(value):\n" + " return hash(value)\n", + "builtin hash", + ), + # Code may run before the statement creating it binds its names: a + # decorator may call the function, a class body runs before its + # name is bound, and an assignment's value may call a lambda in it. + ( + "def eager(f):\n" + " return f('x')\n" + "@eager\n" + "def hash(value):\n" + " return hash(value)\n", + "builtin hash", + ), + ("class hash:\n answer = hash('x')\n", "builtin hash"), + ("hash = (lambda value: hash(value))('x')\n", "builtin hash"), + # 3.14 evaluates annotations when they are read (PEP 649), after any + # later deletion; up to 3.13 they run with their statement. + ("hash = str\ndef f(x: hash('x')):\n pass\ndel hash\n", "builtin hash"), + ("hash = str\ndef f() -> hash('x'):\n pass\ndel hash\n", "builtin hash"), + ( + "class C:\n" + " hash = str\n" + " def f(self, x: hash('x')):\n" + " pass\n" + " del hash\n", + "builtin hash", + ), + ( + "class C:\n hash = str\n y: hash('x') = 1\n del hash\n", + "builtin hash", + ), + ("hash = str\ny: hash('x') = 1\ndel hash\n", "builtin hash"), + ("def hash(x: hash('x')):\n pass\n", "builtin hash"), + # A statement binds its names only after evaluating them. + ("hash = (\n hash('x')\n)\n", "builtin hash"), + ("def hash(\n value=hash('x'),\n):\n return value\n", "builtin hash"), + ("class hash(\n make(hash('x')),\n):\n pass\n", "builtin hash"), + ("(\n hash\n) = (\n hash('x')\n)\n", "builtin hash"), + # An annotation alone binds no value in a module or class body. + ("hash: int\nkey = hash('x')\n", "builtin hash"), + ( + "import numpy as lib\nclass C:\n lib: object\n answer = lib.random.uniform()\n", + "random source", + ), + # A function may run before a later binding, or after a deletion. + ( + "def consumer(value):\n" + " return hash(value)\n" + "answer = consumer('x')\n" + "hash = str\n", + "builtin hash", + ), + ("hash = str\ndef f(x):\n return hash(x)\ndel hash\n", "builtin hash"), + # A walrus in a generator expression binds around it. + ( + "import numpy as np\nlist((lib := np) for _ in 'x')\nlib.random.uniform()\n", + "random source", + ), + ("import importlib\nimportlib.import_module('random')\n", "stochastic import"), + ( + "import importlib\nimportlib.import_module(name='random')\n", + "stochastic import", + ), + ("__import__('numpy.random')\n", "stochastic import"), + ("__import__('numpy', fromlist=['random'])\n", "stochastic import"), + # Python iterates a fromlist, unpacked entries too. + ("__import__('numpy', fromlist={**{'random': None}})\n", "stochastic import"), + ("__import__('numpy', fromlist=[*('random',)])\n", "stochastic import"), + ("__import__('numpy', fromlist={*'x', *{'random': 0}})\n", "stochastic import"), + # At any depth. + ( + "__import__('numpy', fromlist={**{**{'random': None}}})\n", + "stochastic import", + ), + ("__import__('numpy', fromlist=[*[*('random',)]])\n", "stochastic import"), + ("from importlib import import_module as load\nload('zlib')\n", "hash library"), + # Hashes. + ('key = hash("x")\n', "builtin hash"), + ("keys = map(hash, names)\n", "builtin hash"), + ("import builtins\nbuiltins.hash(1)\n", "builtin hash"), + ( + "class A:\n hash = 1\n def m(self, x):\n return hash(x)\n", + "builtin hash", + ), + ("import hashlib\n", "hash library"), + ("from zlib import crc32\n", "hash library"), + ("import hashlib\ndef f(b):\n return hashlib.sha256(b)\n", "hash use"), + ("from zlib import crc32\ncrc32(b'x') % 12\n", "hash use"), + ("pd.util.hash_pandas_object(df)\n", "hash use"), + ("m = np.uint64(0x94D049BB133111EB)\n", "hash constant"), + ("gamma = 11400714819323198485\n", "hash constant"), + ("h = 0xCBF29CE484222325\n", "hash constant"), + ("x = x ^ (x >> 30)\n", "xorshift"), + ("x = (x << 13) ^ x\n", "xorshift"), + ("x ^= x >> 30\n", "xorshift"), + ("x = np.bitwise_xor(x, np.right_shift(x, 30))\n", "xorshift"), + # Sampling and ids. + ("df = df.sample(frac=0.5, random_state=1)\n", "sampling call"), + ("df = df.sample(5)\n", "sampling call"), + ("rng.shuffle(values)\n", "sampling call"), + ("pick = rng.choice(5)\n", "sampling call"), + ("month = person_id % 12\n", "id modulo"), + ('half = person("person_id", period) % 2\n', "id modulo"), + ("person_id %= 12\n", "id modulo"), + ("month = np.mod(person_id, 12)\n", "id modulo"), + ("month, _ = divmod(person_id, 12)\n", "id modulo"), + # A call of a function that draws. + ( + "def mix(x):\n return x ^ (x >> 3)\ndef f(x):\n return mix(x)\n", + "draw helper call", + ), + ], +) +def test_detector_flags_each_draw_pattern(source, kind): + assert kind in _kinds(source) + + +@pytest.mark.parametrize( + "source", + [ + "from typing import Generator\ndef f(Generator):\n return Generator\n", + "random = 3\nrandom + 1\n", + "def f(random):\n return random.random()\n", + "def hash(x):\n return x\nhash(1)\n", + # A binding sure to run before a function's def hides the builtin + # from its body; so does a binding after a deletion. + "def hash(x):\n return x\ndef f(x):\n return hash(x)\n", + "hash = str\nclass A:\n def m(self, x):\n return hash(x)\n", + "hash = str\ndel hash\nhash = repr\nkey = hash('x')\n", + "def f(x):\n hash = str\n del hash\n return x\n", + # Deleting a local leaves it unbound (UnboundLocalError); the + # builtin stays hidden. + "def f():\n hash = str\n del hash\n return hash('x')\n", + # Nothing can call an undecorated function before its def binds it. + "def hash(x):\n return hash(x - 1) if x else 0\nhash(3)\n", + "def f(hash):\n return hash(1)\n", + "class A:\n def hash(self):\n return 1\n def m(self):\n" + " return self.hash()\n", + '"""np.random.seed is banned"""\nlabel = "np.random"\n', + "obj.random_seed\nobj.sample()\n", + "months = np.ceil(weeks * 12 / 52)\nposition = (month - 1 - start) % 12\n", + "flags = x ^ y\nbits = x >> 3\nbig = 0xFFFFFFFFFFFFFFFF\n", + "rank = person.get_rank(person.household, person_id)\n", + "import numpy as np\nnp.random_like = 1\nvalue = np.where(a, b, c)\n", + "def f():\n np = object()\n return np.random\n", + "xs = [random for random in range(3)]\n", + "xp = object()\nxp.random.uniform()\n", + "getattr(np, 'where')(a, b, c)\n", + "import importlib\nimportlib.import_module('numpy')\n", + "def helper(x):\n return x + 1\ndef f(x):\n return helper(x)\n", + # A binding sure to have run hides the builtin. + "hash = str\nkey = hash('x')\n", + "class A:\n hash = str\n key = hash('x')\n", + "def f():\n hash = str\n return hash('x')\n", + # An annotation reads a binding sure to have run before its + # statement; a deferred one in a class body also sees the class. + "hash = str\ndef f(x: hash('x')):\n pass\n", + "class C:\n hash = str\n def f(self, x: hash('x')):\n pass\n", + "hash = str\ny: hash('x') = 1\n", + # A statement's bindings count for the statements after it. + "hash = str\nkey = (\n hash('x')\n)\n", + "class A:\n hash = str\n key = (\n hash('x')\n )\n", + "def hash(x):\n return (\n hash(x - 1)\n ) if x else 0\nhash(1)\n", + # An annotated value binds; in a function an annotation alone makes + # the name local (UnboundLocalError), hiding the builtin. + "hash: object = str\nkey = hash('x')\n", + "def f():\n hash: object\n return hash('x')\n", + # The walrus value is the comprehension's own np, not numpy. + "import numpy as np\n[(lib := np) for np in [object()]]\nlib.random.uniform()\n", + # nonlocal stops at the nearest function binding the name. + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " lib = object()\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + # A walrus in a lambda's default binds where the comprehension runs, + # so in middle. + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " [(lambda x=(lib := None): x) for marker in ()]\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + ], +) +def test_detector_skips_deterministic_code(source): + assert draw_findings(source) == [] + + +# Python runs these when they are used, not where they are written, so a +# later deletion of a shadowing global counts; a list comprehension runs at +# once. Each needs the syntax of the Python 3 minor version given. +@pytest.mark.parametrize( + "source, reaches_builtin, minor", + _on_this_python( + [ + ("hash = str\ng = (hash(v) for v in 'x')\ndel hash\nlist(g)\n", True, 11), + ("hash = str\ng = (hash(v) for v in 'x')\nlist(g)\n", False, 11), + ("hash = str\nkeys = [hash(v) for v in 'x']\ndel hash\n", False, 11), + ("hash = str\ntype X = hash('x')\ndel hash\nX.__value__\n", True, 12), + ("hash = str\ntype X = hash('x')\nX.__value__\n", False, 12), + ( + "hash = str\n" + "type X[T: hash('x')] = list[T]\n" + "del hash\n" + "X.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "def f[T: hash('x')]():\n" + " pass\n" + "del hash\n" + "f.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "class C[T: hash('x')]:\n" + " pass\n" + "del hash\n" + "C.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "class C:\n" + " hash = str\n" + " def m[T: hash('x')](self):\n" + " pass\n" + " del hash\n" + "C.m.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "def f[T = hash('x')]():\n" + " pass\n" + "del hash\n" + "f.__type_params__[0].__default__\n", + True, + 13, + ), + ] + ), +) +def test_code_python_runs_when_used_sees_later_deletions( + source, reaches_builtin, minor +): + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in draw_findings(source)} == expected + + +# Each is the body of a function outer that binds hash = str. A class body +# that binds hash anywhere, even by an annotation alone, a del, or a later, +# conditional or failed binding, reads it from its own namespace, then the +# module's, so here the builtin; so does what the body evaluates, its +# methods' defaults and annotations and its first comprehension iterables +# included. Code nested in the class reads outer's. Each needs the syntax of +# the Python 3 minor version given. +@pytest.mark.parametrize( + "body, reaches_builtin, minor", + _on_this_python( + [ + (" class C:\n key = hash('x')\n hash = int\n", True, 11), + (" class C:\n hash: int\n key = hash('x')\n", True, 11), + ( + " class C:\n" + " if False:\n" + " hash = int\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " hash = int\n" + " del hash\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " raise ValueError\n" + " except ValueError as hash:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " del hash\n" + " except NameError:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " from no_such_module_anywhere import hash\n" + " except ImportError:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " y: hash('x')\n" + " C.__annotations__\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m(self, x: hash('x')):\n" + " pass\n" + " C.m.__annotations__\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m(self, x=hash('x')):\n" + " pass\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " keys = [v for v in [hash('x')]]\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m[T: hash('x')](self):\n" + " pass\n" + " C.m.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + " class C:\n" + " hash: int\n" + " type X = hash('x')\n" + " C.X.__value__\n", + True, + 12, + ), + # Past a nonlocal statement around the class. + ( + " def middle():\n" + " nonlocal hash\n" + " class C:\n" + " hash: int\n" + " key = hash('x')\n" + " middle()\n", + True, + 11, + ), + # The class binds hash first, or not at all, or the read is in code + # nested in the class: outer's str. + (" class C:\n hash = str\n key = hash('x')\n", False, 11), + (" class C:\n key = hash('x')\n", False, 11), + ( + " class C:\n" + " hash: int\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n", + False, + 11, + ), + ( + " class C:\n hash: int\n keys = [hash(v) for v in 'x']\n", + False, + 11, + ), + ] + ), +) +def test_a_class_body_that_binds_a_name_reads_it_from_the_module( + body, reaches_builtin, minor +): + findings = draw_findings("def outer():\n hash = str\n" + body) + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in findings} == expected + + +def test_a_reform_factory_class_reads_a_name_it_annotates_from_the_module(): + # An annotation alone makes lib local to the class body, which then + # reads numpy from the module, not the factory's None. + source = ( + "import numpy as lib\n" + "def create_reform():\n" + " lib = None\n" + " class reform_variable:\n" + " lib: object\n" + " value = lib.random.uniform()\n" + " return reform_variable\n" + ) + assert [kind for _, _, kind in draw_findings(source)] == ["random source"] + + +# A walrus binds only if it runs: in a comprehension it may run any number +# of times, in a generator expression only as it is iterated, past the first +# operand of and, or or a chained comparison only if those reach it, and in +# a conditional expression only on its branch. So these look further out, +# here to the builtin. A walrus where it is sure to run binds. +@pytest.mark.parametrize( + "source, reaches_builtin", + [ + ("[(hash := str) for _ in ()]\nkey = hash('x')\n", True), + ("g = ((hash := str) for _ in 'x')\nkey = hash('x')\nlist(g)\n", True), + ("[v for v in '' if (hash := str)]\nkey = hash('x')\n", True), + ("{(hash := str): v for v in ()}\nkey = hash('x')\n", True), + ("flag = False\nflag and (hash := str)\nkey = hash('x')\n", True), + ("flag = True\nflag or (hash := str)\nkey = hash('x')\n", True), + ("1 < 0 < (hash := str)\nkey = hash('x')\n", True), + ("(hash := str) if False else None\nkey = hash('x')\n", True), + ("(hash := str)\nkey = hash('x')\n", False), + ("(hash := str) and None\nkey = hash('x')\n", False), + ("None if (hash := str) else None\nkey = hash('x')\n", False), + ("[] == (hash := str)\nkey = hash('x')\n", False), + ], +) +def test_a_walrus_that_may_not_run_binds_nothing_surely(source, reaches_builtin): + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in draw_findings(source)} == expected + + +def test_a_walrus_in_an_assert_binds_nothing_surely(): + # python -O drops the assert. + source = "assert (hash := str)\nkey = hash('x')\n" + assert [kind for _, _, kind in draw_findings(source)] == ["builtin hash"] + + +# A type alias needs Python 3.12 syntax. +if sys.version_info >= (3, 12): + + def test_a_type_alias_binds_its_name(): + # The alias isn't callable, so Python raises before any builtin runs. + source = "type hash = str\ntry:\n hash('x')\nexcept TypeError:\n pass\n" + assert draw_findings(source) == [] + + +# Each is the body of a function outer that binds hash = str. A global +# statement in a function sends hash to the module, and so to the builtin, +# in all the code nested in that function, classes and their methods +# included. One in a class body does so in that body and what it evaluates +# (its annotations, and its methods' defaults and annotations), but not in +# the code nested in it. Python 3.11 to 3.14 read each the same way. +@pytest.mark.parametrize( + "body, reaches_builtin", + [ + ( + " def middle():\n" + " global hash\n" + " def f(x: hash('x')):\n" + " pass\n" + " f.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f() -> hash('x'):\n" + " pass\n" + " f.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f(x=hash('x')):\n" + " pass\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " (lambda: hash('x'))()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " [hash(v) for v in 'x']\n" + " list(hash(v) for v in 'x')\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " key = hash('x')\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " y: hash('x') = 1\n" + " C.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def inner():\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " inner()\n" + " middle()\n", + True, + ), + # inner's global skips middle's binding too. + ( + " def middle():\n" + " hash = str\n" + " def inner():\n" + " global hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " inner()\n" + " middle()\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " key = hash('x')\n" + " keys = [v for v in [hash('x')]]\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " y: hash('x')\n" + " C.__annotations__\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " def m(self, x: hash('x')) -> hash('y'):\n" + " pass\n" + " C.m.__annotations__\n", + True, + ), + # Not in the code nested in a class body. + ( + " class C:\n" + " global hash\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " keys = [hash(v) for v in 'x']\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " class D:\n" + " key = hash('x')\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " y: (lambda: hash('x'))()\n" + " C.__annotations__\n", + False, + ), + # nonlocal names outer's hash; a nested binding is the nested code's. + ( + " def middle():\n" + " nonlocal hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + False, + ), + ( + " def middle():\n" + " global hash\n" + " def f():\n" + " hash = str\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + False, + ), + ], +) +def test_global_and_nonlocal_reach_the_code_nested_in_their_scope( + body, reaches_builtin +): + findings = draw_findings("def outer():\n hash = str\n" + body) + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in findings} == expected + + +def test_draw_helpers_are_followed_through_calls(): + source = ( + "def mix(x):\n" + " return x ^ (x >> 3)\n" + "def month(x):\n" + " return mix(x) % 12\n" + "def formula(x):\n" + " return month(x) + 1\n" + ) + found = {(scope, kind) for _, scope, kind in draw_findings(source)} + assert ("month", "draw helper call") in found + assert ("formula", "draw helper call") in found + # A function whose findings are exempt is not a draw helper. + exempt = {("mix", "xorshift"): 1} + found = {(scope, kind) for _, scope, kind in draw_findings(source, exempt=exempt)} + assert found == {("mix", "xorshift")} + + +def test_allowlist_counts_occurrences_per_scope(): + source = ( + "def first():\n" + " return np.random.random()\n" + "def second():\n" + " return np.random.random()\n" + ) + counted = count_findings({"m.py": draw_findings(source)}) + one = {"m.py": {("first", "random source"): 1}} + # An occurrence in a new function of an allowlisted file fails. + violations, stale = check_allowlists(counted, one, {}) + assert len(violations) == 1 and "second" in violations[0] and not stale + # A second occurrence in an allowlisted scope fails. + twice = count_findings({"m.py": draw_findings(source.replace("second", "first"))}) + violations, _ = check_allowlists(twice, one, {}) + assert violations and "2 found, 1 allowed" in violations[0] + # An entry that allows more than it matches is stale. + _, stale = check_allowlists(counted, {"m.py": {("first", "random source"): 2}}, {}) + assert stale and "2 allowed, 1 found" in stale[0] + + +def test_reusing_an_allowed_hash_or_draw_spends_an_occurrence(): + checksum = ( + "import hashlib\n" + "def checksum(data):\n" + " return hashlib.sha256(data).hexdigest()\n" + ) + exempt = {("", "hash library"): 1, ("checksum", "hash use"): 1} + + def check(source, ratchet=None): + findings = draw_findings(source, "m.py", exempt=exempt) + counted = count_findings({"m.py": findings}) + return check_allowlists(counted, {"m.py": ratchet or {}}, {"m.py": exempt}) + + assert check(checksum) == ([], []) + # A new function hashing ids through the exempt import fails. + reuse = checksum + ( + "def draw(person_id):\n" + " return hashlib.sha256(bytes(person_id)).digest()[0] % 12\n" + ) + violations, _ = check(reuse) + assert any("in draw: hash use" in violation for violation in violations) + # So does a new call of a ratcheted draw. + legacy = checksum + ( + "def legacy(x):\n" + " return x ^ (x >> 3)\n" + "def stored(x):\n" + " return legacy(x)\n" + ) + ratchet = {("legacy", "xorshift"): 1, ("stored", "draw helper call"): 1} + assert check(legacy, ratchet) == ([], []) + violations, _ = check(legacy + "def more(x):\n return legacy(x)\n", ratchet) + assert any("in more: draw helper call" in violation for violation in violations) + + +def _package_findings(sources: dict, order=None, exempt=None) -> dict: + """{module: findings} of a package ``pkg`` given {module: source}. + + ``order`` is the order the modules are analysed in. + """ + modules = Modules( + { + f"pkg.{name}": ("pkg", lambda source=source: source) + for name, source in sources.items() + } + ) + analyses = { + name: analyse(sources[name], f"{name}.py", f"pkg.{name}", "pkg", modules) + for name in order or sources + } + helpers = draw_helpers(analyses, exempt or {}) + return { + name: sorted(analysis.findings + helper_calls(analysis, helpers)) + for name, analysis in analyses.items() + } + + +def _package_kinds(sources: dict, module: str = "user") -> list: + return [kind for _, _, kind in _package_findings(sources)[module]] + + +def test_named_imports_and_reexports_resolve_to_what_they_name(): + api = "import numpy as lib\nfrom numpy import random as draws\n" + user = ( + "from .api import lib\n" + "lib.random.uniform()\n" + "from .api import draws as d\n" + "d.uniform()\n" + ) + assert _package_kinds({"api": api, "user": user}) == ["random source"] * 2 + # A draw helper re-exported by name is still a draw helper. + helper = "def mix(x):\n return x ^ (x >> 3)\n" + found = _package_findings( + { + "a": helper, + "b": "from .a import mix\n", + "c": "from .b import mix\ndef consumer(x):\n return mix(x)\n", + } + ) + assert ("consumer", "draw helper call") in {(s, k) for _, s, k in found["c"]} + + +def test_module_attributes_resolve_to_what_the_module_binds(): + # A name passed on as an attribute of another module. + sources = { + "api": "import numpy as lib\n", + "bridge": "from . import api\nlib = api.lib\n", + "user": "from .bridge import lib\nlib.random.uniform()\n", + } + assert _package_kinds(sources) == ["random source"] + # A draw helper reached as an attribute of a module that re-exports it. + found = _package_findings( + { + "a": "def mix(x):\n return x ^ (x >> 3)\n", + "b": "from .a import mix\n", + "c": "from . import b\ndef consumer(x):\n return b.mix(x)\n", + } + ) + assert ("consumer", "draw helper call") in {(s, k) for _, s, k in found["c"]} + + +def test_a_package_that_rebinds_its_submodules_name_resolves(): + # Both __init__ files bind w to the function pkg.w.w defines, so the + # attribute pkg.w is that function, though "pkg.w" also names a package. + sources = { + "pkg": ("pkg", "from .w import w\n"), + "pkg.w": ("pkg.w", "from .w import w\n"), + "pkg.w.w": ("pkg.w", "def w(x):\n return x ^ (x >> 3)\n"), + "pkg.user": ("pkg", "import pkg\ndef consumer(x):\n return pkg.w(x)\n"), + } + # This resolution needs depth 4. A low limit makes one that expands + # pkg.w.w.w into ever longer paths fail fast, with a recorded cut, + # instead of searching for minutes. + modules = Modules( + { + module: (package, lambda source=source: source) + for module, (package, source) in sources.items() + }, + max_depth=5, + ) + analyses = { + module: analyse(source, f"{module}.py", module, package, modules) + for module, (package, source) in sources.items() + } + helpers = draw_helpers(analyses, {}) + assert "pkg.w.w.w" in helpers + calls = helper_calls(analyses["pkg.user"], helpers) + assert [(scope, kind) for _, scope, kind in calls] == [ + ("consumer", "draw helper call") + ] + assert not modules.exhausted + + +def test_lookups_cut_by_the_depth_limit_are_recorded(): + # lib passes along 12 re-exports, each read where it arrives. From the + # consumer end, its lookup nests past a limit of 4 and is cut; from the + # numpy end, kept results keep each lookup shallow. A cut is recorded, so + # the package scan fails instead of depending on the order. + sources = {"m0": "import numpy as lib\n"} + sources |= {f"m{i}": f"from .m{i - 1} import lib\nlib\n" for i in range(1, 13)} + sources["user"] = "from .m12 import lib\nlib.random.uniform()\n" + consumer_first = list(reversed(sources)) + for order, max_depth, cut in [ + (consumer_first, 4, True), + (list(sources), 4, False), + (consumer_first, MAX_RESOLUTION_DEPTH, False), + ]: + modules = Modules( + { + f"pkg.{name}": ("pkg", lambda source=source: source) + for name, source in sources.items() + }, + max_depth=max_depth, + ) + analyses = { + name: analyse(sources[name], f"{name}.py", f"pkg.{name}", "pkg", modules) + for name in order + } + assert bool(modules.exhausted) == cut + if not cut: + kinds = [kind for _, _, kind in analyses["user"].findings] + assert kinds == ["random source"] + + +@pytest.mark.parametrize( + "api", + [ + "import numpy as lib\nleft, right = lib, 1\n", + "import numpy as _lib\n__all__ = ['left'] + []\nleft = _lib\n", + "import numpy as _lib\n__all__ = []\n__all__.append('left')\nleft = _lib\n", + "import numpy as _lib\n__all__ = ('x',)\n__all__ += ['left']\nleft = _lib\n", + "import numpy as _lib\n__all__ = names()\nleft = _lib\n", + ], +) +def test_star_imports_bind_what_the_module_exports(api): + user = "from .api import *\nleft.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +def test_star_imports_bind_private_names_only_through_all(): + user = "from .api import *\n_lib.random.uniform()\n" + assert _package_kinds({"api": "import numpy as _lib\n", "user": user}) == [] + api = "import numpy as _lib\n__all__ = ['_lib']\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +def test_star_imports_see_an_earlier_all_in_a_cycle(): + # Importing api first runs user while __all__ still lists lib. + api = "import numpy as lib\n__all__ = ['lib']\nfrom . import user\n__all__ = []\n" + user = "from .api import *\nlib.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + # Or before api sets __all__, when every public name it has bound is + # exported. + api = "import numpy as lib\nfrom . import user\n__all__ = []\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + # A private name is exported only while an earlier __all__ lists it. + api = "import numpy as _lib\n__all__ = ['_lib']\nfrom . import user\n__all__ = []\n" + user = "from .api import *\n_lib.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +@pytest.mark.parametrize( + "api", + [ + # Importing api first runs user before __all__ lists hash, or before + # api binds it. + "__all__ = []\nfrom . import user\nhash = str\n__all__ = ['hash']\n", + "hash = str\n__all__ = []\nfrom . import user\n__all__ = ['hash']\n", + "from . import user\nhash = str\n", + # Through another module, a package's __init__, a function that + # imports, or a literal dynamic import. + "from . import other\nhash = str\n", + "import pkg.sub.leaf\nhash = str\n", + "def load():\n from . import user\nload()\nhash = str\n", + "import importlib\nimportlib.import_module('pkg.user')\nhash = str\n", + ], +) +def test_star_imports_in_an_import_cycle_bind_nothing_surely(api): + sources = { + "api": api, + "other": "from . import user\n", + "sub": "from pkg import user\n", + "sub.leaf": "", + "user": "from .api import *\nkey = hash('x')\n", + } + assert _package_kinds(sources) == ["builtin hash"] + # A module's star import of itself sees itself part-run too. + itself = {"user": "from .user import *\nkey = hash('x')\nhash = str\n"} + assert _package_kinds(itself) == ["builtin hash"] + + +def _modules_of(sources: dict) -> Modules: + """Modules for {module: source}; a module with submodules is a package.""" + packages = { + module: module + if any(other.startswith(module + ".") for other in sources) + else module.rsplit(".", 1)[0] + for module in sources + } + return Modules( + { + module: (packages[module], lambda source=source: source) + for module, source in sources.items() + } + ) + + +def _assert_users_find(sources: dict, reaches_builtin: bool): + """Each module that reads ``hash('x')`` finds the builtin, or not, in + every order the modules are analysed in.""" + users = [module for module, source in sources.items() if "hash('x')" in source] + expected = ["builtin hash"] if reaches_builtin else [] + for order in permutations(sources): + modules = _modules_of(sources) + analyses = { + module: analyse( + sources[module], + f"{module}.py", + module, + modules.sources[module][0], + modules, + ) + for module in order + } + for user in users: + assert [kind for _, _, kind in analyses[user].findings] == expected + + +# Some import order starts user while api is part-run: through the modules +# a star import of a package loads (what its __all__ lists, in any version +# of it, dotted names too, with the packages on the way, or every module +# below it where __all__ is built otherwise), or a dynamic import under +# another name or relative to the package its arguments give. In the +# controls no order does: without __all__ a star import of a package loads +# none of its submodules, one of the package around the importer starts +# nothing, as that has run, a dotted name loads only what it names, and a +# relative import's own package is the one its globals give. +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + ( + { + "pkg": "__all__ = ['user']\n", + "pkg.api": "from . import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['leaf']\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "from pkg.sub import *\nhash = str\n", + }, + True, + ), + # Imported first, pkg.sub imports api while its __all__ lists leaf. + ( + { + "pkg.sub": "__all__ = ['leaf']\nfrom .. import api\n__all__ = []\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "names = ['leaf']\n__all__ = list(names)\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.api": "from importlib import import_module as load\n" + "load('pkg.user')\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.api": "import importlib\n" + "getattr(importlib, 'import_module')('pkg.user')\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + False, + ), + # pkg.sub started before api, so api's star import of pkg starts no + # module again. Python cannot bind sub, part-run, and api catches it. + ( + { + "pkg": "__all__ = ['sub']\n", + "pkg.sub": "from . import user\n", + "pkg.sub.api": "try:\n" + " from .. import *\n" + "except AttributeError:\n" + " pass\n" + "hash = str\n", + "pkg.sub.user": "from .api import *\nkey = hash('x')\n", + }, + False, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "from builtins import __import__ as load\n" + "load('pkg.sub', fromlist=('*',))\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', globals(), None, ['*'], 1)\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist='*')\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + False, + ), + ( + { + "pkg.sub": "__all__ = ['branch.user']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + # Python loads pkg.sub.branch.user, then fails to bind 'branch.user'. + ( + { + "pkg.sub": "__all__ = ['branch.user']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "try:\n" + " from .sub import *\n" + "except AttributeError:\n" + " pass\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['branch.leaf']\n", + "pkg.sub.branch": "from . import user\n", + "pkg.sub.branch.leaf": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "names = ['branch.user']\n__all__ = list(names)\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['branch.other']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.other": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + False, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', {'__package__': 'pkg.other'}, None, ['*'], 1)\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', {'__name__': 'pkg.other.mod'}, None, ['*'], 1)\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub', {'__name__': 'pkg.other', '__path__': []}, None, ['*'], 1\n" + ")\n" + "hash = str\n", + }, + True, + ), + # The last of a repeated key wins: __package__ is None, so __name__ + # gives the package. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub',\n" + " {'__package__': 'pkg', '__package__': None, '__name__': 'pkg.other.mod'},\n" + " None,\n" + " ['*'],\n" + " 1,\n" + ")\n" + "hash = str\n", + }, + True, + ), + # A dict fromlist is iterated by key. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={'*': None})\nhash = str\n", + }, + True, + ), + # And so are a literal ``**`` or ``*`` entry's own items. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={**{'*': None}})\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist=[*['*']])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub', {**{'__package__': 'pkg.other'}}, None, ['*'], 1\n" + ")\n" + "hash = str\n", + }, + True, + ), + # An empty one names nothing: no star load, so user never runs. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={**{}})\nhash = str\n", + }, + False, + ), + ( + { + "pkg.api": "import importlib\n" + "importlib.import_module('.user', __package__)\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.other": "", + "pkg.other.sub": "__all__ = []\n", + "pkg.api": "__import__('sub', {'__package__': 'pkg.other'}, None, ['*'], 1)\n" + "hash = str\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_star_loads_and_renamed_imports( + sources, reaches_builtin +): + _assert_users_find({"pkg": ""} | sources, reaches_builtin) + + +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + ( + { + "pkg": "def load():\n from . import user\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "from .helpers import go\ndef load():\n go()\n", + "pkg.helpers": "def go():\n from . import user\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "def load():\n from .mid import user\n", + "pkg.mid": "", + "pkg.mid.api": "from pkg import load\nload()\nhash = str\n", + "pkg.mid.user": "from pkg.mid.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "def load():\n __import__('pkg.sub', fromlist=['*'])\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "load = lambda: __import__('pkg.user')\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "loads = (__import__('pkg.user') for _ in [0])\n", + "pkg.api": "from pkg import loads\nnext(loads)\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "class Loader:\n" + " def load(self):\n" + " from . import user\n", + "pkg.api": "from pkg import Loader\nLoader().load()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "from .sub import *\n", + "pkg.sub": "__all__ = ['helpers']\n", + "pkg.sub.helpers": "def go():\n from .. import user\n", + "pkg.api": "from pkg import helpers\nhelpers.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # pkg ran its import of other before api started; nothing api may + # call imports user. + ( + { + "pkg": "from . import other\ndef load():\n return other\n", + "pkg.other": "VALUE = 1\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + # pkg.mid ran, with its import of user, before api started: a + # function's import of it starts nothing. + ( + { + "pkg": "def load():\n from . import mid\n", + "pkg.mid": "from . import user\n", + "pkg.mid.api": "from .. import load\nhash = str\n", + "pkg.mid.user": "from pkg.mid.api import *\nkey = hash('x')\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_code_the_packages_around_may_run( + sources, reaches_builtin +): + # The packages around a module, and what they imported as they ran, have + # run before it, but its code may call their functions: an import in + # one is an edge. + _assert_users_find(sources, reaches_builtin) + + +# main loads pkg.helper, then api reaches it without an import, as an +# attribute of its package, and calls code in it that starts user while api +# is part-run. +@pytest.mark.parametrize( + "api, helper", + [ + ("import pkg\npkg.helper.go()\n", None), + ( + "import pkg\npkg.helper.trigger\n", + "def __getattr__(name):\n from . import user\n", + ), + ("import pkg as p\np.helper.go()\n", None), + ("import pkg\ngetattr(pkg, 'helper').go()\n", None), + ("import sys\nsys.modules['pkg.helper'].go()\n", None), + ("import sys\nsys.modules.get('pkg.helper').go()\n", None), + ("__import__('pkg').helper.go()\n", None), + ("from pkg import *\nhelper.go()\n", None), + ], +) +def test_star_imports_see_cycles_through_modules_reached_without_an_import(api, helper): + sources = { + "pkg": "", + "pkg.helper": helper or "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": api + "hash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + } + _assert_users_find(sources, True) + + +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + # An alias, assigned, of a package whose submodule main loaded. + ( + { + "pkg.sub": "", + "pkg.sub.helper": "def go():\n from .. import user\n", + "pkg.main": "import pkg.sub.helper\nimport pkg.api\n", + "pkg.api": "import pkg\nh = pkg.sub\nh.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # An alias bound to a path below itself. + ( + { + "pkg.sub": "", + "pkg.sub.helper": "def go():\n from .. import user\n", + "pkg.main": "import pkg.sub.helper\nimport pkg.api\n", + "pkg.api": "import pkg\nh = pkg\nh = h.sub\nh.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # A function in a module api imports reaches it. + ( + { + "pkg.helper": "def go():\n from . import user\n", + "pkg.x": "import pkg\ndef run():\n pkg.helper.go()\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "from pkg.x import run\nrun()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # So does a function in the package around api, by its own name for + # its submodule. + ( + { + "pkg": "def load():\n helper.go()\n", + "pkg.helper": "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # The code api reaches starts nothing, or nothing that starts user. + ( + { + "pkg": "from . import helper\n", + "pkg.helper": "def go():\n return 1\n", + "pkg.api": "import pkg\npkg.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + ( + { + "pkg": "from . import helper\n", + "pkg.helper": "def go():\n from . import other\n", + "pkg.other": "VALUE = 1\n", + "pkg.api": "import pkg\npkg.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + # A plain module's bare name is its own, not its package's submodule. + ( + { + "pkg.helper": "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "def helper():\n pass\nhelper()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_modules_reached_by_aliases_and_functions( + sources, reaches_builtin +): + _assert_users_find({"pkg": ""} | sources, reaches_builtin) + + +def test_a_computed_all_may_name_any_module_below_its_package(): + # __all__ holds nothing when this one runs, but a computed __all__ may + # hold a dotted name, so a star import of the package may load any module + # below it, and the scan errs toward reporting. + sources = { + "pkg": "__all__ = list([])\n", + "pkg.sub": "from . import user\n", + "pkg.sub.api": "from .. import *\nhash = str\n", + "pkg.sub.user": "from .api import *\nkey = hash('x')\n", + } + _assert_users_find(sources, True) + + +@pytest.mark.parametrize( + "source, run, later", + [ + ("import pkg\npkg.a.f()\n", {"pkg.a"}, set()), + ("import pkg\ndef f():\n pkg.a.g()\n", set(), {"pkg.a"}), + ("import pkg as p\nx = p.a\nx.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ("from pkg import a as b\nb.c.f()\n", {"pkg.a", "pkg.a.c"}, set()), + ("import pkg\ng = getattr(pkg, 'a')\n", {"pkg.a"}, set()), + ("import sys\nsys.modules['pkg.a'].f()\n", {"pkg.a"}, set()), + ("from sys import modules\nmodules.get('pkg.a').f()\n", {"pkg.a"}, set()), + ("f = lambda: __import__('pkg').a.f()\n", set(), {"pkg.a"}), + # __import__ returns the top package; import_module the module. + ("__import__('pkg.sub').a.f()\n", {"pkg.a", "pkg.sub.a"}, set()), + ("from pkg.sub import *\na.f()\n", {"pkg.sub.a"}, set()), + ("pkg.a.f()\n", {"pkg.a"}, set()), + # A name bound to a path below itself. + ("import pkg\nx = pkg\nx = x.a\nx.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ("import pkg\nx = pkg.a\ny = x.b\nx = y\nx.f()\n", {"pkg.a", "pkg.a.b"}, set()), + # y's binding reads x's, bound after it. + ( + "import pkg\nfor i in range(2):\n y = x if i else pkg\n x = y.a\n" + "y.b.f()\n", + {"pkg.a", "pkg.a.b"}, + set(), + ), + # Either branch, inline or bound. + ( + "import pkg\nh = pkg.a if c else None\nh.b.f()\n", + {"pkg.a", "pkg.a.b"}, + set(), + ), + ("import pkg\nh = c or pkg.a\nh.c.f()\n", {"pkg.a", "pkg.a.c"}, set()), + ("import pkg\nh: object = pkg.a\nh.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ( + "import pkg\n(h := pkg.a).b.f()\nh.c\n", + {"pkg.a", "pkg.a.b", "pkg.a.c"}, + set(), + ), + # A plain module's bare name is its own, not its package's submodule. + ("a.f()\n", set(), set()), + ], +) +def test_references_are_split_by_when_they_may_run(source, run, later): + tree = ast.parse(source) + (_, run_stars), (_, later_stars) = _imports_in(tree, "pkg", "pkg.m") + modules = {"pkg.a", "pkg.a.b", "pkg.a.c", "pkg.sub.a"} + known = {path for module in modules for path in _started(module)} + found = _references_in(tree, "pkg", run_stars | later_stars, known, "pkg.m") + assert (found[0] & modules, found[1] & modules) == (run, later) + + +@pytest.mark.parametrize( + "source", + [ + "import pkg\npkg.a.f()\nx.y.z\n", + "import pkg as p\nq = p.a\nq.b.c.f()\nimport os\nos.path.join('a')\n", + "import sys\nsys.modules['pkg.a'].f()\nsys.modules['os'].sep\ngetattr(o, 'n')\n", + ], +) +def test_reference_paths_stay_within_the_known_paths(source): + # Only paths a package module may yet be named from, and the names the + # rules read, are kept, so the binding fixpoint has finitely many paths + # to add and ends. + modules = {"pkg.a", "pkg.a.b"} + known = {path for module in modules for path in _started(module)} + run, later = _references_in(ast.parse(source), "pkg", set(), known, "pkg.m") + assert run | later <= known | REFERENCE_NAMES + + +# What Python imports for each call, read from its arguments. +@pytest.mark.parametrize( + "call, imported", + [ + ("__import__('sub', globals(), None, ['x'], 1)", ["pkg.sub", "pkg.sub.x"]), + ( + "__import__('sub', {'__package__': 'pkg.other'}, None, (), 1)", + ["pkg.other.sub"], + ), + ( + "__import__('sub', {'__package__': None, '__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {'__name__': 'pkg.o', '__path__': None}, level=1)", + ["pkg.o.sub"], + ), + ("__import__('sub', {'__name__': 'pkg.o.m'}, level=2)", ["pkg.sub"]), + # The last of a repeated key wins, and None then reads as absent. + ( + "__import__('sub', {'__package__': 'pkg', '__package__': None, " + "'__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {'__package__': None, '__package__': 'pkg.o'}, level=1)", + ["pkg.o.sub"], + ), + ("__import__('sub', {'__name__': 'pkg.o.m', '__name__': None}, level=1)", []), + ("__import__('sub', globals(), None, {'x': 0}, 1)", ["pkg.sub", "pkg.sub.x"]), + # Unpacked literal entries are read in place; computed ones give none. + ( + "__import__('sub', globals(), None, {**{'x': 0}}, 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ( + "__import__('sub', globals(), None, [*computed, 'x'], 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ("__import__('sub', {**{'__package__': 'pkg.o'}}, level=1)", ["pkg.o.sub"]), + ( + "__import__('sub', {'__package__': 'pkg', **{'__package__': None}, " + "'__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {**{'__package__': 'pkg.o'}, **other}, level=1)", + ["pkg.sub"], + ), + ("__import__('sub', {1: 0, '__package__': 'pkg.o'}, level=1)", ["pkg.o.sub"]), + # At any depth. + ( + "__import__('sub', globals(), None, {**{**{'x': 0}}}, 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ( + "__import__('sub', globals(), None, [*[*['x']]], 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ("__import__('sub', {**{**{'__package__': 'pkg.o'}}}, level=1)", ["pkg.o.sub"]), + ( + "__import__('sub', {'__spec__': spec, '__name__': 'x.y'}, level=1)", + ["pkg.sub"], + ), + ("__import__('sub', {'__package__': computed}, level=1)", ["pkg.sub"]), + ("__import__('sub', {'__name__': name}, level=1)", ["pkg.sub"]), + ("__import__('sub', {**other}, level=1)", ["pkg.sub"]), + ("__import__('sub', mapping, level=1)", ["pkg.sub"]), + ("__import__('sub', {}, level=1)", []), + ("import_module('.sub', __package__)", ["pkg.sub"]), + ("import_module('.sub', package=__spec__.parent)", ["pkg.sub"]), + ("import_module('.sub', __name__)", ["pkg.m.sub"]), + ("import_module('..sub', 'pkg.o')", ["pkg.sub"]), + ("import_module('.sub', computed)", ["pkg.sub"]), + ("import_module('.sub')", []), + ("import_module('.sub', None)", []), + ], +) +def test_relative_dynamic_imports_resolve_as_python_reads_them(call, imported): + node = ast.parse(call, mode="eval").body + names = _dynamic_imports(node, DYNAMIC_IMPORTS, "pkg", "pkg.m") + assert [name for name in names if name.startswith("pkg.")] == imported + + +@pytest.mark.parametrize( + "source, run, later", + [ + ("import pkg.a\nclass C:\n import pkg.b\n", {"pkg.a", "pkg.b"}, set()), + ( + "if x:\n import pkg.a\ntry:\n import pkg.b\nexcept E:\n pass\n", + {"pkg.a", "pkg.b"}, + set(), + ), + ("def f():\n import pkg.a\n", set(), {"pkg.a"}), + ("async def f():\n import pkg.a\n", set(), {"pkg.a"}), + ("def f():\n if x:\n import pkg.a\n", set(), {"pkg.a"}), + ("class C:\n def m(self):\n import pkg.a\n", set(), {"pkg.a"}), + ("f = lambda: __import__('pkg.a')\n", set(), {"pkg.a"}), + ("g = (__import__('pkg.a') for _ in [0])\n", set(), {"pkg.a"}), + ("x: __import__('pkg.a')\n", set(), {"pkg.a"}), + ("def f(x: __import__('pkg.a')): pass\n", set(), {"pkg.a"}), + ("@__import__('pkg.a')\ndef f(): pass\n", set(), {"pkg.a"}), + ], +) +def test_imports_are_split_by_when_they_may_run(source, run, later): + (run_names, _), (later_names, _) = _imports_in(ast.parse(source), "pkg") + assert (run_names, later_names) == (run, later) + + +# Each needs the syntax of the Python 3 minor version given, and none parses +# before 3.12. +if sys.version_info >= (3, 12): + + @pytest.mark.parametrize( + "minor, source", + _on_this_python( + [ + (12, "class C[T: __import__('pkg.a')]:\n pass\n"), + (12, "type Alias = __import__('pkg.a')\n"), + (13, "def f[T = __import__('pkg.a')](): pass\n"), + ], + minor=0, + ), + ) + def test_type_parameters_and_aliases_may_run_later(minor, source): + (run_names, _), (later_names, _) = _imports_in(ast.parse(source), "pkg") + assert (run_names, later_names) == (set(), {"pkg.a"}) + + +def test_imports_in_name_star_bases_by_when_they_may_run(): + source = ( + "from .sub import *\n" + "__import__('pkg.top', fromlist=['*'])\n" + "def f():\n __import__('pkg.inner', fromlist='*')\n" + ) + (_, run_stars), (_, later_stars) = _imports_in(ast.parse(source), "pkg") + assert (run_stars, later_stars) == ({"pkg.sub", "pkg.top"}, {"pkg.inner"}) + + +def test_star_imports_see_all_of_a_module_whose_package_imports_the_importer(): + # Importing pkg.api starts pkg first, which runs user, whose star import + # then runs all of api: api's import of pkg starts nothing. + sources = { + "pkg": ("pkg", "from . import user\n"), + "pkg.api": ("pkg", "import pkg\nhash = str\n"), + "pkg.user": ("pkg", "from .api import *\nkey = hash('x')\n"), + } + modules = Modules( + { + module: (package, lambda source=source: source) + for module, (package, source) in sources.items() + } + ) + for module, (package, source) in sources.items(): + assert analyse(source, f"{module}.py", module, package, modules).findings == [] + + +@pytest.mark.parametrize( + "api", + [ + # An earlier __all__, which a module imported in a cycle sees. + "hash = str\n__all__ = ['hash']\nfrom . import user\n__all__ = []\n", + "hash = str\n__all__ = ['hash']\n__all__.remove('hash')\n", + "hash = str\n__all__ = ['hash']\n__all__.remove(name)\n", + "if flag:\n hash = str\n", + "hash = str\ndel hash\n", + "hash = str\n__all__ = names()\n", + ], +) +def test_star_imports_may_not_bind_some_names(api): + # So the importer looks further out, here to the builtin. + user = "from .api import *\nkey = hash('x')\n" + assert _package_kinds({"api": api, "user": user}) == ["builtin hash"] + + +@pytest.mark.parametrize( + "api", + [ + "hash = str\n", + "hash = str\n__all__ = ['hash']\n", + "from .other import hash\n", + # other doesn't import user. + "from . import other\nhash = str\n", + ], +) +def test_star_imports_bind_what_the_module_surely_exports(api): + user = "from .api import *\nkey = hash('x')\n" + sources = {"api": api, "other": "hash = str\n", "user": user} + assert _package_kinds(sources) == [] + + +def test_cyclic_star_imports_resolve_in_any_order(): + sources = { + "a": "from .b import *\nimport numpy as lib\n", + "b": "from .a import *\n", + "user": "from .b import *\nlib.random.uniform()\n", + } + results = [ + _package_findings(sources, order) + for order in (["a", "b", "user"], ["user", "b", "a"], ["b", "user", "a"]) + ] + assert results[0] == results[1] == results[2] + assert [kind for _, _, kind in results[0]["user"]] == ["random source"] + + +@pytest.mark.parametrize( + "sources", + [ + { + "a": "if flag:\n import numpy as lib\nelse:\n from .b import lib\n", + "b": "from .a import lib\n", + "other": "from .a import lib\nlib.random.uniform()\n", + "user": "from .b import lib\nlib.random.uniform()\n", + }, + # From via_a, a.lib leads to b.lib, then a.lib again, then b.lib, + # which is cut: so that inner a.lib has none of b's numpy. Keeping + # that partial answer would hide numpy from via_c, which reaches it + # only through a.lib, whenever via_a is analysed first. + { + "a": "from .b import lib\n", + "b": "if flag:\n from .a import lib\nelse:\n import numpy as lib\n", + "c": "from .a import lib\n", + "via_a": "from .a import lib\nlib.random.uniform()\n", + "via_c": "from .c import lib\nlib.random.uniform()\n", + }, + ], +) +def test_cyclic_named_imports_resolve_in_any_order(sources): + results = [ + _package_findings(sources, list(order)) for order in permutations(sources) + ] + assert all(result == results[0] for result in results) + for module, source in sources.items(): + if "lib.random" in source: + assert [kind for _, _, kind in results[0][module]] == ["random source"] + + +def test_model_api_passes_on_cores_numpy_and_its_own_helpers(): + modules = package_scan().modules + user = "policyengine_uk.variables.gov.dwp.tax_credits_current_year_income" + assert "numpy" in modules.star_binds("policyengine_uk.model_api", "np", user) + assert modules.star_binds_surely("policyengine_uk.model_api", "np", user) + for api in ("policyengine_uk.model_api", "policyengine_core.model_api"): + assert "numpy.random.uniform" in modules.expand(f"{api}.np.random.uniform") + assert "policyengine_uk.utils.benefit_unit.add_for_members" in modules.star_binds( + "policyengine_uk.model_api", "add_for_members", user + ) + + +def test_splitmix64_hash_and_its_uprating_caller_are_draw_helpers(): + helpers = package_scan().helpers + assert "policyengine_uk.utils.stochastic.splitmix64_uniform" in helpers + assert ( + "policyengine_uk.data.economic_assumptions.uprate_student_loan_plans" in helpers + ) + assert "policyengine_uk.build_metadata.get_data_build_fingerprint" not in helpers + + +def test_package_draws_nothing_outside_the_allowlists(): + violations, _ = check_allowlists(package_scan().counts, RATCHET, EXEMPT) + assert not violations, ( + "The model must not draw, sample, shuffle or hash entities to a " + "pseudo-random value while a simulation is built or run " + "(policyengine-core#500). Draw in the dataset build and store the " + "draw as an input variable; the formula then compares it with the " + "rule, so a rate stays reformable. Draw patterns found:\n" + + "\n".join(violations) + ) + + +def test_allowlist_entries_still_match(): + _, stale = check_allowlists(package_scan().counts, RATCHET, EXEMPT) + assert not stale, ( + "These entries allow more than the package now has. Lower or delete " + "them, and lower RATCHET_CEILING to match RATCHET:\n" + "\n".join(stale) + ) + + +def test_package_lookups_stay_within_the_depth_limit(): + exhausted = package_scan().modules.exhausted + assert not exhausted, ( + f"{len(exhausted)} name lookups nested deeper than MAX_RESOLUTION_DEPTH " + "and were cut, so what they found may depend on the order modules " + "were scanned in. Raise the limit. One cut: " + repr(next(iter(exhausted))) + ) + + +def test_ratchet_only_shrinks(): + total = sum(sum(entries.values()) for entries in RATCHET.values()) + assert total <= RATCHET_CEILING, ( + "RATCHET lists engine-time draws still to move into the data. Draw " + "a new stochastic input in the dataset build instead of adding it." + ) + + +def _is_offline(name: str, offline=OFFLINE_MODULES) -> bool: + return _matches(name, offline) + + +# An offline package the import check tests against; the package has none. +EXAMPLE_OFFLINE_MODULES = {"policyengine_uk.tools.offline"} + + +@pytest.mark.parametrize( + "source", + [ + "import policyengine_uk.tools.offline.generate_tests\n", + "from policyengine_uk.tools.offline import generate_tests\n", + "from .offline import generate_tests\n", + "import importlib\n" + "importlib.import_module('policyengine_uk.tools.offline.generate_tests')\n", + "from importlib import import_module as load\n" + "load('.offline', package='policyengine_uk.tools')\n", + "__import__('policyengine_uk.tools.offline')\n", + "import importlib\n" + "importlib.import_module(name='policyengine_uk.tools.offline')\n", + "import importlib\nimportlib.import_module('.offline', 'policyengine_uk.tools')\n", + "__import__('policyengine_uk.tools', fromlist=['offline'])\n", + "__import__('offline', globals(), locals(), [], 1)\n", + ], +) +def test_offline_import_check_sees_each_import_form(source): + imports = analyse(source, package="policyengine_uk.tools").imports + assert any(_is_offline(name, EXAMPLE_OFFLINE_MODULES) for _, name in imports) + + +@pytest.mark.parametrize( + "source, offline", + [ + ( + "from importlib import import_module\nimport_module('..offline', __name__)\n", + True, + ), + ("__import__('offline', {'__name__': __name__}, None, [], 1)\n", True), + # helper is a module, not a package: Python looks for helper.offline. + ( + "from importlib import import_module\nimport_module('.offline', __name__)\n", + False, + ), + ], +) +def test_offline_import_check_resolves_against_the_modules_name(source, offline): + imports = analyse( + source, + module="policyengine_uk.tools.helper", + package="policyengine_uk.tools", + ).imports + assert ( + any(_is_offline(name, EXAMPLE_OFFLINE_MODULES) for _, name in imports) + is offline + ) + + +def test_model_never_imports_offline_modules(): + importers = [ + f"{relative}:{line}" + for relative, module, _, _ in package_modules() + if not _is_offline(module) + for line, name in package_scan().imports[relative] + if _is_offline(name) + ] + assert not importers, ( + "Offline modules are exempt from the draw check only because the " + "model never imports them:\n" + "\n".join(importers) + ) From 741054b167a9f060d56eba9c170227ec9a05e281 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:41:55 +0000 Subject: [PATCH 164/168] Give DWP or the formula the verb in the PR's remaining comments, and scope two readings Case 19's B(0) comment, case 23's guarantee credit comment, the docstring of couple_on_pension_credit and a comment in the combined property no longer give Pension Credit the verb. Three property docstrings say the model keeps each saved report open for 2026-27, since some of their reporters have no claim. The docstring of pension_credit_without_income_related_esa gives the other reading of para 4(2)(a) with DWP as the actor and with para 4(2)(b)'s condition, and the comment on pension_credit's guard says "has an entitlement". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwxAaGSkVVvGDuMZzZR --- .../tests/legacy_award_work_reference.py | 4 ++-- .../esa_income/esa_income_pension_age.yaml | 15 ++++++++------- .../test_esa_income_pension_age_properties.py | 18 +++++++++--------- .../variables/gov/dwp/esa_income_eligible.py | 6 ++++-- .../gov/dwp/pension_credit/pension_credit.py | 4 ++-- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index e63ecfafab..f9f85076b6 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -185,8 +185,8 @@ def couple_on_pension_credit(couple): """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing Benefit reports in 2025 or 2026: a couple every member of which has reached the qualifying age, each of whom has also reached pensionable - age and so has no claim under s.1(3)(c), whether or not Pension Credit - pays.""" + age and so has no claim under s.1(3)(c), whether or not DWP pays the + couple Pension Credit.""" assert not any( adult.get("pension_credit_reported", 0) or adult.get("housing_benefit_reported", 0) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml index e776243db7..6987ebea0d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -709,11 +709,12 @@ # working tax credit in the savings credit's qualifying income (SPC Regs # 2002 reg 9(a); savings_credit_income): 10_000 - 1_040 = 8_960, 172.31 a # week, under the £212.97 couple threshold: nil. - # B(0): Pension Credit on 3_970 would be nil too, so it gives no - # passport. The income test applies: 41% of income over £6,420 comes off, - # 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + 2_502.20 - - # 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) exceeds nil, so - # the younger member fails para 6(1)(d), and the couple gets B(0). + # B(0): the couple would get no Pension Credit on 3_970 either, so it has + # no passport, and HMRC applies the income test: 41% of income over £6,420 + # comes off, 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + + # 2_502.20 - 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) + # exceeds nil, so the younger member fails para 6(1)(d), and the couple + # gets B(0). esa_income_eligible: false esa_income: 0 claimant_or_partner_esa_income: 0 @@ -848,8 +849,8 @@ members: [person1] output: meets_pension_credit_age_conditions: false - # Without the award his income is nil, so the guarantee credit pays the - # minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg + # Without the award his income is nil, so his guarantee credit comes to + # the minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg # 6(1)(b)). Under 65 he gets no savings credit. guarantee_credit: 8_091.20 pension_credit: 8_091.20 diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py index b1f7fe9ea9..9331151624 100644 --- a/policyengine_uk/tests/test_esa_income_pension_age_properties.py +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -428,7 +428,8 @@ def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): assert np.allclose( pension_credit[claims][barred], without_award[barred], atol=0.01 ), units - # Where it pays nothing, take-up changes no award. + # Where the family would get no Pension Credit without the award, take-up + # changes no award. assert np.allclose( couple_esa[claims][refused], couple_esa[declines][refused], atol=0.01 ), units @@ -556,8 +557,8 @@ def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( a pensioner, and a pensioner reporting the award, who has reached pensionable age and so has no claim whatever the couple's Pension Credit. Each reporter has the appointee saving (SI 2025/1148 art. 3A(2)), - so DWP keeps each award in payment after the abolition of income-related - ESA on 1 July 2026, and each award stays payable for the whole year.""" + so the model keeps each report open for the whole of 2026-27 + (esa_income_payable_share is 1).""" log = BranchLog(monkeypatch) year = 2026 adult = {"is_claimant_or_partner": True} @@ -790,9 +791,9 @@ def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): age conditions, so the model reads its Pension Credit and refuses the younger member's award. With only a working-age claimant, no couple meets them, and the model opens no branch, under the reform or without it. Each - reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so DWP keeps - each award in payment after the abolition of income-related ESA on 1 July - 2026, and each award stays payable for the whole year.""" + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the model + keeps each report open for the whole of 2026-27 (esa_income_payable_share + is 1).""" log = BranchLog(monkeypatch) year = 2026 reform = { @@ -1226,9 +1227,8 @@ def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( beside a pensioner, whose own claim passes. The screen then reads neither the Pension Credit age conditions nor the claim. A claimant aged 45 out of work passes, and the screen reads both. Each reporter has the appointee - saving (SI 2025/1148 art. 3A(2)), so DWP keeps each award in payment - after the abolition of income-related ESA on 1 July 2026, and each report - stays open for the whole year.""" + saving (SI 2025/1148 art. 3A(2)), so the model keeps each report open for + the whole of 2026-27 (esa_income_payable_share is 1).""" reads = ScreenReads(monkeypatch) year = 2026 no_couple_claim = [ diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 5acdcb0986..9c00f34dc1 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -104,8 +104,10 @@ def pension_credit_without_income_related_esa(benunit, period): those, the model's result agrees with the reading of para 4(2)(a) under which "£20 was disregarded" means the £20 disregard of Sch 7 paras 6 and 7(1): the family would lose its Pension Credit and get the award back. - On the reading under which any disregard of at least £20 meets para - 4(2)(a), DWP could keep that family on Pension Credit with no award. + On the reading under which para 4(2)(a) holds wherever DWP disregarded + at least £20 of earnings in the award, DWP could keep such a family on + Pension Credit with no award where it disregarded at least £20 of those + earnings and the earner stays in employment (para 4(2)(b)). pension_credit_earnings_disregard does not apply para 4(2), and the model holds no record of how an award started, so the model also pays the award and no Pension Credit to a couple in whose award DWP diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index 3611dd82a1..97295a64ff 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -37,8 +37,8 @@ def formula(benunit, period, parameters): would_claim = benunit("would_claim_pc", period) # The formula pays nothing while the couple has income-related ESA, # so that no couple gets both: Parliament allows the award only where - # neither member of the couple is entitled to State Pension Credit - # (WRA 2007 Sch 1 para 6(1)(c) and (d)). + # neither member of the couple has an entitlement to State Pension + # Credit (WRA 2007 Sch 1 para 6(1)(c) and (d)). couple_esa = benunit("claimant_or_partner_esa_income", period) amount = where(couple_esa > 0, 0, entitlement * would_claim) From 62f5fcad240837189aa74505415e724217075059 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:18:33 +0000 Subject: [PATCH 165/168] Say whose calculated Income Support the #2302 changelog fragment means is_on_income_support gives the benefit unit's calculated award only to whichever of the claimant and partner receives it, so the fragment says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HKeDniUizUaZcFw4Q --- changelog.d/qyp-income-support-exclusion.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md index 5ceecdaf61..8dec0770f0 100644 --- a/changelog.d/qyp-income-support-exclusion.fixed.md +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -1 +1 @@ -Tested that Pension Credit, Child Benefit and Child Tax Credit each stop counting a young person who receives Income Support in their own right as a qualifying young person, and Universal Credit does not (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c); UC Regs 2013 reg. 5(5)), in the baseline and under reforms that remove Income Support. Pension Credit and Child Tax Credit read the Income Support the model calculates for a claimant or partner, so a reform that pays no Income Support ends the exclusion for a claimant or partner; Child Benefit reads the young person's own report while Income Support is in payment. +Tested that Pension Credit, Child Benefit and Child Tax Credit each stop counting a young person who receives Income Support in their own right as a qualifying young person, and Universal Credit does not (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c); UC Regs 2013 reg. 5(5)), in the baseline and under reforms that remove Income Support. Pension Credit and Child Tax Credit read the benefit unit's calculated Income Support for whichever of the claimant and partner receives it, so a reform that pays no Income Support ends the exclusion for that person; Child Benefit reads the young person's own report while Income Support is in payment. From 6f4d3313060987611bd00da46d3f392ffda1013e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 01:35:35 -0500 Subject: [PATCH 166/168] Correct the guard's comments and add two sys.modules test rows The guard's docstring now says it looks for draws and not for the clock (#2875). The comments on the splitmix64 and Universal Credit entries say which constants the scan recognises, which entries reach the hash and how Simulation.__init__ applies the modifier. Two test rows follow PolicyEngine/policyengine-us#10447 at 221f2fe44, and a changelog fragment records the guard. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2874.changed.md | 1 + .../test_no_engine_time_randomness.py | 43 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 changelog.d/2874.changed.md diff --git a/changelog.d/2874.changed.md b/changelog.d/2874.changed.md new file mode 100644 index 0000000000..aaf800f40b --- /dev/null +++ b/changelog.d/2874.changed.md @@ -0,0 +1 @@ +The test suite fails on any new random draw, sampling call or hash of entity ids in the package, and lists today's draws in a ratchet. diff --git a/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py index 5b38e94666..6c60b15e90 100644 --- a/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py +++ b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py @@ -1,18 +1,18 @@ """The model draws nothing while a simulation is built or run. A formula is a pure, deterministic function of its inputs -(policyengine-core#500), and seeding does not exempt a draw. Stochastic -inputs such as take-up flags are drawn when the dataset is built and stored -as input variables; the model compares them with its rules. policyengine-core#519 checks formulas -for numpy and stdlib randomness when variables register, but a hand-written -integer hash of entity ids passes that check, so this scan looks for the -hash as well. +(policyengine-core#500), and seeding does not exempt a draw. The dataset +build draws stochastic inputs such as take-up flags and stores them as input +variables; the model compares them with its rules. policyengine-core#519 +checks formulas for numpy and stdlib randomness when variables register, but +a hand-written integer hash of entity ids passes that check, so this scan +looks for the hash as well. Every module of the package except tests is parsed (formulas, reforms, -scenarios, simulation code, data loading, uprating and utilities). A name resolves to -everything it may refer to where it is used: the targets of every binding -that may be the one read, in each scope Python looks in, so the scan errs -toward reporting. Bindings come from imports, named imports and re-exports +scenarios, simulation code, data loading, uprating and utilities). A name +resolves to everything it may refer to where it is used: the targets of every +binding that may be the one read, in each scope Python looks in, so the scan +errs toward reporting. Bindings come from imports, named imports and re-exports followed across the package, star imports, assignments of a name or attribute chain (``xp = np``, also in tuples), default arguments, ``global`` and ``nonlocal`` (the nearest enclosing function that binds the name, @@ -144,8 +144,9 @@ class body it is nested in binds, or its ``global`` and ``nonlocal`` the values calls return (``r = importlib.import_module("random")`` is caught at the import; a later ``r.random()`` is not), names ``match`` patterns capture, or methods as draw -helpers. A lookup that nests deeper than MAX_RESOLUTION_DEPTH fails the scan -rather than passing unresolved. +helpers. It looks for draws, not for other reads that differ between runs, +such as the clock (policyengine-uk#2875). A lookup that nests deeper than +MAX_RESOLUTION_DEPTH fails the scan rather than passing unresolved. EXEMPT lists reviewed occurrences that are not draws; RATCHET lists today's engine-time draws that still have to move into the data. Both count exact @@ -170,9 +171,10 @@ class body it is nested in binds, or its ``global`` and ``nonlocal`` TESTS = PACKAGE / "tests" RATCHET = { - # The splitmix64 finalizer that maps entity ids to uniform draws on - # [0, 1): its three mixing constants and three xorshift steps. Every - # other entry calls it, or calls what calls it. + # The splitmix64 finaliser that maps entity ids to uniform draws on + # [0, 1): the three splitmix64 constants the scan recognises and three + # xorshift steps. The formula, uprating and simulation loading entries + # below call it, or call what calls it. "utils/stochastic.py": { ("splitmix64_uniform", "hash constant"): 3, ("splitmix64_uniform", "xorshift"): 3, @@ -219,9 +221,11 @@ class body it is nested in binds, or its ``global`` and ``nonlocal`` "dynamics/participation.py": { ("apply_participation_responses", "random source"): 1, }, - # UC rebalancing scenario: a seeded numpy Generator draws one value per - # benefit unit and compares it with the new claimant share; the Scenario - # at module level passes on the modifier. + # UC rebalancing: a seeded numpy Generator draws one value per benefit + # unit and compares it with the new claimant share. Simulation.__init__ + # applies it to every simulation through the Scenario object, a call the + # scan does not follow; the Scenario at module level passes on the + # modifier. "scenarios/uc_reform.py": { ("add_universal_credit_reform", "random source"): 1, ("", "draw helper call"): 1, @@ -3777,6 +3781,9 @@ def test_a_computed_all_may_name_any_module_below_its_package(): ("import pkg\ng = getattr(pkg, 'a')\n", {"pkg.a"}, set()), ("import sys\nsys.modules['pkg.a'].f()\n", {"pkg.a"}, set()), ("from sys import modules\nmodules.get('pkg.a').f()\n", {"pkg.a"}, set()), + # sys.modules holds the module itself, not its top package. + ("import sys\nsys.modules['pkg.sub'].a.f()\n", {"pkg.sub.a"}, set()), + ("import sys\nsys.modules.get('pkg.sub').a.f()\n", {"pkg.sub.a"}, set()), ("f = lambda: __import__('pkg').a.f()\n", set(), {"pkg.a"}), # __import__ returns the top package; import_module the module. ("__import__('pkg.sub').a.f()\n", {"pkg.a", "pkg.sub.a"}, set()), From f56b53b96df7e3500f97092a2890df750b1cf1b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:58:03 +0000 Subject: [PATCH 167/168] Price real incomes to a fixed base year inflation_adjustment and inflation_adjustment_ahc read the base year from gov.simulation.price_base_year instead of the clock, and inflation_adjustment_ahc uses one index on both dates. A code-health test fails on any clock read under policyengine_uk/variables. Fixes #2875 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KUHRad4GgGptdPAWP --- changelog.d/2875.fixed.md | 1 + .../gov/simulation/price_base_year.yaml | 8 + .../test_formulas_do_not_read_the_clock.py | 206 +++++++++++++++ policyengine_uk/tests/test_price_base_year.py | 242 ++++++++++++++++++ .../household/income/inflation_adjustment.py | 53 ++-- .../income/real_household_net_income.py | 3 +- .../income/real_household_net_income_ahc.py | 3 +- 7 files changed, 493 insertions(+), 23 deletions(-) create mode 100644 changelog.d/2875.fixed.md create mode 100644 policyengine_uk/parameters/gov/simulation/price_base_year.yaml create mode 100644 policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py create mode 100644 policyengine_uk/tests/test_price_base_year.py diff --git a/changelog.d/2875.fixed.md b/changelog.d/2875.fixed.md new file mode 100644 index 0000000000..4f36797bf2 --- /dev/null +++ b/changelog.d/2875.fixed.md @@ -0,0 +1 @@ +The real income variables now price to the year in gov.simulation.price_base_year (2026) instead of the year the simulation runs, and inflation_adjustment_ahc uses one price index on both sides of its ratio. diff --git a/policyengine_uk/parameters/gov/simulation/price_base_year.yaml b/policyengine_uk/parameters/gov/simulation/price_base_year.yaml new file mode 100644 index 0000000000..7bdf4352f9 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/price_base_year.yaml @@ -0,0 +1,8 @@ +description: Calendar year whose prices the real income variables use. inflation_adjustment and inflation_adjustment_ahc divide the price index on 1 January of this year by the index in the period, so a simulation gives the same real incomes whatever year it runs in. A modelling convention with no legislation behind it. +values: + 0000-01-01: 2026 +metadata: + unit: year + period: year + economy: false + label: Price base year for real incomes diff --git a/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py new file mode 100644 index 0000000000..9dafa5fa88 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py @@ -0,0 +1,206 @@ +"""No module under ``policyengine_uk/variables`` may read the clock. + +A variable whose formula, label or class body reads the wall clock gives +different answers for the same inputs depending on when the simulation runs +(#2875: ``inflation_adjustment`` priced real incomes to the year +``datetime.datetime.now()`` returned). A formula that needs a reference year +reads it from the parameters instead, as the real income variables read +``gov.simulation.price_base_year``. + +The test parses every module under ``policyengine_uk/variables`` with ``ast`` +and fails on any expression that names a clock function, at module level, in +a class body or in a formula, whether the code calls it or only refers to it +(``default=datetime.now`` counts). It resolves ``import x``, ``import x as y``, +``from x import y`` and ``from x import y as z`` at module level and in nested +scopes, so ``import datetime as dt; dt.datetime.now()`` and +``from time import time as clock; clock()`` both fail. An ``import`` that +nothing reads the clock through passes. + +The scan stays under ``variables``. Outside it, +``utils/aggregate_impact/measure.py`` times its own stages with ``time.time``, +which changes no output. + +Limits: the detector does not follow names rebound by assignment +(``now = datetime.now``; a later ``now()`` passes, though the assignment +itself fails), ``getattr`` with a string, ``__import__``, ``importlib``, or a +clock read inside a helper module outside ``variables`` that a formula calls. +It treats an imported name as bound for the whole module, so a local variable +that shadows an import can raise a false alarm. ``time.localtime``, +``time.gmtime``, ``time.ctime`` and ``time.asctime`` read the clock only when +called without a time argument, and ``time.strftime`` only with one argument, +so the detector flags those forms only. It does not inspect string arguments, +so ``numpy.datetime64("now")`` and ``pandas.Timestamp("now")`` pass. +""" + +import ast +from pathlib import Path + +import pytest + +VARIABLES_ROOT = Path(__file__).parents[2] / "variables" +PACKAGE_ROOT = VARIABLES_ROOT.parent + +# Functions that read the clock whenever the code names them. +CLOCK_FUNCTIONS = { + "datetime.datetime.now", + "datetime.datetime.today", + "datetime.datetime.utcnow", + "datetime.date.today", + "time.time", + "time.time_ns", + "time.monotonic", + "time.monotonic_ns", + "time.perf_counter", + "time.perf_counter_ns", + "time.process_time", + "time.process_time_ns", + "time.thread_time", + "time.thread_time_ns", + "pandas.Timestamp.now", + "pandas.Timestamp.today", + "pandas.Timestamp.utcnow", +} + +# Functions that read the clock only when called with at most this many +# positional arguments and no keyword arguments. +CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT = { + "time.localtime": 0, + "time.gmtime": 0, + "time.ctime": 0, + "time.asctime": 0, + "time.strftime": 1, +} + + +def _import_aliases(tree: ast.AST) -> dict[str, str]: + """Map each name an import binds to the dotted name it stands for.""" + aliases = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + aliases[alias.asname] = alias.name + else: + root = alias.name.split(".")[0] + aliases[root] = root + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + for alias in node.names: + if alias.name == "*": + continue + aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}" + return aliases + + +def _qualified_name(node: ast.AST, aliases: dict[str, str]) -> str | None: + """The dotted name a ``Name`` or ``Attribute`` chain resolves to.""" + attributes = [] + while isinstance(node, ast.Attribute): + attributes.append(node.attr) + node = node.value + if not isinstance(node, ast.Name) or node.id not in aliases: + return None + return ".".join([aliases[node.id], *reversed(attributes)]) + + +def clock_reads(source: str) -> list[tuple[int, str]]: + """Each clock read in ``source`` as (line, dotted function name).""" + tree = ast.parse(source) + aliases = _import_aliases(tree) + reads = [] + conditional = {} + for node in ast.walk(tree): + if isinstance(node, ast.Call): + name = _qualified_name(node.func, aliases) + limit = CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT.get(name) + if limit is not None: + conditional[id(node.func)] = ( + len(node.args) <= limit + and not node.keywords + and not any(isinstance(arg, ast.Starred) for arg in node.args) + ) + for node in ast.walk(tree): + if not isinstance(node, (ast.Name, ast.Attribute)): + continue + if isinstance(node.ctx, (ast.Store, ast.Del)): + continue + name = _qualified_name(node, aliases) + if name in CLOCK_FUNCTIONS: + reads.append((node.lineno, name)) + elif name in CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT: + if conditional.get(id(node), False): + reads.append((node.lineno, name)) + return sorted(reads) + + +DETECTOR_CASES = [ + # Reads the clock. + ("import datetime\nx = datetime.datetime.now()", ["datetime.datetime.now"]), + ("import datetime\nx = datetime.date.today()", ["datetime.date.today"]), + ( + "from datetime import datetime\nx = datetime.utcnow()", + ["datetime.datetime.utcnow"], + ), + ( + "from datetime import datetime\nx = datetime.today()", + ["datetime.datetime.today"], + ), + ("from datetime import date\nx = date.today()", ["datetime.date.today"]), + ("import datetime as dt\nx = dt.datetime.now().year", ["datetime.datetime.now"]), + ("from datetime import datetime as D\nx = D.now()", ["datetime.datetime.now"]), + ("import time\nx = time.time()", ["time.time"]), + ("import time\nx = time.time_ns()", ["time.time_ns"]), + ("from time import time\nx = time()", ["time.time"]), + ("from time import time_ns as clock\nx = clock()", ["time.time_ns"]), + ("import time as t\nx = t.perf_counter()", ["time.perf_counter"]), + ("import time\nx = time.localtime()", ["time.localtime"]), + ("import time\nx = time.strftime('%Y')", ["time.strftime"]), + ("import pandas as pd\nx = pd.Timestamp.now()", ["pandas.Timestamp.now"]), + # A reference without a call still reads the clock when used. + ("from datetime import datetime\nf = datetime.now", ["datetime.datetime.now"]), + ( + "import datetime\n" + "class v:\n" + " label = f'{datetime.datetime.now().year} prices'\n", + ["datetime.datetime.now"], + ), + ( + "class v:\n" + " def formula(household, period, parameters):\n" + " import datetime\n" + " return datetime.datetime.now().year\n", + ["datetime.datetime.now"], + ), + # Does not read the clock. + ("import datetime\nimport time\nimport numpy as np", []), + ("import datetime\nx = datetime.date(2026, 1, 1)", []), + ("import datetime\nx = datetime.datetime(2026, 1, 1).year", []), + ("from datetime import date\nx = date.fromisoformat('2026-01-01')", []), + ("import time\nx = time.localtime(0)", []), + ("import time\nx = time.strftime('%Y', (2026, 1, 1, 0, 0, 0, 0, 1, 0))", []), + ("now = 1\nx = now", []), + ("x = household.now()\ny = parameters.time", []), +] + + +@pytest.mark.parametrize( + "source, expected", + DETECTOR_CASES, + ids=[f"case-{i}" for i in range(len(DETECTOR_CASES))], +) +def test_detector(source, expected): + assert [name for _, name in clock_reads(source)] == expected + + +def test_variables_do_not_read_the_clock(): + paths = sorted(VARIABLES_ROOT.rglob("*.py")) + assert len(paths) > 100, "The scan found too few variable modules." + found = {} + for path in paths: + reads = clock_reads(path.read_text()) + if reads: + found[str(path.relative_to(PACKAGE_ROOT))] = reads + assert not found, ( + "These modules under policyengine_uk/variables read the clock, so " + "their variables change with the date a simulation runs. Read a " + f"reference date from the parameters instead: {found}" + ) diff --git a/policyengine_uk/tests/test_price_base_year.py b/policyengine_uk/tests/test_price_base_year.py new file mode 100644 index 0000000000..22cf880c63 --- /dev/null +++ b/policyengine_uk/tests/test_price_base_year.py @@ -0,0 +1,242 @@ +"""Real incomes use the prices of gov.simulation.price_base_year (#2875). + +``inflation_adjustment`` divides ``consumer_price_index`` on 1 January of the +base year by the index in the period. ``inflation_adjustment_ahc`` divides one +index on 1 January of the base year by the same index in the period: it uses +``consumer_price_index_ahc`` where that index has a value on both dates, and +``consumer_price_index`` on both dates otherwise. No formula reads the clock, +so a simulation gives the same answers whatever year it runs in. + +Invariants: + +1. Identity: each adjustment equals 1 when the period equals the base year, + for every base year from 2009, where the CPI index starts. For + ``inflation_adjustment_ahc`` that includes base years before 2022. +2. Positivity: each adjustment stays positive wherever the indices have + values. +3. Rebasing: for any two base years, the ratio of the two adjustments does + not depend on the period. It holds for ``inflation_adjustment`` from 2009 + and for ``inflation_adjustment_ahc`` across periods and base years from + 2022. +4. The four real income variables equal their nominal income times + ``inflation_adjustment``. + +The property tests set the base year on a fresh simulation's own parameter +tree, which is faster than a reform; ``test_tree_update_matches_reform`` +checks that the two routes agree. +""" + +import functools + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import instant + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +INDICES = system.parameters.gov.economic_assumptions.indices.obr +CPI = INDICES.consumer_price_index +CPI_AHC = INDICES.consumer_price_index_ahc +BASE_YEAR = "gov.simulation.price_base_year" +DEFAULT_BASE_YEAR = 2026 + +SITUATION = { + "people": { + "adult": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}, + }, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": {"household": {"members": ["adult"]}}, +} + +# Base years the property tests draw from: the first CPI year, years before +# and within the stretch where the AHC index reads 1, the default, a forecast +# year and a year after the last index value. +BASE_YEARS = [2009, 2014, 2018, 2022, 2026, 2031, 2080] +PERIODS = st.integers(min_value=2009, max_value=2080) + +PROPERTY_SETTINGS = settings( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + +REAL_INCOMES = { + "real_household_net_income": "household_net_income", + "real_household_net_income_ahc": "household_net_income_ahc", + "real_hbai_household_net_income": "hbai_household_net_income", + "real_hbai_household_net_income_ahc": "hbai_household_net_income_ahc", +} + + +def cpi(year): + return CPI(f"{year}-01-01") + + +@functools.lru_cache(maxsize=None) +def simulation_at_base_year(base_year): + """A fresh simulation whose own parameter tree prices to ``base_year``.""" + sim = Simulation(situation=SITUATION) + sim.tax_benefit_system.parameters.get_child(BASE_YEAR).update( + start=instant("2000-01-01"), stop=instant("2100-12-31"), value=base_year + ) + sim.tax_benefit_system._parameters_at_instant_cache.clear() + return sim + + +@functools.lru_cache(maxsize=None) +def baseline_simulation(): + return Simulation(situation=SITUATION) + + +@pytest.fixture(scope="module", autouse=True) +def release_simulations(): + yield + simulation_at_base_year.cache_clear() + baseline_simulation.cache_clear() + + +def adjustment(variable, base_year, period): + sim = simulation_at_base_year(base_year) + return float(sim.calculate(variable, period)[0]) + + +# Example tests. + + +def test_default_base_year(): + parameter = system.parameters.get_child(BASE_YEAR) + for year in (2009, 2015, 2026, 2027, 2050, 2100): + assert parameter(f"{year}-01-01") == DEFAULT_BASE_YEAR + + +@pytest.mark.parametrize( + "period", [2009, 2014, 2015, 2021, 2022, 2026, 2027, 2030, 2074, 2080] +) +def test_inflation_adjustment_against_cpi(period): + expected = cpi(DEFAULT_BASE_YEAR) / cpi(period) + actual = baseline_simulation().calculate("inflation_adjustment", period) + assert actual[0] == pytest.approx(expected, rel=1e-6) + + +def test_ahc_adjustment_uses_one_index(): + sim = baseline_simulation() + # Both dates have an AHC index value. + for period in (2022, 2027, 2030): + expected = CPI_AHC("2026-01-01") / CPI_AHC(f"{period}-01-01") + actual = sim.calculate("inflation_adjustment_ahc", period)[0] + assert actual == pytest.approx(expected, rel=1e-6) + # The AHC index has no value in 2012, so CPI prices both dates. + assert CPI_AHC("2012-01-01") is None + actual = sim.calculate("inflation_adjustment_ahc", 2012)[0] + assert actual == pytest.approx(cpi(2026) / cpi(2012), rel=1e-6) + + +def test_reform_changes_the_base_year(): + # A reform can pass the year as a float. + reformed = Simulation( + situation=SITUATION, + reform={BASE_YEAR: {"2000-01-01.2100-12-31": 2030.0}}, + ) + baseline = baseline_simulation() + adjustment_2026 = reformed.calculate("inflation_adjustment", 2026)[0] + assert adjustment_2026 == pytest.approx(cpi(2030) / cpi(2026), rel=1e-6) + assert reformed.calculate("inflation_adjustment", 2030)[0] == pytest.approx(1) + assert reformed.calculate("inflation_adjustment_ahc", 2030)[0] == pytest.approx(1) + assert reformed.calculate("real_household_net_income", 2026)[0] == pytest.approx( + baseline.calculate("household_net_income", 2026)[0] * cpi(2030) / cpi(2026), + rel=1e-5, + ) + assert baseline.calculate("inflation_adjustment", 2026)[0] == pytest.approx(1) + + +def test_tree_update_matches_reform(): + reformed = Simulation( + situation=SITUATION, + reform={BASE_YEAR: {"2000-01-01.2100-12-31": 2031}}, + ) + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + for period in (2012, 2026, 2040): + assert adjustment(variable, 2031, period) == pytest.approx( + reformed.calculate(variable, period)[0], rel=1e-7 + ) + + +def test_labels_name_no_year(): + for name in ( + "inflation_adjustment", + "inflation_adjustment_ahc", + "real_household_net_income", + "real_household_net_income_ahc", + ): + label = system.variables[name].label + assert "base-year" in label + assert not any(char.isdigit() for char in label), label + assert ( + system.variables["real_household_net_income"].label + != system.variables["real_household_net_income_ahc"].label + ) + + +# Property tests. + + +@PROPERTY_SETTINGS +@given(base_year=st.sampled_from(BASE_YEARS)) +def test_identity_in_the_base_year(base_year): + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + assert adjustment(variable, base_year, base_year) == pytest.approx(1) + + +@PROPERTY_SETTINGS +@given(base_year=st.sampled_from(BASE_YEARS), period=PERIODS) +def test_positive(base_year, period): + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + value = adjustment(variable, base_year, period) + assert np.isfinite(value) and value > 0 + + +@PROPERTY_SETTINGS +@given( + base_years=st.lists(st.sampled_from(BASE_YEARS), min_size=2, max_size=2), + periods=st.lists(PERIODS, min_size=2, max_size=2), +) +def test_rebasing_does_not_depend_on_the_period(base_years, periods): + (b1, b2), (p1, p2) = base_years, periods + variable = "inflation_adjustment" + ratio_1 = adjustment(variable, b1, p1) / adjustment(variable, b2, p1) + ratio_2 = adjustment(variable, b1, p2) / adjustment(variable, b2, p2) + assert ratio_1 == pytest.approx(ratio_2, rel=1e-6) + assert ratio_1 == pytest.approx(cpi(b1) / cpi(b2), rel=1e-6) + + +@PROPERTY_SETTINGS +@given( + base_years=st.lists( + st.sampled_from([b for b in BASE_YEARS if b >= 2022]), min_size=2, max_size=2 + ), + periods=st.lists(st.integers(2022, 2080), min_size=2, max_size=2), +) +def test_ahc_rebasing_does_not_depend_on_the_period(base_years, periods): + (b1, b2), (p1, p2) = base_years, periods + variable = "inflation_adjustment_ahc" + ratio_1 = adjustment(variable, b1, p1) / adjustment(variable, b2, p1) + ratio_2 = adjustment(variable, b1, p2) / adjustment(variable, b2, p2) + assert ratio_1 == pytest.approx(ratio_2, rel=1e-6) + + +@settings(PROPERTY_SETTINGS, max_examples=8) +@given( + base_year=st.sampled_from(BASE_YEARS), + period=st.sampled_from([2024, 2026, 2028]), +) +def test_real_income_is_nominal_times_adjustment(base_year, period): + sim = simulation_at_base_year(base_year) + multiplier = sim.calculate("inflation_adjustment", period)[0] + for real, nominal in REAL_INCOMES.items(): + assert sim.calculate(real, period)[0] == pytest.approx( + sim.calculate(nominal, period)[0] * multiplier, rel=1e-5, abs=0.01 + ) diff --git a/policyengine_uk/variables/household/income/inflation_adjustment.py b/policyengine_uk/variables/household/income/inflation_adjustment.py index 996763f91e..8a01dbf2ff 100644 --- a/policyengine_uk/variables/household/income/inflation_adjustment.py +++ b/policyengine_uk/variables/household/income/inflation_adjustment.py @@ -1,10 +1,22 @@ from policyengine_uk.model_api import * -import datetime -import numpy as np + + +def price_base_date(parameters, period) -> str: + """1 January of gov.simulation.price_base_year, as a date string. + + A reform can set the year as a float, so the formula takes its integer + part before it builds the date. + """ + year = int(parameters(period).gov.simulation.price_base_year) + return f"{year:04d}-01-01" class inflation_adjustment(Variable): - label = f"inflation multiplier to get {datetime.datetime.now().year} prices" + label = "inflation multiplier to get base-year prices" + documentation = ( + "Consumer price index on 1 January of gov.simulation.price_base_year " + "divided by the index in the period." + ) entity = Household definition_period = YEAR value_type = float @@ -12,28 +24,31 @@ class inflation_adjustment(Variable): def formula(household, period, parameters): cpi = parameters.gov.economic_assumptions.indices.obr.consumer_price_index - current_period_cpi = cpi(period) - now_cpi = cpi(datetime.datetime.now().strftime("%Y-01-01")) - return now_cpi / current_period_cpi + ratio = cpi(price_base_date(parameters, period)) / cpi(period) + return household.empty_array() + ratio class inflation_adjustment_ahc(Variable): - label = f"inflation multiplier to get {datetime.datetime.now().year} after housing costs prices" + label = "inflation multiplier to get base-year after housing costs prices" + documentation = ( + "Ratio of one price index on 1 January of " + "gov.simulation.price_base_year to the same index in the period. The " + "formula uses consumer_price_index_ahc where that index has a value " + "on both dates, and consumer_price_index on both dates otherwise." + ) entity = Household definition_period = YEAR value_type = float unit = "/1" def formula(household, period, parameters): - cpi = parameters.gov.economic_assumptions.indices.obr.consumer_price_index - cpi_ahc = ( - parameters.gov.economic_assumptions.indices.obr.consumer_price_index_ahc - ) - - cpi_ahc_val = cpi_ahc(period) - # Fall back to use cpi, for periods beyond 2029 - if not cpi_ahc_val: - cpi_ahc_val = cpi(period) - - now_cpi_ahc = cpi(datetime.datetime.now().strftime("%Y-01-01")) - return now_cpi_ahc / cpi_ahc_val + indices = parameters.gov.economic_assumptions.indices.obr + base_date = price_base_date(parameters, period) + ahc_base = indices.consumer_price_index_ahc(base_date) + ahc_period = indices.consumer_price_index_ahc(period) + if ahc_base is not None and ahc_period is not None: + ratio = ahc_base / ahc_period + else: + cpi = indices.consumer_price_index + ratio = cpi(base_date) / cpi(period) + return household.empty_array() + ratio diff --git a/policyengine_uk/variables/household/income/real_household_net_income.py b/policyengine_uk/variables/household/income/real_household_net_income.py index d762af06d9..e12174fc35 100644 --- a/policyengine_uk/variables/household/income/real_household_net_income.py +++ b/policyengine_uk/variables/household/income/real_household_net_income.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * -import datetime import numpy as np class real_household_net_income(Variable): - label = f"real household net income ({datetime.datetime.now().year} prices)" + label = "real household net income (base-year prices)" entity = Household definition_period = YEAR value_type = float diff --git a/policyengine_uk/variables/household/income/real_household_net_income_ahc.py b/policyengine_uk/variables/household/income/real_household_net_income_ahc.py index 5cdd1c50e3..c25e4b80c9 100644 --- a/policyengine_uk/variables/household/income/real_household_net_income_ahc.py +++ b/policyengine_uk/variables/household/income/real_household_net_income_ahc.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * -import datetime import numpy as np class real_household_net_income_ahc(Variable): - label = f"real household net income ({datetime.datetime.now().year} prices)" + label = "real household net income after housing costs (base-year prices)" entity = Household definition_period = YEAR value_type = float From 83e286b3e6c05f9bf45bef5e4ed64fd71ceb41e3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 07:03:15 -0500 Subject: [PATCH 168/168] Resolve the clock guard's import aliases in their lexical scope A function-local import overwrote a module-level one in the flat alias map, so `import math as datetime` inside a helper hid a module-level datetime.datetime.now(). Each name now resolves in the innermost scope that imports it, and a class body's imports reach only that class body. Co-Authored-By: Claude Opus 5.5 --- .../test_formulas_do_not_read_the_clock.py | 193 ++++++++++++++---- 1 file changed, 153 insertions(+), 40 deletions(-) diff --git a/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py index 9dafa5fa88..805fb1a246 100644 --- a/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py +++ b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py @@ -16,6 +16,12 @@ ``from time import time as clock; clock()`` both fail. An ``import`` that nothing reads the clock through passes. +Each name resolves in the innermost scope that imports it, as in Python: a +function's ``import math as datetime`` leaves the module's ``datetime`` alone, +and a class body's imports reach only that class body, not its methods. +Decorators, defaults, annotations and base classes resolve in the enclosing +scope. + The scan stays under ``variables``. Outside it, ``utils/aggregate_impact/measure.py`` times its own stages with ``time.time``, which changes no output. @@ -24,8 +30,11 @@ (``now = datetime.now``; a later ``now()`` passes, though the assignment itself fails), ``getattr`` with a string, ``__import__``, ``importlib``, or a clock read inside a helper module outside ``variables`` that a formula calls. -It treats an imported name as bound for the whole module, so a local variable -that shadows an import can raise a false alarm. ``time.localtime``, +It treats an imported name as bound for the whole scope that imports it, +wherever the import sits in that scope, so a local variable or argument that +shadows an import can raise a false alarm, and two imports of one name in one +scope both count. It reads ``global`` and ``nonlocal`` imports as local to their +function. ``time.localtime``, ``time.gmtime``, ``time.ctime`` and ``time.asctime`` read the clock only when called without a time argument, and ``time.strftime`` only with one argument, so the detector flags those forms only. It does not inspect string arguments, @@ -72,63 +81,95 @@ } -def _import_aliases(tree: ast.AST) -> dict[str, str]: - """Map each name an import binds to the dotted name it stands for.""" - aliases = {} - for node in ast.walk(tree): +# Nodes whose bodies Python runs in a scope of their own. +SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + + +def _scope_imports(scope: ast.AST) -> dict[str, set[str]]: + """Map each name an import directly in ``scope`` binds to the dotted names + it can stand for. A nested scope's imports bind names of its own.""" + aliases: dict[str, set[str]] = {} + pending = list(ast.iter_child_nodes(scope)) + while pending: + node = pending.pop() if isinstance(node, ast.Import): for alias in node.names: if alias.asname: - aliases[alias.asname] = alias.name + aliases.setdefault(alias.asname, set()).add(alias.name) else: root = alias.name.split(".")[0] - aliases[root] = root - elif isinstance(node, ast.ImportFrom) and node.module and not node.level: - for alias in node.names: - if alias.name == "*": - continue - aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}" + aliases.setdefault(root, set()).add(root) + elif isinstance(node, ast.ImportFrom): + if node.module and not node.level: + for alias in node.names: + if alias.name != "*": + aliases.setdefault(alias.asname or alias.name, set()).add( + f"{node.module}.{alias.name}" + ) + elif not isinstance(node, SCOPE_NODES): + pending.extend(ast.iter_child_nodes(node)) return aliases -def _qualified_name(node: ast.AST, aliases: dict[str, str]) -> str | None: - """The dotted name a ``Name`` or ``Attribute`` chain resolves to.""" +def _qualified_names(node: ast.AST, scopes: list[dict[str, set[str]]]) -> set[str]: + """The dotted names a ``Name`` or ``Attribute`` chain can stand for, from + the innermost of ``scopes`` that imports its root.""" attributes = [] while isinstance(node, ast.Attribute): attributes.append(node.attr) node = node.value - if not isinstance(node, ast.Name) or node.id not in aliases: - return None - return ".".join([aliases[node.id], *reversed(attributes)]) + if not isinstance(node, ast.Name): + return set() + for aliases in reversed(scopes): + if node.id in aliases: + return { + ".".join([target, *reversed(attributes)]) for target in aliases[node.id] + } + return set() def clock_reads(source: str) -> list[tuple[int, str]]: - """Each clock read in ``source`` as (line, dotted function name).""" + """Each clock read in ``source`` as (line, dotted function name). + + The walk carries two lists of import scopes for each node: the ones its + own names resolve in, and the ones a scope nested in it can see. Python + hides a class body's names from the scopes nested in that class.""" tree = ast.parse(source) - aliases = _import_aliases(tree) + module = [_scope_imports(tree)] reads = [] conditional = {} - for node in ast.walk(tree): - if isinstance(node, ast.Call): - name = _qualified_name(node.func, aliases) - limit = CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT.get(name) - if limit is not None: - conditional[id(node.func)] = ( - len(node.args) <= limit - and not node.keywords - and not any(isinstance(arg, ast.Starred) for arg in node.args) - ) - for node in ast.walk(tree): - if not isinstance(node, (ast.Name, ast.Attribute)): - continue - if isinstance(node.ctx, (ast.Store, ast.Del)): + pending = [(tree, module, module)] + while pending: + node, scopes, visible = pending.pop() + if isinstance(node, SCOPE_NODES): + own = [*visible, _scope_imports(node)] + nested = visible if isinstance(node, ast.ClassDef) else own + body = node.body if isinstance(node.body, list) else [node.body] + for child in ast.iter_child_nodes(node): + if any(child is statement for statement in body): + pending.append((child, own, nested)) + else: + pending.append((child, scopes, visible)) continue - name = _qualified_name(node, aliases) - if name in CLOCK_FUNCTIONS: - reads.append((node.lineno, name)) - elif name in CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT: - if conditional.get(id(node), False): - reads.append((node.lineno, name)) + if isinstance(node, ast.Call): + for name in _qualified_names(node.func, scopes): + limit = CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT.get(name) + if limit is not None: + conditional[id(node.func)] = ( + len(node.args) <= limit + and not node.keywords + and not any(isinstance(arg, ast.Starred) for arg in node.args) + ) + elif isinstance(node, (ast.Name, ast.Attribute)) and not isinstance( + node.ctx, (ast.Store, ast.Del) + ): + for name in _qualified_names(node, scopes): + if name in CLOCK_FUNCTIONS: + reads.append((node.lineno, name)) + elif name in CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT: + if conditional.get(id(node), False): + reads.append((node.lineno, name)) + pending.extend((child, scopes, visible) for child in ast.iter_child_nodes(node)) return sorted(reads) @@ -170,6 +211,53 @@ def clock_reads(source: str) -> list[tuple[int, str]]: " return datetime.datetime.now().year\n", ["datetime.datetime.now"], ), + # A function's import leaves the module's binding alone. + ( + "import datetime\n" + "def helper():\n" + " import math as datetime\n" + " return datetime.sqrt(4)\n" + "x = datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), + # A function's import rebinds the name inside that function. + ( + "import math as datetime\n" + "def formula():\n" + " import datetime\n" + " return datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), + # A nested function sees the enclosing function's import. + ( + "def outer():\n" + " import datetime\n" + " def inner():\n" + " return datetime.datetime.now()\n" + " return inner\n", + ["datetime.datetime.now"], + ), + # A class body sees its own import, and a lambda sees the module's. + ( + "class v:\n" + " import datetime\n" + " label = f'{datetime.datetime.now().year} prices'\n", + ["datetime.datetime.now"], + ), + ("import time\nf = lambda: time.time()", ["time.time"]), + # A default runs in the enclosing scope, before the function's import. + ( + "import datetime\n" + "def formula(now=datetime.datetime.now):\n" + " import math as datetime\n" + " return datetime.sqrt(now())\n", + ["datetime.datetime.now"], + ), + # Two imports of one name in one scope both count. + ( + "import datetime\nimport math as datetime\nx = datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), # Does not read the clock. ("import datetime\nimport time\nimport numpy as np", []), ("import datetime\nx = datetime.date(2026, 1, 1)", []), @@ -178,6 +266,31 @@ def clock_reads(source: str) -> list[tuple[int, str]]: ("import time\nx = time.localtime(0)", []), ("import time\nx = time.strftime('%Y', (2026, 1, 1, 0, 0, 0, 0, 1, 0))", []), ("now = 1\nx = now", []), + # A function's import binds nothing outside that function. + ( + "def helper():\n" + " import datetime\n" + " return datetime.date(2026, 1, 1)\n" + "x = datetime.datetime.now()\n", + [], + ), + # A function's import hides the module's binding inside that function. + ( + "import datetime\n" + "def helper():\n" + " import math as datetime\n" + " return datetime.datetime.now()\n", + [], + ), + # A class body's import does not reach its methods. + ( + "import math as datetime\n" + "class v:\n" + " import datetime\n" + " def formula():\n" + " return datetime.datetime.now()\n", + [], + ), ("x = household.now()\ny = parameters.time", []), ]